Skip to content

Commit

Permalink
Merge branch 'issue-185-oidc-user-registration' into 'main'
Browse files Browse the repository at this point in the history
oidc user registration

Closes #185

See merge request yaal/canaille!164
  • Loading branch information
azmeuk committed Dec 23, 2023
2 parents 521ed75 + c847ef9 commit 52ce547
Show file tree
Hide file tree
Showing 13 changed files with 204 additions and 37 deletions.
6 changes: 6 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ All notable changes to this project will be documented in this file.
The format is based on `Keep a Changelog <https://keepachangelog.com/en/1.0.0/>`_,
and this project adheres to `Semantic Versioning <https://semver.org/spec/v2.0.0.html>`_.

Added
*****

- OIDC `prompt=create` support. :issue:`185` :pr:`164`

Fixed
*****

Expand All @@ -13,6 +18,7 @@ Fixed

Added
*****

- ``THEME`` can be a relative path

[0.0.39] - 2023-12-15
Expand Down
22 changes: 22 additions & 0 deletions canaille/oidc/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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(" ")
Expand All @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion canaille/oidc/well_known.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 []),
}


Expand Down
2 changes: 1 addition & 1 deletion doc/specifications.rst
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ OpenID Connect
- ❌ `OpenID Connect Back Channel Logout <https://openid.net/specs/openid-connect-backchannel-1_0.html>`_
- ❌ `OpenID Connect Back Channel Authentication Flow <https://openid.net/specs/openid-client-initiated-backchannel-authentication-core-1_0.html>`_
- ❌ `OpenID Connect Core Error Code unmet_authentication_requirements <https://openid.net/specs/openid-connect-unmet-authentication-requirements-1_0.html>`_
- `Initiating User Registration via OpenID Connect 1.0 <https://openid.net/specs/openid-connect-prompt-create-1_0.html>`_
- `Initiating User Registration via OpenID Connect 1.0 <https://openid.net/specs/openid-connect-prompt-create-1_0.html>`_

Comparison with other providers
===============================
Expand Down
4 changes: 4 additions & 0 deletions tests/core/test_registration.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
7 changes: 4 additions & 3 deletions tests/oidc/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ def configuration(configuration, keypair):


@pytest.fixture
def client(testclient, other_client, backend):
def client(testclient, trusted_client, backend):
c = models.Client(
client_id=gen_salt(24),
client_name="Some client",
Expand Down Expand Up @@ -69,15 +69,15 @@ def client(testclient, other_client, backend):
token_endpoint_auth_method="client_secret_basic",
post_logout_redirect_uris=["https://mydomain.tld/disconnected"],
)
c.audience = [c, other_client]
c.audience = [c, trusted_client]
c.save()

yield c
c.delete()


@pytest.fixture
def other_client(testclient, backend):
def trusted_client(testclient, backend):
c = models.Client(
client_id=gen_salt(24),
client_name="Some other client",
Expand All @@ -104,6 +104,7 @@ def other_client(testclient, backend):
jwks_uri="https://myotherdomain.tld/jwk",
token_endpoint_auth_method="client_secret_basic",
post_logout_redirect_uris=["https://myotherdomain.tld/disconnected"],
preconsent=True,
)
c.audience = [c]
c.save()
Expand Down
29 changes: 15 additions & 14 deletions tests/oidc/test_authorization_code_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@


def test_authorization_code_flow(
testclient, logged_user, client, keypair, other_client
testclient, logged_user, client, keypair, trusted_client
):
assert not models.Consent.query()

Expand Down Expand Up @@ -81,13 +81,13 @@ def test_authorization_code_flow(
claims = jwt.decode(access_token, keypair[1])
assert claims["sub"] == logged_user.user_name
assert claims["name"] == logged_user.formatted_name
assert claims["aud"] == [client.client_id, other_client.client_id]
assert claims["aud"] == [client.client_id, trusted_client.client_id]

id_token = res.json["id_token"]
claims = jwt.decode(id_token, keypair[1])
assert claims["sub"] == logged_user.user_name
assert claims["name"] == logged_user.formatted_name
assert claims["aud"] == [client.client_id, other_client.client_id]
assert claims["aud"] == [client.client_id, trusted_client.client_id]

res = testclient.get(
"/oauth/userinfo",
Expand All @@ -114,7 +114,7 @@ def test_invalid_client(testclient, logged_user, keypair):


def test_authorization_code_flow_with_redirect_uri(
testclient, logged_user, client, keypair, other_client
testclient, logged_user, client, keypair, trusted_client
):
assert not models.Consent.query()

Expand Down Expand Up @@ -161,7 +161,7 @@ def test_authorization_code_flow_with_redirect_uri(


def test_authorization_code_flow_preconsented(
testclient, logged_user, client, keypair, other_client
testclient, logged_user, client, keypair, trusted_client
):
assert not models.Consent.query()

Expand Down Expand Up @@ -209,7 +209,7 @@ def test_authorization_code_flow_preconsented(
claims = jwt.decode(id_token, keypair[1])
assert logged_user.user_name == claims["sub"]
assert logged_user.formatted_name == claims["name"]
assert [client.client_id, other_client.client_id] == claims["aud"]
assert [client.client_id, trusted_client.client_id] == claims["aud"]

res = testclient.get(
"/oauth/userinfo",
Expand Down Expand Up @@ -584,7 +584,7 @@ def test_authorization_code_flow_when_consent_already_given_but_for_a_smaller_sc


def test_authorization_code_flow_but_user_cannot_use_oidc(
testclient, user, client, keypair, other_client
testclient, user, client, keypair, trusted_client
):
testclient.app.config["ACL"]["DEFAULT"]["PERMISSIONS"] = []
user.reload()
Expand Down Expand Up @@ -645,16 +645,17 @@ def test_nonce_not_required_in_oauth_requests(testclient, logged_user, client):


def test_authorization_code_request_scope_too_large(
testclient, logged_user, keypair, other_client
testclient, logged_user, keypair, client
):
assert not models.Consent.query()
assert "email" not in other_client.scope
client.scope = ["openid", "profile", "groups"]
client.save()

res = testclient.get(
"/oauth/authorize",
params=dict(
response_type="code",
client_id=other_client.client_id,
client_id=client.client_id,
scope="openid profile email",
nonce="somenonce",
),
Expand All @@ -671,7 +672,7 @@ def test_authorization_code_request_scope_too_large(
"profile",
}

consents = models.Consent.query(client=other_client, subject=logged_user)
consents = models.Consent.query(client=client, subject=logged_user)
assert set(consents[0].scope) == {
"openid",
"profile",
Expand All @@ -683,15 +684,15 @@ def test_authorization_code_request_scope_too_large(
grant_type="authorization_code",
code=code,
scope="openid profile email groups address phone",
redirect_uri=other_client.redirect_uris[0],
redirect_uri=client.redirect_uris[0],
),
headers={"Authorization": f"Basic {client_credentials(other_client)}"},
headers={"Authorization": f"Basic {client_credentials(client)}"},
status=200,
)

access_token = res.json["access_token"]
token = models.Token.get(access_token=access_token)
assert token.client == other_client
assert token.client == client
assert token.subject == logged_user
assert set(token.scope) == {
"openid",
Expand Down
125 changes: 125 additions & 0 deletions tests/oidc/test_authorization_prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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"] = "[email protected]"
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="[email protected]",
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])
Loading

0 comments on commit 52ce547

Please sign in to comment.