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
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"))