2025, Oct 05 19:33

OAuth2 между Flask и Nextcloud: почему Authlib падает на обмене кода и как обойти 500

Разбираем OAuth2 между Nextcloud и Flask: Authlib падает с 500 при обмене кода на токен. Покажем рабочий поток на requests и ключевые проверки конфигурации.

OAuth2 между приложением на Flask и экземпляром Nextcloud должен проходить без драм: перенаправляем пользователя на авторизацию, получаем authorization code, меняем его на access token — и переходим к вызовам API. На практике именно обмен кода на токен иногда подводит. Ниже — реальный сценарий: код на Authlib доходил до callback без проблем, но затем стабильно падал на запросе токена с ответом 500 от провайдера; при этом минимальная реализация на чистом requests работала от начала до конца и показала, что с провайдером всё в порядке.

Как выглядит сбойный сценарий

Приложение — сервис Flask, настроенный с NEXTCLOUD_CLIENT_ID, NEXTCLOUD_SECRET, NEXTCLOUD_API_BASE_URL, NEXTCLOUD_AUTHORIZE_URL и NEXTCLOUD_ACCESS_TOKEN_URL. Редирект на Nextcloud проходит, пользователь подтверждает доступ, и Nextcloud возвращает state и code. Сбой происходит на этапе обмена кода на access token.

import requests
from flask import Flask, render_template, jsonify, request, session, url_for, redirect
from flask_session import Session
from authlib.integrations.flask_client import OAuth
web = Flask("portal")
# web.config задаётся здесь, в частности параметры:
# NEXTCLOUD_CLIENT_ID
# NEXTCLOUD_SECRET
# NEXTCLOUD_API_BASE_URL
# NEXTCLOUD_AUTHORIZE_URL
# NEXTCLOUD_ACCESS_TOKEN_URL
Session(web)
oauth_mgr = OAuth(web)
nc_client = oauth_mgr.register('nextcloud')
@web.route("/", methods=["GET"])
def home():
    return render_template("index.html"), 200
@web.route("/nextcloud_login", methods=["GET"])
def begin_nextcloud():
    target = url_for("nc_callback", _external=True)
    return nc_client.authorize_redirect(target)
@web.route('/callback/nextcloud', methods=["GET"])
def nc_callback():
    tok = nc_client.authorize_access_token()
    session["nc_token"] = tok
    return redirect(url_for("home"))

В этот момент браузер попадает на callback вида GET /callback/nextcloud?state=some-token&code=even-longer-token. Во время обмена на стороне провайдера появляется сообщение:

OC\Security\Crypto::calculateHMAC(): Argument #1 ($message) must be of type string, null given, called in /var/www/nextcloud/apps/oauth2/lib/Controller/OauthApiController.php on line 142 in file '/var/www/nextcloud/lib/private/Security/Crypto.php' line 42

Что происходит на самом деле

Редирект и авторизация проходят нормально. Ошибка возникает, когда приложение вызывает endpoint токенов через authorize_access_token — в ответ приходит 500. Логи Nextcloud показывают, что calculateHMAC получает null в качестве аргумента message. При этом минимальная реализация на requests успешно завершила обмен и вернула both access_token и refresh_token, что подтверждает: проблема не в провайдере.

Рабочий подход на чистом requests

Прямой обмен токена на базе requests валидирует state, проверяет наличие code и отправляет grant authorization_code на NEXTCLOUD_ACCESS_TOKEN_URL. Этот путь отрабатывает успешно и доказывает корректность конфигурации и самого провайдера.

