Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
fccaa3a
feat: initial-dot-setup
Zaimwa9 Mar 27, 2026
e190637
feat: node-test-application
Zaimwa9 Mar 27, 2026
cf0c378
feat: lint
Zaimwa9 Mar 27, 2026
02d5fba
feat: registered-metadata-for-task-processing
Zaimwa9 Mar 27, 2026
eeb1584
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 27, 2026
6780f71
feat: updated-url-default
Zaimwa9 Mar 27, 2026
e330174
Merge branch 'feat/setup-dot-and-as-metadata' of github.com:Flagsmith…
Zaimwa9 Mar 27, 2026
ccd5ffd
feat: regenerated-open-api-specs
Zaimwa9 Mar 27, 2026
262ebda
feat: oauth-only-consumes-authorization-header
Zaimwa9 Mar 27, 2026
1f0d84e
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Mar 27, 2026
ba950be
feat: type-linting
Zaimwa9 Mar 30, 2026
a528acc
feat: tested-authentication-and-cleartoken-call
Zaimwa9 Mar 30, 2026
eb8ccff
feat: lint
Zaimwa9 Mar 30, 2026
74d4fc1
feat: rebased
Zaimwa9 Mar 30, 2026
36477da
feat: fixed-dependencies-type-errors
Zaimwa9 Mar 30, 2026
ec0b067
feat: regenerate-openapi-specs
Zaimwa9 Mar 30, 2026
8e9005d
feat: added-dcr-endpoints
Zaimwa9 Mar 31, 2026
df96c27
feat: added-throttle-on-dcr-registration-endpoint
Zaimwa9 Mar 31, 2026
5e28088
feat: clean-up-stale-apps
Zaimwa9 Mar 31, 2026
8a92a8a
feat: added-dcr-tests
Zaimwa9 Mar 31, 2026
d741808
feat: use-standard-rfc7591-errors
Zaimwa9 Apr 1, 2026
2783d3e
feat: removed-daily-logging-of-created-apps
Zaimwa9 Apr 1, 2026
38ed2b9
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Apr 1, 2026
610acbd
feat: added-test-coverage
Zaimwa9 Apr 1, 2026
8098090
feat: removed-cleanup-task-antijoin-pattern
Zaimwa9 Apr 1, 2026
990f3e3
feat: added-ipv6-local-in-whitelist
Zaimwa9 Apr 1, 2026
6806acb
feat: restricted-client-application-to-ascii
Zaimwa9 Apr 1, 2026
6a98fa6
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Apr 1, 2026
03717aa
feat: misc-cleanup
Zaimwa9 Apr 1, 2026
910765b
Merge branch 'feat/implement-dynamic-client-registration' of github.c…
Zaimwa9 Apr 1, 2026
a1fea8e
feat: coverage-on-blank-client-name
Zaimwa9 Apr 1, 2026
779b0eb
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Apr 1, 2026
0dcd6b7
feat: blank-client-name-validation-with-drf
Zaimwa9 Apr 2, 2026
a10dfd8
feat: renamed-tests
Zaimwa9 Apr 2, 2026
a972c62
feat: addressed-review-comments
Zaimwa9 Apr 6, 2026
254c215
feat: lint
Zaimwa9 Apr 6, 2026
f39086c
feat: rebased
Zaimwa9 Apr 6, 2026
ff28ba0
feat: removed-testing-related-code
Zaimwa9 Apr 6, 2026
2287016
feat: rebased
Zaimwa9 Apr 7, 2026
b4bcbba
feat: add-flagsmith-api-and-frontend-url-to-ecs-task-definitions
Zaimwa9 Apr 7, 2026
4ba885b
Merge branch 'feat/setup-dot-and-as-metadata' of github.com:Flagsmith…
Zaimwa9 Apr 7, 2026
b64229b
feat: resolved-conflicts
Zaimwa9 Apr 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions api/app/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,7 @@


