Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 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
65b596f
feat: frontend-oauth-implementation
Zaimwa9 Apr 6, 2026
ece3136
Merge branch 'feat/oauth-consent-backend' of github.com:Flagsmith/fla…
Zaimwa9 Apr 6, 2026
bbee8fc
feat: misc-minor-improvements
Zaimwa9 Apr 6, 2026
70eca0a
feat: guard-against-500-and-comments
Zaimwa9 Apr 6, 2026
18d0655
Merge branch 'feat/oauth-consent-backend' of github.com:Flagsmith/fla…
Zaimwa9 Apr 6, 2026
f39086c
feat: rebased
Zaimwa9 Apr 6, 2026
ff28ba0
feat: removed-testing-related-code
Zaimwa9 Apr 6, 2026
d2bb278
feat: moved-css-to-file-and-reuse-existing-icons
Zaimwa9 Apr 6, 2026
f02e487
feat: coverage
Zaimwa9 Apr 6, 2026
e4b8cec
feat: prepared-open-api-specs-for-oauth
Zaimwa9 Apr 7, 2026
63c2594
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Apr 7, 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
d63423e
Merge branch 'feat/oauth-consent-backend' of github.com:Flagsmith/fla…
Zaimwa9 Apr 7, 2026
cec3de1
Merge branch 'feat/oauth-consent-frontend' of github.com:Flagsmith/fl…
Zaimwa9 Apr 7, 2026
36ef8df
Merge branch 'main' of github.com:Flagsmith/flagsmith into feat/updat…
Zaimwa9 Apr 8, 2026
bf59f5b
feat: moved-oauth-to-dataclass
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
29 changes: 23 additions & 6 deletions api/api/openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
from rest_framework.request import Request
from typing_extensions import is_typeddict

from oauth2_metadata.dataclasses import OAuthConfig


def append_meta(schema: dict[str, Any], meta: dict[str, Any]) -> dict[str, Any]:
"""
Expand Down Expand Up @@ -73,21 +75,21 @@ class MCPSchemaGenerator(SchemaGenerator):
"""

MCP_TAG = "mcp"
MCP_SERVER_URL = "https://api.flagsmith.com"

def get_schema(
self, request: Request | None = None, public: bool = False
) -> dict[str, Any]:
oauth = OAuthConfig.from_settings()
schema = super().get_schema(request, public)
schema["paths"] = self._filter_paths(schema.get("paths", {}))
schema = self._update_security_for_mcp(schema)
schema = self._update_security_for_mcp(schema, oauth)
schema.pop("$schema", None)
info = schema.pop("info").copy()
info["title"] = "mcp_openapi"
return {
"openapi": schema.pop("openapi"),
"info": info,
"servers": [{"url": self.MCP_SERVER_URL}],
"servers": [{"url": oauth.api_url}],
**schema,
}

Expand Down Expand Up @@ -121,19 +123,34 @@ def _transform_for_mcp(self, operation: dict[str, Any]) -> dict[str, Any]:
operation.pop("security", None)
return operation

def _update_security_for_mcp(self, schema: dict[str, Any]) -> dict[str, Any]:
"""Update security schemes for MCP (Organisation API Key)."""
def _update_security_for_mcp(
self, schema: dict[str, Any], oauth: OAuthConfig
) -> dict[str, Any]:
"""Update security schemes for MCP (OAuth + API Key fallback)."""
schema = schema.copy()
schema["components"] = schema.get("components", {}).copy()
schema["components"]["securitySchemes"] = {
"oauth2": {
"type": "oauth2",
"flows": {
"authorizationCode": {
"authorizationUrl": f"{oauth.frontend_url}/oauth/authorize/",
"tokenUrl": f"{oauth.api_url}/o/token/",
"scopes": oauth.scopes,
},
},
},
"TOKEN_AUTH": {
"type": "apiKey",
"in": "header",
"name": "Authorization",
"description": "Organisation API Key. Format: Api-Key <key>",
},
}
schema["security"] = [{"TOKEN_AUTH": []}]
schema["security"] = [
{"oauth2": list(oauth.scopes.keys())},
{"TOKEN_AUTH": []},
]
return schema


Expand Down
24 changes: 24 additions & 0 deletions api/oauth2_metadata/dataclasses.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
from __future__ import annotations

from dataclasses import dataclass
from typing import Any

from django.conf import settings


@dataclass(frozen=True)
class OAuthConfig:
"""Base OAuth configuration derived from Django settings."""

api_url: str
frontend_url: str
scopes: dict[str, str]

@classmethod
def from_settings(cls) -> OAuthConfig:
oauth2_provider: dict[str, Any] = settings.OAUTH2_PROVIDER
return cls(
api_url=settings.FLAGSMITH_API_URL.rstrip("/"),
frontend_url=settings.FLAGSMITH_FRONTEND_URL.rstrip("/"),
scopes=oauth2_provider.get("SCOPES", {}),
)
21 changes: 9 additions & 12 deletions api/oauth2_metadata/views.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from typing import Any
from urllib.parse import urlencode, urlparse, urlunparse

from django.conf import settings
from django.http import HttpRequest, JsonResponse, QueryDict
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_GET
Expand All @@ -17,6 +16,7 @@
from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView

from oauth2_metadata.dataclasses import OAuthConfig
from oauth2_metadata.serializers import DCRRequestSerializer, OAuthConsentSerializer
from oauth2_metadata.services import create_oauth2_application

