pythonflaskoauth-2.0nextcloudauthlib

Authenticating a user with NextCloud using oauth2 with authlib in flask fails at getting access token


I am writing a flask app that authenticates users via oauth2 with a NextCloud instance (and later will use file synchronisation). From what I read this should be fairly straightforward. For example authlib describes how to create a oauth2 client with flask: https://docs.authlib.org/en/latest/client/index.html

Here's the current implementation:

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

app = Flask("webapp")

# app.config is set here, specifically settings:
# NEXTCLOUD_CLIENT_ID
# NEXTCLOUD_SECRET
# NEXTCLOUD_API_BASE_URL
# NEXTCLOUD_AUTHORIZE_URL
# NEXTCLOUD_ACCESS_TOKEN_URL

# set session to be managed server-side
Session(app)

# register NextCloud oauth
oauth = OAuth(app)
nextcloud = oauth.register('nextcloud')

@app.route("/", methods=["GET"])
def index():
    return render_template("index.html"), 200

@app.route("/nextcloud_login", methods=["GET"])
def nextcloud_login():
    redirect_uri = url_for("callback_nextcloud", _external=True)
    return nextcloud.authorize_redirect(redirect_uri)

@app.route('/callback/nextcloud', methods=["GET"])
def callback_nextcloud():
    token = nextcloud.authorize_access_token()
    session["nextcloud_token"] = token
    return redirect(url_for("index"))

The index route shows a page with a link to the login route. If clicked the user is sent to the NextCloud instance using the NEXTCLOUD_AUTHORIZE_URL and upon agreeing to the auth, is sent back to the route /callback/nextcloud (registered that way in NextCloud). I can confirm it is working up to this point and the request to the callback route looks like GET /callback/nextcloud?state=some-token&code=even-longer-token HTTP/1.1. However when the line token = nextcloud.authorize_access_token() is executed, authlib tries to get an access token in the background, but this fails with a 500 response and no error message. It took some digging to find an error message on the NextCloud server (see below). Now it is entirely possible that the issue is with NextCloud and has nothing to do with the code, but I find this very unlikely as I unsuccessfully spent hours trying to find a shred of useful information on the web and it's unlikely I'm the first to notice this issue. At the moment I'm assuming my implementation is at fault and I'm hopefully just overlooking something very simple.

NextCloud error message in the server log: 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


Solution

  • As it turns out, the problem was not NextCloud. Using this tutorial I implemented a working login flow using only the `requests` package. The code for that is below. It is not yet handling performing any kind of API request using the obtained access token beyond the initial authentication, nor is it handling using the refresh token to get a new access token when the old one expired. That is functionality an oauth library is usually handling and this manual implementation is not doing that for now. However it proves the problem isn't with NextCloud.

    I stepped through both the initial authlib implementation and the new with a debugger and the request sent to the NextCloud API for getting the access token looks the same in both cases at first glance. There must be something subtly wrong about the request in the authlib case that causes the API to run into an error. I will investigate this further and take this bug up with authlib. This question here is answered and if there is a bug fix in authlib I will edit the answer to mention which version fixes it.

    
    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
    
    app = Flask("webapp")
    
    # app.config is set here, specifically settings:
    # NEXTCLOUD_CLIENT_ID
    # NEXTCLOUD_SECRET
    # NEXTCLOUD_API_BASE_URL
    # NEXTCLOUD_AUTHORIZE_URL
    # NEXTCLOUD_ACCESS_TOKEN_URL
    
    # set session to be managed server-side
    Session(app)
    
    
    @app.route("/", methods=["GET"])
    def index():
        if "user_id" not in session:
            session["user_id"] = "__anonymous__"
            session["nextcloud_authorized"] = False
        return render_template("index.html", session=session), 200
    
    @app.route("/nextcloud_login", methods=["GET"])
    def nextcloud_login():
        if "nextcloud_authorized" in session and session["nextcloud_authorized"]:
            redirect(url_for("index"))
    
        session['nextcloud_login_state'] = str(uuid.uuid4())
    
        qs = urlencode({
            'client_id': app.config['NEXTCLOUD_CLIENT_ID'],
            'redirect_uri': url_for('callback_nextcloud', _external=True),
            'response_type': 'code',
            'scope': "",
            'state': session['nextcloud_login_state'],
        })
    
        return redirect(app.config['NEXTCLOUD_AUTHORIZE_URL'] + '?' + qs)
    
    @app.route('/callback/nextcloud', methods=["GET"])
    def callback_nextcloud():
        if "nextcloud_authorized" in session and session["nextcloud_authorized"]:
            redirect(url_for("index"))
    
        # if the callback request from NextCloud has an error, we might catch this here, however
        # it is not clear how errors are presented in the request for the callback
        # if "error" in request.args:
        #     return jsonify({"error": "NextCloud callback has errors"}), 400
    
        if request.args["state"] != session["nextcloud_login_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
    
        response = requests.post(
            app.config['NEXTCLOUD_ACCESS_TOKEN_URL'],
            data={
                'client_id': app.config['NEXTCLOUD_CLIENT_ID'],
                'client_secret': app.config['NEXTCLOUD_SECRET'],
                'code': request.args['code'],
                'grant_type': 'authorization_code',
                'redirect_uri': url_for('callback_nextcloud', _external=True),
            },
            headers={'Accept': 'application/json'},
            timeout=10
        )
    
        if response.status_code != 200:
            return jsonify({"error": "Invalid response while fetching access token"}), 400
    
        response_data = response.json()
        access_token = response_data.get('access_token')
        if not access_token:
            return jsonify({"error": "Could not find access token in response"}), 400
    
        refresh_token = response_data.get('refresh_token')
        if not refresh_token:
            return jsonify({"error": "Could not find refresh token in response"}), 400
    
        session["nextcloud_access_token"] = access_token
        session["nextcloud_refresh_token"] = refresh_token
        session["nextcloud_authorized"] = True
        session["user_id"] = response_data.get("user_id")
    
        return redirect(url_for("index"))