From c847ef9284ce88d130e0fa2f046d3aada8132038 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Sat, 23 Dec 2023 18:02:08 +0100 Subject: [PATCH] feat: OIDC prompt=create implementation --- CHANGES.rst | 6 ++ canaille/oidc/endpoints.py | 22 +++++ canaille/oidc/well_known.py | 4 +- doc/specifications.rst | 2 +- tests/core/test_registration.py | 4 + tests/oidc/test_authorization_prompt.py | 125 ++++++++++++++++++++++++ tests/oidc/test_well_known.py | 6 ++ 7 files changed, 167 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 66a72174..a86e5811 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -3,6 +3,11 @@ All notable changes to this project will be documented in this file. The format is based on `Keep a Changelog `_, and this project adheres to `Semantic Versioning `_. +Added +***** + +- OIDC `prompt=create` support. :issue:`185` :pr:`164` + Fixed ***** @@ -13,6 +18,7 @@ Fixed Added ***** + - ``THEME`` can be a relative path [0.0.39] - 2023-12-15 diff --git a/canaille/oidc/endpoints.py b/canaille/oidc/endpoints.py index 2796cac8..e09dc1a1 100644 --- a/canaille/oidc/endpoints.py +++ b/canaille/oidc/endpoints.py @@ -34,6 +34,7 @@ from .oauth import require_oauth from .oauth import RevocationEndpoint from .utils import SCOPE_DETAILS +from .well_known import openid_configuration bp = Blueprint("endpoints", __name__, url_prefix="/oauth") @@ -54,6 +55,23 @@ def authorize(): if not client: abort(400, "Invalid client.") + # https://openid.net/specs/openid-connect-prompt-create-1_0.html#name-authorization-request + # If the OpenID Provider receives a prompt value that it does + # not support (not declared in the prompt_values_supported + # metadata field) the OP SHOULD respond with an HTTP 400 (Bad + # Request) status code and an error value of invalid_request. + # It is RECOMMENDED that the OP return an error_description + # value identifying the invalid parameter value. + if ( + request.args.get("prompt") + and request.args["prompt"] + not in openid_configuration()["prompt_values_supported"] + ): + return { + "error": "invalid_request", + "error_description": f"prompt '{request.args['prompt'] }' value is not supported", + }, 400 + user = current_user() requested_scopes = request.args.get("scope", "").split(" ") allowed_scopes = client.get_allowed_scope(requested_scopes).split(" ") @@ -65,6 +83,10 @@ def authorize(): return jsonify({"error": "login_required"}) session["redirect-after-login"] = request.url + + if request.args.get("prompt") == "create": + return redirect(url_for("core.account.join")) + return redirect(url_for("core.auth.login")) if not user.can_use_oidc: diff --git a/canaille/oidc/well_known.py b/canaille/oidc/well_known.py index 72704341..df03fd23 100644 --- a/canaille/oidc/well_known.py +++ b/canaille/oidc/well_known.py @@ -1,4 +1,5 @@ from flask import Blueprint +from flask import current_app from flask import g from flask import jsonify from flask import request @@ -76,7 +77,8 @@ def openid_configuration(): ], "subject_types_supported": ["pairwise", "public"], "id_token_signing_alg_values_supported": ["RS256", "ES256", "HS256"], - "prompt_values_supported": ["none"], + "prompt_values_supported": ["none"] + + (["create"] if current_app.config.get("ENABLE_REGISTRATION") else []), } diff --git a/doc/specifications.rst b/doc/specifications.rst index 8b5c6851..c61e5a61 100644 --- a/doc/specifications.rst +++ b/doc/specifications.rst @@ -40,7 +40,7 @@ OpenID Connect - ❌ `OpenID Connect Back Channel Logout `_ - ❌ `OpenID Connect Back Channel Authentication Flow `_ - ❌ `OpenID Connect Core Error Code unmet_authentication_requirements `_ -- ❌ `Initiating User Registration via OpenID Connect 1.0 `_ +- ✅ `Initiating User Registration via OpenID Connect 1.0 `_ Comparison with other providers =============================== diff --git a/tests/core/test_registration.py b/tests/core/test_registration.py index c7f39305..bd5080d3 100644 --- a/tests/core/test_registration.py +++ b/tests/core/test_registration.py @@ -71,6 +71,10 @@ def test_registration_with_email_validation(testclient, backend, smtpd): res.form["family_name"] = "newuser" res = res.form.submit() + assert res.flashes == [ + ("success", "Your account has been created successfully."), + ] + user = models.User.get() assert user user.delete() diff --git a/tests/oidc/test_authorization_prompt.py b/tests/oidc/test_authorization_prompt.py index ac337843..b0f53652 100644 --- a/tests/oidc/test_authorization_prompt.py +++ b/tests/oidc/test_authorization_prompt.py @@ -2,11 +2,14 @@ Tests the behavior of Canaille depending on the OIDC 'prompt' parameter. https://openid.net/specs/openid-connect-core-1_0.html#AuthorizationEndpoint """ +import datetime import uuid from urllib.parse import parse_qs from urllib.parse import urlsplit from canaille.app import models +from canaille.core.account import RegistrationPayload +from flask import url_for def test_prompt_none(testclient, logged_user, client): @@ -98,3 +101,125 @@ def test_prompt_no_consent(testclient, logged_user, client): status=200, ) assert "consent_required" == res.json.get("error") + + +def test_prompt_create_logged(testclient, logged_user, client): + """ + If prompt=create and user is already logged in, + then go straight to the consent page. + """ + testclient.app.config["ENABLE_REGISTRATION"] = True + + consent = models.Consent( + consent_id=str(uuid.uuid4()), + client=client, + subject=logged_user, + scope=["openid", "profile"], + ) + consent.save() + + res = testclient.get( + "/oauth/authorize", + params=dict( + response_type="code", + client_id=client.client_id, + scope="openid profile", + nonce="somenonce", + prompt="create", + ), + status=302, + ) + assert res.location.startswith(client.redirect_uris[0]) + + consent.delete() + + +def test_prompt_create_registration_disabled(testclient, trusted_client, smtpd): + """ + If prompt=create but Canaille registration is disabled, + an error response should be returned. + + If the OpenID Provider receives a prompt value that it does + not support (not declared in the prompt_values_supported + metadata field) the OP SHOULD respond with an HTTP 400 (Bad + Request) status code and an error value of invalid_request. + It is RECOMMENDED that the OP return an error_description + value identifying the invalid parameter value. + """ + res = testclient.get( + "/oauth/authorize", + params=dict( + response_type="code", + client_id=trusted_client.client_id, + scope="openid profile", + nonce="somenonce", + prompt="create", + ), + status=400, + ) + assert res.json == { + "error": "invalid_request", + "error_description": "prompt 'create' value is not supported", + } + + +def test_prompt_create_not_logged(testclient, trusted_client, smtpd): + """ + If prompt=create and user is not logged in, + then display the registration form. + Check that the user is correctly redirected to + the client page after the registration process. + """ + testclient.app.config["ENABLE_REGISTRATION"] = True + + res = testclient.get( + "/oauth/authorize", + params=dict( + response_type="code", + client_id=trusted_client.client_id, + scope="openid profile", + nonce="somenonce", + prompt="create", + ), + ) + + # Display the registration form + res = res.follow() + res.form["email"] = "foo@bar.com" + res = res.form.submit() + + # Checks the registration mail is sent + assert len(smtpd.messages) == 1 + + # Simulate a click on the validation link in the mail + payload = RegistrationPayload( + creation_date_isoformat=datetime.datetime.now( + datetime.timezone.utc + ).isoformat(), + user_name="", + user_name_editable=True, + email="foo@bar.com", + groups=[], + ) + registration_url = url_for( + "core.account.registration", + data=payload.b64(), + hash=payload.build_hash(), + _external=True, + ) + + # Fill the user creation form + res = testclient.get(registration_url) + res.form["user_name"] = "newuser" + res.form["password1"] = "password" + res.form["password2"] = "password" + res.form["family_name"] = "newuser" + res = res.form.submit() + + assert res.flashes == [ + ("success", "Your account has been created successfully."), + ] + + # Return to the client + res = res.follow() + assert res.location.startswith(trusted_client.redirect_uris[0]) diff --git a/tests/oidc/test_well_known.py b/tests/oidc/test_well_known.py index 6ad2c2e2..cd7fea68 100644 --- a/tests/oidc/test_well_known.py +++ b/tests/oidc/test_well_known.py @@ -100,3 +100,9 @@ def test_openid_configuration(testclient): "userinfo_endpoint": "http://canaille.test/oauth/userinfo", "prompt_values_supported": ["none"], } + + +def test_openid_configuration_prompt_value_create(testclient): + testclient.app.config["ENABLE_REGISTRATION"] = True + res = testclient.get("/.well-known/openid-configuration", status=200).json + assert "create" in res["prompt_values_supported"]