Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 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
e61946b
feat: backend-oauth-consent-screen
Zaimwa9 Apr 3, 2026
7488731
feat: parametrize-tests
Zaimwa9 Apr 3, 2026
1f49e76
feat: linting
Zaimwa9 Apr 6, 2026
a972c62
feat: addressed-review-comments
Zaimwa9 Apr 6, 2026
254c215
feat: lint
Zaimwa9 Apr 6, 2026
599c0c4
Merge branch 'feat/implement-dynamic-client-registration' of github.c…
Zaimwa9 Apr 6, 2026
235ba2b
feat: lint
Zaimwa9 Apr 6, 2026
70eca0a
feat: guard-against-500-and-comments
Zaimwa9 Apr 6, 2026
f39086c
feat: rebased
Zaimwa9 Apr 6, 2026
ff28ba0
feat: removed-testing-related-code
Zaimwa9 Apr 6, 2026
f02e487
feat: coverage
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
bbe6da2
feat: rebased
Zaimwa9 Apr 7, 2026
cd3ba76
feat: resolved-conflicts
Zaimwa9 Apr 8, 2026
85b4a27
feat: removed-hardcoded-project-in-test
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
6 changes: 6 additions & 0 deletions api/app/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from oauth2_metadata.views import (
DynamicClientRegistrationView,
OAuthAuthorizeView,
authorization_server_metadata,
)
from users.views import password_reset_redirect
Expand Down Expand Up @@ -57,6 +58,11 @@
"robots.txt",
TemplateView.as_view(template_name="robots.txt", content_type="text/plain"),
),
path(
"api/v1/oauth/authorize/",
OAuthAuthorizeView.as_view(),
name="oauth-authorize",
),
path(
"o/register/",
DynamicClientRegistrationView.as_view(),
Expand Down
12 changes: 12 additions & 0 deletions api/oauth2_metadata/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,18 @@

from oauth2_metadata.services import validate_redirect_uri


class OAuthConsentSerializer(serializers.Serializer): # type: ignore[type-arg]
allow = serializers.BooleanField()
client_id = serializers.CharField()
redirect_uri = serializers.CharField()
response_type = serializers.CharField()
scope = serializers.CharField(required=False, default="mcp")
code_challenge = serializers.CharField()
code_challenge_method = serializers.CharField()
state = serializers.CharField(required=False, allow_blank=True, default="")


# 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)
Expand Down
106 changes: 103 additions & 3 deletions api/oauth2_metadata/views.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
from typing import Any
from urllib.parse import urlencode, urlparse, urlunparse

from django.conf import settings
from django.http import HttpRequest, JsonResponse
from django.http import HttpRequest, JsonResponse, QueryDict
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_GET
from oauth2_provider.exceptions import OAuthToolkitError
from oauth2_provider.models import get_application_model
from oauth2_provider.scopes import get_scopes_backend
from oauth2_provider.views.mixins import OAuthLibMixin
from rest_framework import status
from rest_framework import status as drf_status
from rest_framework.permissions import AllowAny
from rest_framework.permissions import AllowAny, IsAuthenticated
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.serializers import DCRRequestSerializer, OAuthConsentSerializer
from oauth2_metadata.services import create_oauth2_application


Expand Down Expand Up @@ -46,6 +52,100 @@ def authorization_server_metadata(request: HttpRequest) -> JsonResponse:
return JsonResponse(metadata)


class OAuthAuthorizeView(OAuthLibMixin, APIView): # type: ignore[misc]
"""Validate an OAuth authorisation request and process consent decisions."""

permission_classes = [IsAuthenticated]

def get(self, request: Request, *args: Any, **kwargs: Any) -> Response:
"""Validate an authorisation request and return application info."""
# Bridge DRF auth to Django request so DOT sees the authenticated user.
request._request.user = request.user

try:
scopes, credentials = self.validate_authorization_request(request._request)
except OAuthToolkitError as e:
oauthlib_error = e.oauthlib_error
return Response(
{
"error": getattr(oauthlib_error, "error", "invalid_request"),
"error_description": getattr(oauthlib_error, "description", str(e)),
},
status=status.HTTP_400_BAD_REQUEST,
)

Application = get_application_model()
application = Application.objects.get(
client_id=credentials["client_id"],
)
all_scopes = get_scopes_backend().get_all_scopes()
scopes_dict: dict[str, str] = {s: all_scopes.get(s, s) for s in scopes}
return Response(
{
"application": {
"name": application.name,
"client_id": application.client_id,
},
"scopes": scopes_dict,
"redirect_uri": credentials.get("redirect_uri", ""),
# skip_authorization is safe to reuse here: this custom view
# always shows the consent screen regardless of this flag.
# We only use it as a trust signal for the frontend UI.
"is_verified": bool(application.skip_authorization),
}
)

def post(self, request: Request, *args: Any, **kwargs: Any) -> Response:
"""Process a consent decision and return the redirect URI."""
serializer = OAuthConsentSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data: dict[str, Any] = serializer.validated_data
allow: bool = data.pop("allow")

# Bridge DRF auth to Django request so DOT sees the authenticated user.
request._request.user = request.user

# DOT's validate_authorization_request reads OAuth params from GET
# and also from request.get_full_path() which uses META['QUERY_STRING'].
# This is necessary because DOT's OAuthLibMixin is designed for
# form-based flows where params arrive via GET. If a DOT upgrade
# changes how it reads params, this will need updating.
query = QueryDict(mutable=True)
for key, value in data.items():
query[key] = str(value)
request._request.GET = query # type: ignore[assignment]
request._request.META["QUERY_STRING"] = query.urlencode()

try:
scopes, credentials = self.validate_authorization_request(request._request)
except OAuthToolkitError as e:
oauthlib_error = e.oauthlib_error
return Response(
{
"error": getattr(oauthlib_error, "error", "invalid_request"),
"error_description": getattr(oauthlib_error, "description", str(e)),
},
status=status.HTTP_400_BAD_REQUEST,
)

try:
scopes_str = " ".join(scopes) if isinstance(scopes, list) else scopes
uri, _headers, _body, _status = self.create_authorization_response(
request._request, scopes_str, credentials, allow
)
except OAuthToolkitError:
# User denied access -- build the error redirect manually.
redirect_uri = credentials.get("redirect_uri", data.get("redirect_uri", ""))
state = credentials.get("state", data.get("state", ""))
error_params: dict[str, str] = {"error": "access_denied"}
if state:
error_params["state"] = state
parsed = urlparse(str(redirect_uri))
uri = urlunparse(parsed._replace(query=urlencode(error_params)))

return Response({"redirect_uri": uri})


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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from features.versioning.models import EnvironmentFeatureVersion
from integrations.github.constants import GITHUB_API_URL, GITHUB_API_VERSION
from integrations.github.models import GithubConfiguration, GitHubRepository
from organisations.models import Organisation
from projects.models import Project
from projects.tags.models import Tag
from segments.models import Segment
Expand Down Expand Up @@ -316,14 +317,17 @@ def test_create_feature_external_resource__no_project_permissions__returns_403(
feature: Feature,
) -> None:
# Given
new_org = Organisation.objects.create(name="New Org")
new_project = Project.objects.create(name="New Project", organisation=new_org)
feature_external_resource_data = {
"type": "GITHUB_ISSUE",
"url": "https://example.com?item=create",
"feature": feature.id,
"metadata": {"status": "open"},
}
url = reverse(
"api-v1:projects:feature-external-resources-list", args=[2, feature.id]
"api-v1:projects:feature-external-resources-list",
args=[new_project.id, feature.id],
)

# When
Expand Down
Loading
Loading