diff --git a/authlib/oidc/core/errors.py b/authlib/oidc/core/errors.py index a2ed7609..334a4337 100644 --- a/authlib/oidc/core/errors.py +++ b/authlib/oidc/core/errors.py @@ -85,3 +85,15 @@ class RegistrationNotSupportedError(OAuth2Error): """The OP does not support use of the registration parameter.""" error = "registration_not_supported" + + +class RegistrationRequiredError(OAuth2Error): + """The Authorization Server requires the End-User to register. + + This is used to indicate the authorization request included the + `prompt=create` value and the server wants to redirect the user + to a registration page. Integrations can raise this with + ``redirect_uri`` to perform the redirect. + """ + + error = "registration_required" diff --git a/authlib/oidc/core/grants/util.py b/authlib/oidc/core/grants/util.py index a228fdaa..e4c72037 100644 --- a/authlib/oidc/core/grants/util.py +++ b/authlib/oidc/core/grants/util.py @@ -150,6 +150,9 @@ def create_response_mode_response(redirect_uri, params, response_mode): def _guess_prompt_value(end_user, prompts, redirect_uri, redirect_fragment): # http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest + if "create" in prompts: + return "create" + if not end_user or "login" in prompts: return "login" diff --git a/authlib/oidc/discovery/models.py b/authlib/oidc/discovery/models.py index 7557802f..79bfe34c 100644 --- a/authlib/oidc/discovery/models.py +++ b/authlib/oidc/discovery/models.py @@ -53,6 +53,7 @@ class OpenIDProviderMetadata(AuthorizationServerMetadata): "request_parameter_supported", "request_uri_parameter_supported", "require_request_uri_registration", + "prompt_values_supported", # not defined by OpenID # 'revocation_endpoint', # 'revocation_endpoint_auth_methods_supported', @@ -260,6 +261,23 @@ def validate_request_uri_parameter_supported(self): """ validate_boolean_value(self, "request_uri_parameter_supported") + def validate_prompt_values_supported(self): + """OPTIONAL. JSON array containing the list of prompt values that + this OpenID Provider supports. Values defined by OpenID Connect Core + are: "none", "login", "consent", "select_account". This + specification defines the additional value "create". + """ + values = self.get("prompt_values_supported") + if not values: + return + + if not isinstance(values, list): + raise ValueError('"prompt_values_supported" MUST be JSON array') + + valid_values = {"none", "login", "consent", "select_account", "create"} + if not valid_values.issuperset(set(values)): + raise ValueError('"prompt_values_supported" contains invalid values') + def validate_require_request_uri_registration(self): """OPTIONAL. Boolean value specifying whether the OP requires any request_uri values used to be pre-registered using the request_uris diff --git a/docs/oauth2/specs/oidc.rst b/docs/oauth2/specs/oidc.rst index dcaf9fa6..1ab20643 100644 --- a/docs/oauth2/specs/oidc.rst +++ b/docs/oauth2/specs/oidc.rst @@ -67,6 +67,23 @@ OpenID Claims .. autoclass:: UserInfo :members: +Prompt Create Extension +----------------------- + +Authlib supports the +`Initiating User Registration via OpenID Connect 1.0 `__ +extension. + +This extension adds a ``create`` value for the ``prompt`` authorization +request parameter, which can be used to initiate a user registration flow. + +Authlib also supports advertising supported prompt values through OpenID +Provider discovery metadata via ``prompt_values_supported``. + +When using OpenID Connect grants (for example ``OpenIDCode``), +``prompt=create`` is parsed and exposed on the grant as ``grant.prompt`` for +application-specific handling in your authorization flow. + Dynamic client registration --------------------------- diff --git a/tests/core/test_oidc/test_discovery.py b/tests/core/test_oidc/test_discovery.py index 1a37c8aa..cc1ad0fa 100644 --- a/tests/core/test_oidc/test_discovery.py +++ b/tests/core/test_oidc/test_discovery.py @@ -105,6 +105,11 @@ def test_validate_display_values_supported(): _call_contains_invalid_value("display_values_supported", ["invalid"]) +def test_validate_prompt_values_supported(): + _call_validate_array("prompt_values_supported", ["none", "login", "create"]) + _call_contains_invalid_value("prompt_values_supported", ["invalid"]) + + def test_validate_claim_types_supported(): _call_validate_array("claim_types_supported", ["normal"]) _call_contains_invalid_value("claim_types_supported", ["invalid"]) diff --git a/tests/django/test_oauth2/test_authorization_code_grant.py b/tests/django/test_oauth2/test_authorization_code_grant.py index 9b39643a..03dd0fde 100644 --- a/tests/django/test_oauth2/test_authorization_code_grant.py +++ b/tests/django/test_oauth2/test_authorization_code_grant.py @@ -7,6 +7,7 @@ from authlib.common.urls import urlparse from authlib.oauth2.rfc6749 import errors from authlib.oauth2.rfc6749 import grants +from authlib.oidc.core.grants import OpenIDCode as _OpenIDCode from .models import Client from .models import CodeGrantMixin @@ -56,6 +57,23 @@ def client(user): client.delete() +@pytest.fixture +def oidc_server(server): + class OpenIDCode(_OpenIDCode): + pass + + grant_cls, _ = server._authorization_grants[0] + server._authorization_grants = [(grant_cls, [OpenIDCode()])] + return server + + +@pytest.fixture +def openid_client(client): + client.scope = "openid" + client.save() + return client + + def test_get_consent_grant_client(factory, server, client): url = "/authorize?response_type=code" request = factory.get(url) @@ -119,6 +137,30 @@ def test_create_authorization_response(factory, server): assert "code=" in resp["Location"] +def test_prompt_create_sets_grant_prompt(factory, oidc_server, openid_client): + data = { + "response_type": "code", + "client_id": "client-id", + "redirect_uri": "https://client.test", + "scope": "openid", + "prompt": "create", + } + request = factory.post("/authorize", data=data) + grant = oidc_server.get_consent_grant(request) + + resp = oidc_server.create_authorization_response(request, grant=grant) + assert resp.status_code == 302 + assert "error=access_denied" in resp["Location"] + + grant_user = User.objects.get(username="foo") + resp = oidc_server.create_authorization_response( + request, grant=grant, grant_user=grant_user + ) + assert resp.status_code == 302 + assert "code=" in resp["Location"] + assert grant.prompt == "create" + + def test_create_token_response_invalid(factory, server): # case: no auth request = factory.post("/oauth/token", data={"grant_type": "authorization_code"}) diff --git a/tests/flask/test_oauth2/test_openid_code_grant.py b/tests/flask/test_oauth2/test_openid_code_grant.py index e9fed009..516907df 100644 --- a/tests/flask/test_oauth2/test_openid_code_grant.py +++ b/tests/flask/test_oauth2/test_openid_code_grant.py @@ -226,6 +226,10 @@ def test_prompt(test_client, server): rv = test_client.get("/oauth/authorize?" + query) assert rv.data == b"login" + query = url_encode(params + [("user_id", "1"), ("prompt", "create")]) + rv = test_client.get("/oauth/authorize?" + query) + assert rv.data == b"create" + def test_prompt_none_not_logged(test_client, server): register_oidc_code_grant( @@ -248,6 +252,39 @@ def test_prompt_none_not_logged(test_client, server): assert params["state"] == "bar" +def test_prompt_create_is_retained(test_client, server): + register_oidc_code_grant(server) + params = [ + ("response_type", "code"), + ("client_id", "client-id"), + ("state", "bar"), + ("nonce", "abc"), + ("scope", "openid profile"), + ("redirect_uri", "https://client.test"), + ("user_id", "1"), + ("prompt", "create"), + ] + query = url_encode(params) + rv = test_client.get("/oauth/authorize?" + query) + assert rv.data == b"create" + + +def test_prompt_multiple_values_prefers_login_without_user(test_client, server): + register_oidc_code_grant(server) + params = [ + ("response_type", "code"), + ("client_id", "client-id"), + ("state", "bar"), + ("nonce", "abc"), + ("scope", "openid profile"), + ("redirect_uri", "https://client.test"), + ("prompt", "login consent"), + ] + query = url_encode(params) + rv = test_client.get("/oauth/authorize?" + query) + assert rv.data == b"login" + + def test_client_metadata_custom_alg(test_client, server, client, db, app): """The client metadata 'id_token_signed_response_alg' must take precedence over the server-wide ``get_jwt_config()`` ``alg``."""