Skip to content

Commit 8cd740f

Browse files
feat: implement dynamic client registration (#7096)
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 58f6e51 commit 8cd740f

10 files changed

Lines changed: 606 additions & 2 deletions

File tree

api/app/settings/common.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,7 @@
303303

304304

305305
LOGIN_THROTTLE_RATE = env("LOGIN_THROTTLE_RATE", "20/min")
306+
DCR_THROTTLE_RATE = env("DCR_THROTTLE_RATE", "500/month")
306307
SIGNUP_THROTTLE_RATE = env("SIGNUP_THROTTLE_RATE", "10000/min")
307308
USER_THROTTLE_RATE = env("USER_THROTTLE_RATE", default=None)
308309
MASTER_API_KEY_THROTTLE_RATE = env("MASTER_API_KEY_THROTTLE_RATE", default=None)
@@ -321,6 +322,7 @@
321322
"DEFAULT_THROTTLE_CLASSES": DEFAULT_THROTTLE_CLASSES,
322323
"DEFAULT_THROTTLE_RATES": {
323324
"login": LOGIN_THROTTLE_RATE,
325+
"dcr_register": DCR_THROTTLE_RATE,
324326
"signup": SIGNUP_THROTTLE_RATE,
325327
"master_api_key": MASTER_API_KEY_THROTTLE_RATE,
326328
"mfa_code": "5/min",

api/app/settings/test.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
REST_FRAMEWORK["DEFAULT_THROTTLE_CLASSES"] = ["core.throttling.UserRateThrottle"]
88
REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"] = {
99
"login": "100/min",
10+
"dcr_register": "100/min",
1011
"mfa_code": "5/min",
1112
"invite": "10/min",
1213
"signup": "100/min",

api/app/urls.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,10 @@
77
from django.views.generic.base import TemplateView
88
from oauth2_provider import views as oauth2_views
99

10-
from oauth2_metadata.views import authorization_server_metadata
10+
from oauth2_metadata.views import (
11+
DynamicClientRegistrationView,
12+
authorization_server_metadata,
13+
)
1114
from users.views import password_reset_redirect
1215

1316
from . import views
@@ -54,6 +57,11 @@
5457
"robots.txt",
5558
TemplateView.as_view(template_name="robots.txt", content_type="text/plain"),
5659
),
60+
path(
61+
"o/register/",
62+
DynamicClientRegistrationView.as_view(),
63+
name="oauth2-dcr-register",
64+
),
5765
path(
5866
"o/",
5967
include(

api/oauth2_metadata/serializers.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import re
2+
3+
from django.core.exceptions import ValidationError as DjangoValidationError
4+
from rest_framework import serializers
5+
6+
from oauth2_metadata.services import validate_redirect_uri
7+
8+
# Allow ASCII letters, digits, spaces, hyphens, underscores, dots, and parentheses.
9+
# ASCII-only to prevent Unicode homoglyph spoofing on the consent screen.
10+
_CLIENT_NAME_RE = re.compile(r"^[\w\s.\-()]+$", re.ASCII)
11+
12+
13+
class DCRRequestSerializer(serializers.Serializer[None]):
14+
client_name = serializers.CharField(max_length=255, required=True)
15+
redirect_uris = serializers.ListField(
16+
child=serializers.URLField(),
17+
min_length=1,
18+
max_length=5,
19+
required=True,
20+
)
21+
grant_types = serializers.ListField(
22+
child=serializers.CharField(),
23+
required=False,
24+
default=["authorization_code", "refresh_token"],
25+
)
26+
response_types = serializers.ListField(
27+
child=serializers.CharField(),
28+
required=False,
29+
default=["code"],
30+
)
31+
token_endpoint_auth_method = serializers.CharField(
32+
required=False,
33+
default="none",
34+
)
35+
36+
def validate_client_name(self, value: str) -> str:
37+
if not _CLIENT_NAME_RE.match(value):
38+
raise serializers.ValidationError(
39+
"Client name may only contain letters, digits, spaces, "
40+
"hyphens, underscores, dots, and parentheses."
41+
)
42+
return value
43+
44+
def validate_redirect_uris(self, value: list[str]) -> list[str]:
45+
errors: list[str] = []
46+
for uri in value:
47+
try:
48+
validate_redirect_uri(uri)
49+
except DjangoValidationError as e:
50+
errors.append(str(e.message))
51+
if errors:
52+
raise serializers.ValidationError(errors)
53+
return value
54+
55+
def validate_token_endpoint_auth_method(self, value: str) -> str:
56+
if value != "none":
57+
raise serializers.ValidationError(
58+
"Only public clients are supported; "
59+
"token_endpoint_auth_method must be 'none'."
60+
)
61+
return value
62+
63+
def validate_grant_types(self, value: list[str]) -> list[str]:
64+
allowed = {"authorization_code", "refresh_token"}
65+
invalid = set(value) - allowed
66+
if invalid:
67+
raise serializers.ValidationError(
68+
f"Unsupported grant types: {', '.join(sorted(invalid))}"
69+
)
70+
return value
71+
72+
def validate_response_types(self, value: list[str]) -> list[str]:
73+
allowed = {"code"}
74+
invalid = set(value) - allowed
75+
if invalid:
76+
raise serializers.ValidationError(
77+
f"Unsupported response types: {', '.join(sorted(invalid))}"
78+
)
79+
return value

api/oauth2_metadata/services.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import logging
2+
from urllib.parse import urlparse
3+
4+
from django.core.exceptions import ValidationError
5+
from oauth2_provider.models import Application
6+
7+
logger = logging.getLogger(__name__)
8+
9+
10+
def validate_redirect_uri(uri: str) -> str:
11+
"""Validate a single redirect URI per DCR policy.
12+
13+
Rules:
14+
- HTTPS required for all redirect URIs
15+
- No wildcards, exact match only
16+
- No fragment components
17+
- localhost exception: http://localhost:*, http://127.0.0.1:*, and http://[::1]:* permitted
18+
"""
19+
parsed = urlparse(uri)
20+
21+
if not parsed.scheme or not parsed.netloc:
22+
raise ValidationError(f"Invalid URI: {uri}")
23+
24+
if "*" in uri:
25+
raise ValidationError(f"Wildcards are not permitted in redirect URIs: {uri}")
26+
27+
if parsed.fragment:
28+
raise ValidationError(f"Fragment components are not permitted: {uri}")
29+
30+
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
31+
32+
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
33+
raise ValidationError(
34+
f"HTTPS is required for redirect URIs (localhost excepted): {uri}"
35+
)
36+
37+
return uri
38+
39+
40+
def create_oauth2_application(
41+
*,
42+
client_name: str,
43+
redirect_uris: list[str],
44+
) -> Application:
45+
"""Create a public OAuth2 application for dynamic client registration."""
46+
application: Application = Application.objects.create(
47+
name=client_name,
48+
client_type=Application.CLIENT_PUBLIC,
49+
authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE,
50+
client_secret="",
51+
redirect_uris=" ".join(redirect_uris),
52+
skip_authorization=False,
53+
)
54+
logger.info(
55+
"OAuth2 DCR: registered application %s (client_id=%s).",
56+
client_name,
57+
application.client_id,
58+
)
59+
return application

api/oauth2_metadata/tasks.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,37 @@
1+
import logging
12
from datetime import timedelta
23

34
from django.core.management import call_command
5+
from django.utils import timezone
46
from task_processor.decorators import register_recurring_task
57

8+
logger = logging.getLogger(__name__)
9+
610

711
@register_recurring_task(run_every=timedelta(hours=24))
812
def clear_expired_oauth2_tokens() -> None:
913
call_command("cleartokens")
14+
15+
16+
@register_recurring_task(run_every=timedelta(hours=24))
17+
def cleanup_stale_oauth2_applications() -> None:
18+
"""Remove DCR applications that were never used to obtain a token.
19+
20+
An application is considered stale if it was registered more than 14 days
21+
ago and has no associated access tokens, refresh tokens, or grants.
22+
"""
23+
from django.db.models import Exists, OuterRef
24+
from oauth2_provider.models import AccessToken, Application, Grant, RefreshToken
25+
26+
threshold = timezone.now() - timedelta(days=14)
27+
stale = Application.objects.filter(
28+
created__lt=threshold,
29+
user__isnull=True, # Only DCR-created apps (no user)
30+
).exclude(
31+
Exists(AccessToken.objects.filter(application=OuterRef("pk")))
32+
| Exists(RefreshToken.objects.filter(application=OuterRef("pk")))
33+
| Exists(Grant.objects.filter(application=OuterRef("pk")))
34+
)
35+
count, _ = stale.delete()
36+
if count:
37+
logger.info("OAuth2 DCR cleanup: removed %d stale application(s).", count)

api/oauth2_metadata/views.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,15 @@
44
from django.http import HttpRequest, JsonResponse
55
from django.views.decorators.csrf import csrf_exempt
66
from django.views.decorators.http import require_GET
7+
from rest_framework import status as drf_status
8+
from rest_framework.permissions import AllowAny
9+
from rest_framework.request import Request
10+
from rest_framework.response import Response
11+
from rest_framework.throttling import ScopedRateThrottle
12+
from rest_framework.views import APIView
13+
14+
from oauth2_metadata.serializers import DCRRequestSerializer
15+
from oauth2_metadata.services import create_oauth2_application
716

817

918
@csrf_exempt
@@ -35,3 +44,63 @@ def authorization_server_metadata(request: HttpRequest) -> JsonResponse:
3544
}
3645

3746
return JsonResponse(metadata)
47+
48+
49+
class DynamicClientRegistrationView(APIView):
50+
"""RFC 7591 Dynamic Client Registration endpoint."""
51+
52+
authentication_classes: list[type] = []
53+
permission_classes = [AllowAny]
54+
throttle_classes = [ScopedRateThrottle]
55+
throttle_scope = "dcr_register"
56+
57+
# Map DRF serializer field names to RFC 7591 error codes.
58+
_rfc7591_error_codes: dict[str, str] = {
59+
"redirect_uris": "invalid_redirect_uri",
60+
"client_name": "invalid_client_metadata",
61+
"grant_types": "invalid_client_metadata",
62+
"response_types": "invalid_client_metadata",
63+
"token_endpoint_auth_method": "invalid_client_metadata",
64+
}
65+
66+
def post(self, request: Request) -> Response:
67+
serializer = DCRRequestSerializer(data=request.data)
68+
if not serializer.is_valid():
69+
return self._rfc7591_error_response(serializer.errors)
70+
71+
data = serializer.validated_data
72+
73+
application = create_oauth2_application(
74+
client_name=data["client_name"],
75+
redirect_uris=data["redirect_uris"],
76+
)
77+
78+
return Response(
79+
{
80+
"client_id": application.client_id,
81+
"client_name": application.name,
82+
"redirect_uris": data["redirect_uris"],
83+
"grant_types": data["grant_types"],
84+
"response_types": data["response_types"],
85+
"token_endpoint_auth_method": data["token_endpoint_auth_method"],
86+
"client_id_issued_at": int(application.created.timestamp()),
87+
},
88+
status=drf_status.HTTP_201_CREATED,
89+
)
90+
91+
def _rfc7591_error_response(self, errors: dict[str, list[str]]) -> Response:
92+
"""Format validation errors per RFC 7591 section 3.2.2."""
93+
first_field = next(iter(errors))
94+
error_code = self._rfc7591_error_codes.get(
95+
first_field, "invalid_client_metadata"
96+
)
97+
messages = errors[first_field]
98+
description = messages[0] if isinstance(messages[0], str) else str(messages[0])
99+
100+
return Response(
101+
{
102+
"error": error_code,
103+
"error_description": description,
104+
},
105+
status=drf_status.HTTP_400_BAD_REQUEST,
106+
)

0 commit comments

Comments
 (0)