Expand All @@ -25,19 +25,16 @@
@require_GET
def authorization_server_metadata(request: HttpRequest) -> JsonResponse:
"""RFC 8414 OAuth 2.0 Authorization Server Metadata."""
api_url: str = settings.FLAGSMITH_API_URL.rstrip("/")
frontend_url: str = settings.FLAGSMITH_FRONTEND_URL.rstrip("/")
oauth2_settings: dict[str, Any] = settings.OAUTH2_PROVIDER
scopes: dict[str, str] = oauth2_settings.get("SCOPES", {})
oauth = OAuthConfig.from_settings()

metadata = {
"issuer": api_url,
"authorization_endpoint": f"{frontend_url}/oauth/authorize/",
"token_endpoint": f"{api_url}/o/token/",
"registration_endpoint": f"{api_url}/o/register/",
"revocation_endpoint": f"{api_url}/o/revoke_token/",
"introspection_endpoint": f"{api_url}/o/introspect/",
"scopes_supported": list(scopes.keys()),
"issuer": oauth.api_url,
"authorization_endpoint": f"{oauth.frontend_url}/oauth/authorize/",
"token_endpoint": f"{oauth.api_url}/o/token/",
"registration_endpoint": f"{oauth.api_url}/o/register/",
"revocation_endpoint": f"{oauth.api_url}/o/revoke_token/",
"introspection_endpoint": f"{oauth.api_url}/o/introspect/",
"scopes_supported": list(oauth.scopes.keys()),
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
Expand Down
40 changes: 32 additions & 8 deletions api/tests/unit/api/test_mcp_openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@
from unittest.mock import MagicMock

import pytest
from pytest_django.fixtures import SettingsWrapper

from api.openapi import MCPSchemaGenerator, SchemaGenerator
from api.openapi_views import CustomSpectacularJSONAPIView, CustomSpectacularYAMLAPIView
from oauth2_metadata.dataclasses import OAuthConfig


def test_mcp_filter_paths__mcp_tagged_operation__includes_path() -> None:
Expand Down Expand Up @@ -143,10 +145,15 @@ def test_mcp_transform_for_mcp__security_present__removes_operation_level_securi
assert "security" not in transformed


def test_mcp_update_security_for_mcp__existing_scheme__sets_token_auth_security() -> (
def test_mcp_update_security_for_mcp__existing_scheme__sets_oauth_and_token_auth() -> (
None
):
# Given
oauth = OAuthConfig(
api_url="https://api.flagsmith.example.com",
frontend_url="https://app.flagsmith.example.com",
scopes={"mcp": "MCP access"},
)
schema: dict[str, Any] = {
"components": {
"securitySchemes": {
Expand All @@ -158,12 +165,21 @@ def test_mcp_update_security_for_mcp__existing_scheme__sets_token_auth_security(
generator = MCPSchemaGenerator()

# When
updated = generator._update_security_for_mcp(schema)
updated = generator._update_security_for_mcp(schema, oauth)

# Then
assert "TOKEN_AUTH" in updated["components"]["securitySchemes"]
assert updated["security"] == [{"TOKEN_AUTH": []}]
assert "Private" not in updated["components"]["securitySchemes"]
assert "TOKEN_AUTH" in updated["components"]["securitySchemes"]
oauth2_scheme = updated["components"]["securitySchemes"]["oauth2"]
assert oauth2_scheme["type"] == "oauth2"
auth_code_flow = oauth2_scheme["flows"]["authorizationCode"]
assert (
auth_code_flow["authorizationUrl"]
== "https://app.flagsmith.example.com/oauth/authorize/"
)
assert auth_code_flow["tokenUrl"] == "https://api.flagsmith.example.com/o/token/"
assert auth_code_flow["scopes"] == {"mcp": "MCP access"}
assert updated["security"] == [{"oauth2": ["mcp"]}, {"TOKEN_AUTH": []}]


@pytest.mark.parametrize(
Expand Down Expand Up @@ -267,24 +283,32 @@ def test_mcp_schema__full_schema_generated__includes_expected_endpoints_only() -
assert "/api/v1/users/" not in paths


def test_mcp_schema__full_schema_generated__includes_https_server() -> None:
def test_mcp_schema__full_schema_generated__includes_server_from_settings(
settings: SettingsWrapper,
) -> None:
# Given
settings.FLAGSMITH_API_URL = "https://flagsmith.example.com/"
generator = MCPSchemaGenerator()

# When
schema = generator.get_schema(request=None, public=True)

# Then
assert schema["servers"] == [{"url": "https://api.flagsmith.com"}]
assert schema["servers"] == [{"url": "https://flagsmith.example.com"}]


def test_mcp_schema__full_schema_generated__includes_token_auth_security() -> None:
def test_mcp_schema__full_schema_generated__includes_oauth_and_token_auth(
settings: SettingsWrapper,
) -> None:
# Given
settings.FLAGSMITH_API_URL = "https://api.flagsmith.example.com/"
settings.FLAGSMITH_FRONTEND_URL = "https://app.flagsmith.example.com/"
generator = MCPSchemaGenerator()

# When
schema = generator.get_schema(request=None, public=True)

# Then
assert "TOKEN_AUTH" in schema["components"]["securitySchemes"]
assert schema["security"] == [{"TOKEN_AUTH": []}]
assert "oauth2" in schema["components"]["securitySchemes"]
assert schema["security"] == [{"oauth2": ["mcp"]}, {"TOKEN_AUTH": []}]
Loading