LOGIN_THROTTLE_RATE = env("LOGIN_THROTTLE_RATE", "20/min")
DCR_THROTTLE_RATE = env("DCR_THROTTLE_RATE", "500/month")
SIGNUP_THROTTLE_RATE = env("SIGNUP_THROTTLE_RATE", "10000/min")
USER_THROTTLE_RATE = env("USER_THROTTLE_RATE", default=None)
MASTER_API_KEY_THROTTLE_RATE = env("MASTER_API_KEY_THROTTLE_RATE", default=None)
Expand All @@ -321,6 +322,7 @@
"DEFAULT_THROTTLE_CLASSES": DEFAULT_THROTTLE_CLASSES,
"DEFAULT_THROTTLE_RATES": {
"login": LOGIN_THROTTLE_RATE,
"dcr_register": DCR_THROTTLE_RATE,
"signup": SIGNUP_THROTTLE_RATE,
"master_api_key": MASTER_API_KEY_THROTTLE_RATE,
"mfa_code": "5/min",
Expand Down
1 change: 1 addition & 0 deletions api/app/settings/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
REST_FRAMEWORK["DEFAULT_THROTTLE_CLASSES"] = ["core.throttling.UserRateThrottle"]
REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"] = {
"login": "100/min",
"dcr_register": "100/min",
"mfa_code": "5/min",
"invite": "10/min",
"signup": "100/min",
Expand Down
10 changes: 9 additions & 1 deletion api/app/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@
from django.views.generic.base import TemplateView
from oauth2_provider import views as oauth2_views

from oauth2_metadata.views import authorization_server_metadata
from oauth2_metadata.views import (
DynamicClientRegistrationView,
authorization_server_metadata,
)
from users.views import password_reset_redirect

from . import views
Expand Down Expand Up @@ -54,6 +57,11 @@
"robots.txt",
TemplateView.as_view(template_name="robots.txt", content_type="text/plain"),
),
path(
"o/register/",
DynamicClientRegistrationView.as_view(),
name="oauth2-dcr-register",
),
path(
"o/",
include(
Expand Down
79 changes: 79 additions & 0 deletions api/oauth2_metadata/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import re

from django.core.exceptions import ValidationError as DjangoValidationError
from rest_framework import serializers

from oauth2_metadata.services import validate_redirect_uri

# Allow ASCII letters, digits, spaces, hyphens, underscores, dots, and parentheses.
# ASCII-only to prevent Unicode homoglyph spoofing on the consent screen.
_CLIENT_NAME_RE = re.compile(r"^[\w\s.\-()]+$", re.ASCII)


class DCRRequestSerializer(serializers.Serializer[None]):
client_name = serializers.CharField(max_length=255, required=True)
redirect_uris = serializers.ListField(
child=serializers.URLField(),
min_length=1,
max_length=5,
required=True,
)
grant_types = serializers.ListField(
child=serializers.CharField(),
required=False,
default=["authorization_code", "refresh_token"],
)
response_types = serializers.ListField(
child=serializers.CharField(),
required=False,
default=["code"],
)
token_endpoint_auth_method = serializers.CharField(
required=False,
default="none",
)

def validate_client_name(self, value: str) -> str:
if not _CLIENT_NAME_RE.match(value):
raise serializers.ValidationError(
"Client name may only contain letters, digits, spaces, "
"hyphens, underscores, dots, and parentheses."
)
return value

def validate_redirect_uris(self, value: list[str]) -> list[str]:
errors: list[str] = []
for uri in value:
try:
validate_redirect_uri(uri)
except DjangoValidationError as e:
errors.append(str(e.message))
if errors:
raise serializers.ValidationError(errors)
return value

def validate_token_endpoint_auth_method(self, value: str) -> str:
if value != "none":
raise serializers.ValidationError(
"Only public clients are supported; "
"token_endpoint_auth_method must be 'none'."
)
return value

def validate_grant_types(self, value: list[str]) -> list[str]:
allowed = {"authorization_code", "refresh_token"}
invalid = set(value) - allowed
if invalid:
raise serializers.ValidationError(
f"Unsupported grant types: {', '.join(sorted(invalid))}"
)
return value

def validate_response_types(self, value: list[str]) -> list[str]:
allowed = {"code"}
invalid = set(value) - allowed
if invalid:
raise serializers.ValidationError(
f"Unsupported response types: {', '.join(sorted(invalid))}"
)
return value
59 changes: 59 additions & 0 deletions api/oauth2_metadata/services.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import logging
from urllib.parse import urlparse

from django.core.exceptions import ValidationError
from oauth2_provider.models import Application

logger = logging.getLogger(__name__)


def validate_redirect_uri(uri: str) -> str:
"""Validate a single redirect URI per DCR policy.

Rules:
- HTTPS required for all redirect URIs
- No wildcards, exact match only
- No fragment components
- localhost exception: http://localhost:*, http://127.0.0.1:*, and http://[::1]:* permitted
"""
parsed = urlparse(uri)

if not parsed.scheme or not parsed.netloc:
raise ValidationError(f"Invalid URI: {uri}")

if "*" in uri:
raise ValidationError(f"Wildcards are not permitted in redirect URIs: {uri}")

if parsed.fragment:
raise ValidationError(f"Fragment components are not permitted: {uri}")

is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")

if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
raise ValidationError(
f"HTTPS is required for redirect URIs (localhost excepted): {uri}"
)

return uri


def create_oauth2_application(
*,
client_name: str,
redirect_uris: list[str],
) -> Application:
"""Create a public OAuth2 application for dynamic client registration."""
application: Application = Application.objects.create(
name=client_name,
client_type=Application.CLIENT_PUBLIC,
authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE,
client_secret="",
redirect_uris=" ".join(redirect_uris),
skip_authorization=False,
)
logger.info(
"OAuth2 DCR: registered application %s (client_id=%s).",
client_name,
application.client_id,
)
return application
28 changes: 28 additions & 0 deletions api/oauth2_metadata/tasks.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,37 @@
import logging
from datetime import timedelta

from django.core.management import call_command
from django.utils import timezone
from task_processor.decorators import register_recurring_task

logger = logging.getLogger(__name__)


@register_recurring_task(run_every=timedelta(hours=24))
def clear_expired_oauth2_tokens() -> None:
call_command("cleartokens")


@register_recurring_task(run_every=timedelta(hours=24))
def cleanup_stale_oauth2_applications() -> None:
"""Remove DCR applications that were never used to obtain a token.

An application is considered stale if it was registered more than 14 days
ago and has no associated access tokens, refresh tokens, or grants.
"""
from django.db.models import Exists, OuterRef
from oauth2_provider.models import AccessToken, Application, Grant, RefreshToken

threshold = timezone.now() - timedelta(days=14)
stale = Application.objects.filter(
created__lt=threshold,
user__isnull=True, # Only DCR-created apps (no user)
).exclude(
Exists(AccessToken.objects.filter(application=OuterRef("pk")))
| Exists(RefreshToken.objects.filter(application=OuterRef("pk")))
| Exists(Grant.objects.filter(application=OuterRef("pk")))
)
count, _ = stale.delete()
if count:
logger.info("OAuth2 DCR cleanup: removed %d stale application(s).", count)
69 changes: 69 additions & 0 deletions api/oauth2_metadata/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@
from django.http import HttpRequest, JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_GET
from rest_framework import status as drf_status
from rest_framework.permissions import AllowAny
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView

from oauth2_metadata.serializers import DCRRequestSerializer
from oauth2_metadata.services import create_oauth2_application


@csrf_exempt
Expand Down Expand Up @@ -35,3 +44,63 @@ def authorization_server_metadata(request: HttpRequest) -> JsonResponse:
}

return JsonResponse(metadata)


class DynamicClientRegistrationView(APIView):
"""RFC 7591 Dynamic Client Registration endpoint."""

authentication_classes: list[type] = []
permission_classes = [AllowAny]
throttle_classes = [ScopedRateThrottle]
throttle_scope = "dcr_register"

# Map DRF serializer field names to RFC 7591 error codes.
_rfc7591_error_codes: dict[str, str] = {
"redirect_uris": "invalid_redirect_uri",
"client_name": "invalid_client_metadata",
"grant_types": "invalid_client_metadata",
"response_types": "invalid_client_metadata",
"token_endpoint_auth_method": "invalid_client_metadata",
}

def post(self, request: Request) -> Response:
serializer = DCRRequestSerializer(data=request.data)
if not serializer.is_valid():
return self._rfc7591_error_response(serializer.errors)

data = serializer.validated_data

application = create_oauth2_application(
client_name=data["client_name"],
redirect_uris=data["redirect_uris"],
)

return Response(
{
"client_id": application.client_id,
"client_name": application.name,
"redirect_uris": data["redirect_uris"],
"grant_types": data["grant_types"],
"response_types": data["response_types"],
"token_endpoint_auth_method": data["token_endpoint_auth_method"],
"client_id_issued_at": int(application.created.timestamp()),
},
status=drf_status.HTTP_201_CREATED,
)

def _rfc7591_error_response(self, errors: dict[str, list[str]]) -> Response:
"""Format validation errors per RFC 7591 section 3.2.2."""
first_field = next(iter(errors))
error_code = self._rfc7591_error_codes.get(
first_field, "invalid_client_metadata"
)
messages = errors[first_field]
description = messages[0] if isinstance(messages[0], str) else str(messages[0])

return Response(
{
"error": error_code,
"error_description": description,
},
status=drf_status.HTTP_400_BAD_REQUEST,
)
Loading
Loading