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`
- 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
=====================

View file

@ -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/<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")
def jwks():
obj = jwk.dumps(

View file

@ -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):

View file

@ -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)

View file

@ -12,7 +12,7 @@ OAUTH2
- ✅ `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>`_
- ✅ `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>`_
- ✅ `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>`_

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