forked from Github-Mirrors/canaille
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:
commit
533f9f1a1a
6 changed files with 259 additions and 25 deletions
|
@ -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
|
||||
=====================
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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>`_
|
||||
|
|
160
tests/oidc/test_dynamic_client_registration_management.py
Normal file
160
tests/oidc/test_dynamic_client_registration_management.py
Normal 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"}
|
Loading…
Reference in a new issue