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`
|
- 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
|
||||||
=====================
|
=====================
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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):
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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>`_
|
||||||
|
|
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