2025, Oct 05 19:00

Fixing OAuth2 Token Exchange Between Flask and Nextcloud: Authlib 500 Errors and a Working Requests Flow

Learn why OAuth2 token exchange fails between a Flask app and Nextcloud with Authlib, how to confirm provider health, and fix it using a requests flow.

OAuth2 between a Flask app and a Nextcloud instance should be uneventful: redirect the user to authorize, receive an authorization code, exchange it for an access token, and move on to API calls. In practice, the token exchange step can still bite. Below is a real-world flow where Authlib-based code reached the callback successfully but consistently failed on the token request with a 500 from the provider, while a minimal implementation using plain requests worked end to end and confirmed the provider was fine.

What the failing flow looks like

The application is a Flask service configured with NEXTCLOUD_CLIENT_ID, NEXTCLOUD_SECRET, NEXTCLOUD_API_BASE_URL, NEXTCLOUD_AUTHORIZE_URL, and NEXTCLOUD_ACCESS_TOKEN_URL. The redirect to Nextcloud works, the user approves, and Nextcloud calls back with state and code. The failure occurs when exchanging the code for an 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 is set here, specifically settings:
# 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"))

At this point, the browser arrives at a callback such as GET /callback/nextcloud?state=some-token&code=even-longer-token. The following provider-side message appeared when the exchange step ran:

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

What’s actually going wrong

The redirect and authorization parts are fine. The failure happens when the app invokes the token endpoint through authorize_access_token, which responds with a 500. The Nextcloud log shows calculateHMAC receiving null for the message argument. A minimal implementation using requests, however, completed the exchange and produced both access_token and refresh_token, which makes it clear the provider itself is not the culprit.

The working approach with plain requests

A straightforward token exchange built on requests validates state, checks for the code, and posts the authorization_code grant to the NEXTCLOUD_ACCESS_TOKEN_URL. This path completes successfully and proves the configuration and provider are sound.

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 is set here, specifically settings:
# 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"))

This code does not yet perform API calls using the access token and does not use the refresh token to renew expired access. Those are the niceties a dedicated OAuth client library usually abstracts away; the point here is to validate the exchange and isolate the failure.

Why this matters

When integrating OAuth2, being able to prove the provider configuration and the basic authorization_code flow independently is invaluable. Here, token exchange via a library produced a 500 and a provider log pointing to a null HMAC input, while the manual exchange succeeded with the same inputs at first glance. That demonstrates the environment and provider are healthy and focuses further investigation on the client library’s token request details.

Takeaways

If the authorization redirect works and the callback carries code and state but the token exchange returns a 500, validate the grant with a minimal requests-based flow. Confirm the state check, the code is present, the grant_type is authorization_code, and the redirect_uri matches what the provider expects. Once you have a known-good baseline, you can compare it to the library’s behavior and escalate a bug report if needed. In this scenario, a manual flow proved reliable, and the next step is to pursue a fix upstream in the OAuth client library.

The article is based on a question from StackOverflow by Etienne Ott and an answer by Etienne Ott.