From a3418de239ba3d35e0627a37bf06e8011765d474 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89loi=20Rivard?= Date: Mon, 24 Oct 2022 17:18:46 +0200 Subject: [PATCH] Implemented RFC7592 OAuth Client Registration Management --- CHANGES.rst | 2 + canaille/oidc/endpoints.py | 16 ++ canaille/oidc/models.py | 48 ++++-- canaille/oidc/oauth.py | 56 ++++-- doc/specifications.rst | 2 +- ..._dynamic_client_registration_management.py | 160 ++++++++++++++++++ 6 files changed, 259 insertions(+), 25 deletions(-) create mode 100644 tests/oidc/test_dynamic_client_registration_management.py diff --git a/CHANGES.rst b/CHANGES.rst index 61d09cbb..3cb1ade1 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -8,6 +8,8 @@ Added - User can chose their favourite display name. :pr:`77` - Bumped to authlib 1.2. :pr:`78` +- Implemented RFC7592 OAuth 2.0 Dynamic Client Registration Management + Protocol :pr:`79` [0.0.14] - 2022-11-29 ===================== diff --git a/canaille/oidc/endpoints.py b/canaille/oidc/endpoints.py index c57007dc..54e1fdb2 100644 --- a/canaille/oidc/endpoints.py +++ b/canaille/oidc/endpoints.py @@ -27,6 +27,7 @@ from .forms import LogoutForm from .models import Client from .models import Consent from .oauth import authorization +from .oauth import ClientConfigurationEndpoint from .oauth import ClientRegistrationEndpoint from .oauth import DEFAULT_JWT_ALG from .oauth import DEFAULT_JWT_KTY @@ -211,6 +212,21 @@ def client_registration(): return response +@bp.route("/register/", methods=["GET", "PUT", "DELETE"]) +def client_registration_management(client_id): + current_app.logger.debug( + "client registration management endpoint request: POST: %s", + request.form.to_dict(flat=False), + ) + response = authorization.create_endpoint_response( + ClientConfigurationEndpoint.ENDPOINT_NAME + ) + current_app.logger.debug( + "client registration management endpoint response: %s", response.json + ) + return response + + @bp.route("/jwks.json") def jwks(): obj = jwk.dumps( diff --git a/canaille/oidc/models.py b/canaille/oidc/models.py index c4b18acb..2b8bf981 100644 --- a/canaille/oidc/models.py +++ b/canaille/oidc/models.py @@ -12,18 +12,20 @@ class Client(LDAPObject, ClientMixin): object_class = ["oauthClient"] base = "ou=clients,ou=oauth" id = "oauthClientID" - attribute_table = { - "description": "description", + + client_info_attributes = { "client_id": "oauthClientID", + "client_secret": "oauthClientSecret", + "client_id_issued_at": "oauthIssueDate", + "client_secret_expires_at": "oauthClientSecretExpDate", + } + + client_metadata_attributes = { "client_name": "oauthClientName", "contacts": "oauthClientContact", "client_uri": "oauthClientURI", "redirect_uris": "oauthRedirectURIs", - "post_logout_redirect_uris": "oauthPostLogoutRedirectURI", "logo_uri": "oauthLogoURI", - "client_id_issued_at": "oauthIssueDate", - "client_secret": "oauthClientSecret", - "client_secret_expires_date": "oauthClientSecretExpDate", "grant_types": "oauthGrantType", "response_types": "oauthResponseType", "scope": "oauthScope", @@ -34,10 +36,21 @@ class Client(LDAPObject, ClientMixin): "token_endpoint_auth_method": "oauthTokenEndpointAuthMethod", "software_id": "oauthSoftwareID", "software_version": "oauthSoftwareVersion", - "audience": "oauthAudience", - "preconsent": "oauthPreconsent", } + attribute_table = { + "description": "description", + "preconsent": "oauthPreconsent", + # post_logout_redirect_uris is not yet supported by authlib + "post_logout_redirect_uris": "oauthPostLogoutRedirectURI", + "audience": "oauthAudience", + **client_info_attributes, + **client_metadata_attributes, + } + + def get_client_id(self): + return self.client_id + def get_default_redirect_uri(self): return self.redirect_uris[0] @@ -68,12 +81,21 @@ class Client(LDAPObject, ClientMixin): @property def client_info(self): - return dict( - client_id=self.client_id, - client_secret=self.client_secret, - client_id_issued_at=self.client_id_issued_at, - client_secret_expires_at=self.client_secret_expires_date, + result = { + attribute_name: getattr(self, attribute_name) + for attribute_name in self.client_info_attributes + } + result["client_id_issued_at"] = int( + datetime.datetime.timestamp(result["client_id_issued_at"]) ) + return result + + @property + def client_metadata(self): + return { + attribute_name: getattr(self, attribute_name) + for attribute_name in self.client_metadata_attributes + } class AuthorizationCode(LDAPObject, AuthorizationCodeMixin): diff --git a/canaille/oidc/oauth.py b/canaille/oidc/oauth.py index 0e084987..556ef3b0 100644 --- a/canaille/oidc/oauth.py +++ b/canaille/oidc/oauth.py @@ -16,6 +16,9 @@ from authlib.oauth2.rfc7009 import RevocationEndpoint as _RevocationEndpoint from authlib.oauth2.rfc7591 import ( ClientRegistrationEndpoint as _ClientRegistrationEndpoint, ) +from authlib.oauth2.rfc7592 import ( + ClientConfigurationEndpoint as _ClientConfigurationEndpoint, +) from authlib.oauth2.rfc7636 import CodeChallenge as _CodeChallenge from authlib.oauth2.rfc7662 import IntrospectionEndpoint as _IntrospectionEndpoint from authlib.oidc.core import UserInfo @@ -319,9 +322,7 @@ class IntrospectionEndpoint(_IntrospectionEndpoint): } -class ClientRegistrationEndpoint(_ClientRegistrationEndpoint): - software_statement_alg_values_supported = ["RS256"] - +class ClientManagementMixin: def authenticate_token(self, request): if current_app.config.get("OIDC_DYNAMIC_CLIENT_REGISTRATION_OPEN", False): return True @@ -338,14 +339,6 @@ class ClientRegistrationEndpoint(_ClientRegistrationEndpoint): return True - def save_client(self, client_info, client_metadata, request): - client_info["client_id_issued_at"] = datetime.datetime.fromtimestamp( - client_info["client_id_issued_at"] - ) - client = Client(**client_info, **client_metadata) - client.save() - return client - def get_server_metadata(self): from .well_known import cached_openid_configuration @@ -359,6 +352,46 @@ class ClientRegistrationEndpoint(_ClientRegistrationEndpoint): return fd.read() +class ClientRegistrationEndpoint(ClientManagementMixin, _ClientRegistrationEndpoint): + software_statement_alg_values_supported = ["RS256"] + + def save_client(self, client_info, client_metadata, request): + client_info["client_id_issued_at"] = datetime.datetime.fromtimestamp( + client_info["client_id_issued_at"] + ) + client = Client(**client_info, **client_metadata) + client.save() + return client + + +class ClientConfigurationEndpoint(ClientManagementMixin, _ClientConfigurationEndpoint): + def authenticate_client(self, request): + client_id = request.uri.split("/")[-1] + return Client.get(client_id) + + def revoke_access_token(self, request, token): + pass + + def check_permission(self, client, request): + return True + + def delete_client(self, client, request): + client.delete() + + def update_client(self, client, client_metadata, request): + for key, value in client_metadata.items(): + setattr(client, key, value) + client.save() + return client + + def generate_client_registration_info(self, client, request): + access_token = request.headers["Authorization"].split(" ")[1] + return { + "registration_client_uri": request.uri, + "registration_access_token": access_token, + } + + class CodeChallenge(_CodeChallenge): def get_authorization_code_challenge(self, authorization_code): return authorization_code.challenge @@ -398,3 +431,4 @@ def setup_oauth(app): authorization.register_endpoint(IntrospectionEndpoint) authorization.register_endpoint(RevocationEndpoint) authorization.register_endpoint(ClientRegistrationEndpoint) + authorization.register_endpoint(ClientConfigurationEndpoint) diff --git a/doc/specifications.rst b/doc/specifications.rst index 64fd5427..2a47a5ce 100644 --- a/doc/specifications.rst +++ b/doc/specifications.rst @@ -12,7 +12,7 @@ OAUTH2 - ✅ `RFC7009: OAuth 2.0 Token Revocation `_ - ❌ `RFC7523: JWT Profile for OAuth 2.0 Client Authentication and Authorization Grants `_ - ✅ `RFC7591: OAuth 2.0 Dynamic Client Registration Protocol `_ -- ❌ `RFC7592: OAuth 2.0 Dynamic Client Registration Management Protocol `_ +- ✅ `RFC7592: OAuth 2.0 Dynamic Client Registration Management Protocol `_ - ✅ `RFC7636: Proof Key for Code Exchange by OAuth Public Clients `_ - ✅ `RFC7662: OAuth 2.0 Token Introspection `_ - ✅ `RFC8414: OAuth 2.0 Authorization Server Metadata `_ diff --git a/tests/oidc/test_dynamic_client_registration_management.py b/tests/oidc/test_dynamic_client_registration_management.py new file mode 100644 index 00000000..a75fceee --- /dev/null +++ b/tests/oidc/test_dynamic_client_registration_management.py @@ -0,0 +1,160 @@ +import warnings +from datetime import datetime + +from canaille.oidc.models import Client + + +def test_get(testclient, slapd_connection, client, user): + assert not testclient.app.config.get("OIDC_DYNAMIC_CLIENT_REGISTRATION_OPEN") + testclient.app.config["OIDC_DYNAMIC_CLIENT_REGISTRATION_TOKENS"] = ["static-token"] + + headers = {"Authorization": "Bearer static-token"} + res = testclient.get( + f"/oauth/register/{client.client_id}", headers=headers, status=200 + ) + assert res.json == { + "client_id": client.client_id, + "client_secret": client.client_secret, + "client_id_issued_at": int(datetime.timestamp(client.client_id_issued_at)), + "client_secret_expires_at": None, + "redirect_uris": [ + "https://mydomain.tld/redirect1", + "https://mydomain.tld/redirect2", + ], + "registration_access_token": "static-token", + "registration_client_uri": f"http://localhost/oauth/register/{client.client_id}", + "token_endpoint_auth_method": "client_secret_basic", + "grant_types": [ + "password", + "authorization_code", + "implicit", + "hybrid", + "refresh_token", + ], + "response_types": ["code", "token", "id_token"], + "client_name": "Some client", + "client_uri": "https://mydomain.tld", + "logo_uri": "https://mydomain.tld/logo.png", + "scope": ["openid", "email", "profile", "groups", "address", "phone"], + "contacts": ["contact@mydomain.tld"], + "tos_uri": "https://mydomain.tld/tos", + "policy_uri": "https://mydomain.tld/policy", + "jwk": None, + "jwks_uri": "https://mydomain.tld/jwk", + "software_id": None, + "software_version": None, + } + + +def test_update(testclient, slapd_connection, client, user): + assert not testclient.app.config.get("OIDC_DYNAMIC_CLIENT_REGISTRATION_OPEN") + testclient.app.config["OIDC_DYNAMIC_CLIENT_REGISTRATION_TOKENS"] = ["static-token"] + + assert client.redirect_uris != ["https://newname.example.org/callback"] + assert client.token_endpoint_auth_method != "none" + assert client.grant_types != ["refresh_token"] + assert client.response_types != ["code", "token"] + assert client.client_name != "new name" + assert client.client_uri != "https://newname.example.org" + assert client.logo_uri != "https://newname.example.org/logo.png" + assert client.scope != ["openid", "profile", "email"] + assert client.contacts != ["newcontact@example.org"] + assert client.tos_uri != "https://newname.example.org/tos" + assert client.policy_uri != "https://newname.example.org/policy" + assert client.jwks_uri != "https://newname.example.org/my_public_keys.jwks" + assert client.software_id != "new_software_id" + assert client.software_version != "3.14" + + payload = { + "client_id": client.client_id, + "redirect_uris": ["https://newname.example.org/callback"], + "token_endpoint_auth_method": "none", + "grant_types": ["refresh_token"], + "response_types": ["code", "token"], + "client_name": "new name", + "client_uri": "https://newname.example.org", + "logo_uri": "https://newname.example.org/logo.png", + "scope": ["openid", "profile", "email"], + "contacts": ["newcontact@example.org"], + "tos_uri": "https://newname.example.org/tos", + "policy_uri": "https://newname.example.org/policy", + "jwks_uri": "https://newname.example.org/my_public_keys.jwks", + "software_id": "new_software_id", + "software_version": "3.14", + } + + headers = {"Authorization": "Bearer static-token"} + res = testclient.put_json( + f"/oauth/register/{client.client_id}", payload, headers=headers, status=200 + ) + client = Client.get(res.json["client_id"]) + + assert res.json == { + "client_id": client.client_id, + "client_secret": client.client_secret, + "client_id_issued_at": int(datetime.timestamp(client.client_id_issued_at)), + "client_secret_expires_at": None, + "redirect_uris": ["https://newname.example.org/callback"], + "registration_access_token": "static-token", + "registration_client_uri": f"http://localhost/oauth/register/{client.client_id}", + "token_endpoint_auth_method": "none", + "grant_types": ["refresh_token"], + "response_types": ["code", "token"], + "client_name": "new name", + "client_uri": "https://newname.example.org", + "logo_uri": "https://newname.example.org/logo.png", + "scope": ["openid", "profile", "email"], + "contacts": ["newcontact@example.org"], + "tos_uri": "https://newname.example.org/tos", + "policy_uri": "https://newname.example.org/policy", + "jwk": None, + "jwks_uri": "https://newname.example.org/my_public_keys.jwks", + "software_id": "new_software_id", + "software_version": "3.14", + } + + assert client.redirect_uris == ["https://newname.example.org/callback"] + assert client.token_endpoint_auth_method == "none" + assert client.grant_types == ["refresh_token"] + assert client.response_types == ["code", "token"] + assert client.client_name == "new name" + assert client.client_uri == "https://newname.example.org" + assert client.logo_uri == "https://newname.example.org/logo.png" + assert client.scope == ["openid", "profile", "email"] + assert client.contacts == ["newcontact@example.org"] + assert client.tos_uri == "https://newname.example.org/tos" + assert client.policy_uri == "https://newname.example.org/policy" + assert client.jwks_uri == "https://newname.example.org/my_public_keys.jwks" + assert client.software_id == "new_software_id" + assert client.software_version == "3.14" + + +def test_delete(testclient, slapd_connection, user): + assert not testclient.app.config.get("OIDC_DYNAMIC_CLIENT_REGISTRATION_OPEN") + testclient.app.config["OIDC_DYNAMIC_CLIENT_REGISTRATION_TOKENS"] = ["static-token"] + + client = Client(client_id="foobar", client_name="Some client") + client.save() + + headers = {"Authorization": "Bearer static-token"} + with warnings.catch_warnings(record=True): + res = testclient.delete( + f"/oauth/register/{client.client_id}", headers=headers, status=204 + ) + assert not Client.get(client.client_id) + + +def test_invalid_client(testclient, slapd_connection, user): + assert not testclient.app.config.get("OIDC_DYNAMIC_CLIENT_REGISTRATION_OPEN") + testclient.app.config["OIDC_DYNAMIC_CLIENT_REGISTRATION_TOKENS"] = ["static-token"] + + payload = { + "client_id": "invalid-client-id", + "redirect_uris": ["https://newname.example.org/callback"], + } + + headers = {"Authorization": "Bearer static-token"} + res = testclient.put_json( + "/oauth/register/invalid-client-id", payload, headers=headers, status=401 + ) + assert res.json == {"error": "invalid_client"}