from __future__ import annotations
from pathlib import Path
import io
import uuid
from urllib.parse import urlencode
import requests
from flask import Flask, render_template, jsonify, request, session, url_for, redirect
from flask_session import Session
srv = Flask("site")
# srv.config задаётся здесь, в частности параметры:
# NEXTCLOUD_CLIENT_ID
# NEXTCLOUD_SECRET
# NEXTCLOUD_API_BASE_URL
# NEXTCLOUD_AUTHORIZE_URL
# NEXTCLOUD_ACCESS_TOKEN_URL
Session(srv)
@srv.route("/", methods=["GET"])
def landing():
    if "user_id" not in session:
        session["user_id"] = "__anonymous__"
        session["nc_granted"] = False
    return render_template("index.html", session=session), 200
@srv.route("/nextcloud_login", methods=["GET"])
def start_nc_oauth():
    if "nc_granted" in session and session["nc_granted"]:
        redirect(url_for("landing"))
    session['nc_state'] = str(uuid.uuid4())
    qs = urlencode({
        'client_id': srv.config['NEXTCLOUD_CLIENT_ID'],
        'redirect_uri': url_for('nc_oauth_callback', _external=True),
        'response_type': 'code',
        'scope': "",
        'state': session['nc_state'],
    })
    return redirect(srv.config['NEXTCLOUD_AUTHORIZE_URL'] + '?' + qs)
@srv.route('/callback/nextcloud', methods=["GET"])
def nc_oauth_callback():
    if "nc_granted" in session and session["nc_granted"]:
        redirect(url_for("landing"))
    if request.args["state"] != session["nc_state"]:
        return jsonify({"error": "CSRF warning! Request states do not match."}), 403
    if "code" not in request.args or request.args["code"] == "":
        return jsonify({"error": "Did not receive valid code in NextCloud callback"}), 400
    resp = requests.post(
        srv.config['NEXTCLOUD_ACCESS_TOKEN_URL'],
        data={
            'client_id': srv.config['NEXTCLOUD_CLIENT_ID'],
            'client_secret': srv.config['NEXTCLOUD_SECRET'],
            'code': request.args['code'],
            'grant_type': 'authorization_code',
            'redirect_uri': url_for('nc_oauth_callback', _external=True),
        },
        headers={'Accept': 'application/json'},
        timeout=10
    )
    if resp.status_code != 200:
        return jsonify({"error": "Invalid response while fetching access token"}), 400
    payload = resp.json()
    access_token = payload.get('access_token')
    if not access_token:
        return jsonify({"error": "Could not find access token in response"}), 400
    refresh_token = payload.get('refresh_token')
    if not refresh_token:
        return jsonify({"error": "Could not find refresh token in response"}), 400
    session["nc_access"] = access_token
    session["nc_refresh"] = refresh_token
    session["nc_granted"] = True
    session["user_id"] = payload.get("user_id")
    return redirect(url_for("landing"))

Этот код пока не делает вызовы API с использованием access token и не обновляет истёкший доступ через refresh token. Этими удобствами обычно занимается специализированная библиотека OAuth‑клиента; здесь цель — проверить сам обмен и локализовать причину сбоя.

Почему это важно

При интеграции OAuth2 возможность независимо подтвердить корректность конфигурации провайдера и базового потока authorization_code крайне полезна. В нашем случае обмен через библиотеку возвращал 500 и лог провайдера с указанием на null во входе HMAC, тогда как ручной обмен с теми же входными данными на первый взгляд проходил успешно. Это показывает, что среда и провайдер исправны, а дальше стоит разбирать детали запроса токена на стороне клиентской библиотеки.

Что вынести из этого

Если редирект на авторизацию отрабатывает, в callback приходят code и state, но обмен токена возвращает 500 — проверьте grant минимальным потоком на requests. Убедитесь, что state сверяется, code присутствует, grant_type равен authorization_code, а redirect_uri совпадает с ожидаемым у провайдера. Имея рабочую базу, сравните её с поведением библиотеки и при необходимости поднимайте багрепорт. В этой ситуации ручной поток оказался надёжным, следующий шаг — добиваться исправления в клиентской библиотеке OAuth.

Статья основана на вопросе со StackOverflow от Etienne Ott и ответе от Etienne Ott.