Merge branch 'issue-110-dynamic-client-registration-management' into 'main'

Implemented RFC7592 OAuth Client Registration Management

See merge request yaal/canaille!79
This commit is contained in:
Éloi Rivard 2022-12-09 23:40:09 +00:00
commit 533f9f1a1a
6 changed files with 259 additions and 25 deletions

View file

@ -8,6 +8,8 @@ Added
- User can chose their favourite display name. :pr:`77` - User can chose their favourite display name. :pr:`77`
- Bumped to authlib 1.2. :pr:`78` - 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 [0.0.14] - 2022-11-29
===================== =====================

View file

@ -27,6 +27,7 @@ from .forms import LogoutForm
from .models import Client from .models import Client
from .models import Consent from .models import Consent
from .oauth import authorization from .oauth import authorization
from .oauth import ClientConfigurationEndpoint
from .oauth import ClientRegistrationEndpoint from .oauth import ClientRegistrationEndpoint
from .oauth import DEFAULT_JWT_ALG from .oauth import DEFAULT_JWT_ALG
from .oauth import DEFAULT_JWT_KTY from .oauth import DEFAULT_JWT_KTY
@ -211,6 +212,21 @@ def client_registration():
return response return response
@bp.route("/register/<client_id>", 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") @bp.route("/jwks.json")
def jwks(): def jwks():
obj = jwk.dumps( obj = jwk.dumps(

View file

@ -12,18 +12,20 @@ class Client(LDAPObject, ClientMixin):
object_class = ["oauthClient"] object_class = ["oauthClient"]
base = "ou=clients,ou=oauth" base = "ou=clients,ou=oauth"
id = "oauthClientID" id = "oauthClientID"
attribute_table = {
"description": "description", client_info_attributes = {
"client_id": "oauthClientID", "client_id": "oauthClientID",
"client_secret": "oauthClientSecret",
"client_id_issued_at": "oauthIssueDate",
"client_secret_expires_at": "oauthClientSecretExpDate",
}
client_metadata_attributes = {
"client_name": "oauthClientName", "client_name": "oauthClientName",
"contacts": "oauthClientContact", "contacts": "oauthClientContact",
"client_uri": "oauthClientURI", "client_uri": "oauthClientURI",
"redirect_uris": "oauthRedirectURIs", "redirect_uris": "oauthRedirectURIs",
"post_logout_redirect_uris": "oauthPostLogoutRedirectURI",
"logo_uri": "oauthLogoURI", "logo_uri": "oauthLogoURI",
"client_id_issued_at": "oauthIssueDate",
"client_secret": "oauthClientSecret",
"client_secret_expires_date": "oauthClientSecretExpDate",
"grant_types": "oauthGrantType", "grant_types": "oauthGrantType",
"response_types": "oauthResponseType", "response_types": "oauthResponseType",
"scope": "oauthScope", "scope": "oauthScope",
@ -34,10 +36,21 @@ class Client(LDAPObject, ClientMixin):
"token_endpoint_auth_method": "oauthTokenEndpointAuthMethod", "token_endpoint_auth_method": "oauthTokenEndpointAuthMethod",
"software_id": "oauthSoftwareID", "software_id": "oauthSoftwareID",
"software_version": "oauthSoftwareVersion", "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): def get_default_redirect_uri(self):
return self.redirect_uris[0] return self.redirect_uris[0]
@ -68,12 +81,21 @@ class Client(LDAPObject, ClientMixin):
@property @property
def client_info(self): def client_info(self):
return dict( result = {
client_id=self.client_id, attribute_name: getattr(self, attribute_name)
client_secret=self.client_secret, for attribute_name in self.client_info_attributes
client_id_issued_at=self.client_id_issued_at, }
client_secret_expires_at=self.client_secret_expires_date, 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): class AuthorizationCode(LDAPObject, AuthorizationCodeMixin):

View file

@ -16,6 +16,9 @@ from authlib.oauth2.rfc7009 import RevocationEndpoint as _RevocationEndpoint
from authlib.oauth2.rfc7591 import ( from authlib.oauth2.rfc7591 import (
ClientRegistrationEndpoint as _ClientRegistrationEndpoint, ClientRegistrationEndpoint as _ClientRegistrationEndpoint,
) )
from authlib.oauth2.rfc7592 import (
ClientConfigurationEndpoint as _ClientConfigurationEndpoint,
)
from authlib.oauth2.rfc7636 import CodeChallenge as _CodeChallenge from authlib.oauth2.rfc7636 import CodeChallenge as _CodeChallenge
from authlib.oauth2.rfc7662 import IntrospectionEndpoint as _IntrospectionEndpoint from authlib.oauth2.rfc7662 import IntrospectionEndpoint as _IntrospectionEndpoint
from authlib.oidc.core import UserInfo from authlib.oidc.core import UserInfo
@ -319,9 +322,7 @@ class IntrospectionEndpoint(_IntrospectionEndpoint):
} }
class ClientRegistrationEndpoint(_ClientRegistrationEndpoint): class ClientManagementMixin:
software_statement_alg_values_supported = ["RS256"]
def authenticate_token(self, request): def authenticate_token(self, request):
if current_app.config.get("OIDC_DYNAMIC_CLIENT_REGISTRATION_OPEN", False): if current_app.config.get("OIDC_DYNAMIC_CLIENT_REGISTRATION_OPEN", False):
return True return True
@ -338,14 +339,6 @@ class ClientRegistrationEndpoint(_ClientRegistrationEndpoint):
return True 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): def get_server_metadata(self):
from .well_known import cached_openid_configuration from .well_known import cached_openid_configuration
@ -359,6 +352,46 @@ class ClientRegistrationEndpoint(_ClientRegistrationEndpoint):
return fd.read() 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): class CodeChallenge(_CodeChallenge):
def get_authorization_code_challenge(self, authorization_code): def get_authorization_code_challenge(self, authorization_code):
return authorization_code.challenge return authorization_code.challenge
@ -398,3 +431,4 @@ def setup_oauth(app):
authorization.register_endpoint(IntrospectionEndpoint) authorization.register_endpoint(IntrospectionEndpoint)
authorization.register_endpoint(RevocationEndpoint) authorization.register_endpoint(RevocationEndpoint)
authorization.register_endpoint(ClientRegistrationEndpoint) authorization.register_endpoint(ClientRegistrationEndpoint)
authorization.register_endpoint(ClientConfigurationEndpoint)

View file

@ -12,7 +12,7 @@ OAUTH2
- ✅ `RFC7009: OAuth 2.0 Token Revocation <https://tools.ietf.org/html/rfc7009>`_ - ✅ `RFC7009: OAuth 2.0 Token Revocation <https://tools.ietf.org/html/rfc7009>`_
- ❌ `RFC7523: JWT Profile for OAuth 2.0 Client Authentication and Authorization Grants <https://tools.ietf.org/html/rfc7523>`_ - ❌ `RFC7523: JWT Profile for OAuth 2.0 Client Authentication and Authorization Grants <https://tools.ietf.org/html/rfc7523>`_
- ✅ `RFC7591: OAuth 2.0 Dynamic Client Registration Protocol <https://tools.ietf.org/html/rfc7591>`_ - ✅ `RFC7591: OAuth 2.0 Dynamic Client Registration Protocol <https://tools.ietf.org/html/rfc7591>`_
- `RFC7592: OAuth 2.0 Dynamic Client Registration Management Protocol <https://tools.ietf.org/html/rfc7592>`_ - `RFC7592: OAuth 2.0 Dynamic Client Registration Management Protocol <https://tools.ietf.org/html/rfc7592>`_
- ✅ `RFC7636: Proof Key for Code Exchange by OAuth Public Clients <https://tools.ietf.org/html/rfc7636>`_ - ✅ `RFC7636: Proof Key for Code Exchange by OAuth Public Clients <https://tools.ietf.org/html/rfc7636>`_
- ✅ `RFC7662: OAuth 2.0 Token Introspection <https://tools.ietf.org/html/rfc7662>`_ - ✅ `RFC7662: OAuth 2.0 Token Introspection <https://tools.ietf.org/html/rfc7662>`_
- ✅ `RFC8414: OAuth 2.0 Authorization Server Metadata <https://tools.ietf.org/html/rfc8414>`_ - ✅ `RFC8414: OAuth 2.0 Authorization Server Metadata <https://tools.ietf.org/html/rfc8414>`_

View file

@ -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"}