diff --git a/api/app/settings/common.py b/api/app/settings/common.py index c75fae2fbf34..e82fd1b8241a 100644 --- a/api/app/settings/common.py +++ b/api/app/settings/common.py @@ -88,6 +88,7 @@ "rest_framework.authtoken", # Used for managing api keys "rest_framework_api_key", + "oauth2_provider", "rest_framework_simplejwt.token_blacklist", "djoser", "django.contrib.sites", @@ -165,6 +166,7 @@ "softdelete", "metadata", "app_analytics", + "oauth2_metadata", ] SILENCED_SYSTEM_CHECKS = ["axes.W002"] @@ -312,6 +314,7 @@ "custom_auth.jwt_cookie.authentication.JWTCookieAuthentication", "rest_framework.authentication.TokenAuthentication", "api_keys.authentication.MasterAPIKeyAuthentication", + "oauth2_metadata.authentication.OAuth2BearerTokenAuthentication", ), "PAGE_SIZE": 10, "UNICODE_JSON": False, @@ -941,6 +944,26 @@ "SIGNING_KEY": env.str("COOKIE_AUTH_JWT_SIGNING_KEY", default=SECRET_KEY), } +# OAuth 2.1 Provider (django-oauth-toolkit) +FLAGSMITH_API_URL = env.str("FLAGSMITH_API_URL", default="http://localhost:8000") +FLAGSMITH_FRONTEND_URL = env.str( + "FLAGSMITH_FRONTEND_URL", default="http://localhost:8080" +) + +OAUTH2_PROVIDER = { + "ACCESS_TOKEN_EXPIRE_SECONDS": 60 * 15, # 15 minutes + "REFRESH_TOKEN_EXPIRE_SECONDS": 60 * 60 * 24 * 30, # 30 days + "ROTATE_REFRESH_TOKEN": True, + "PKCE_REQUIRED": True, + "ALLOWED_CODE_CHALLENGE_METHODS": ["S256"], + "SCOPES": {"mcp": "MCP access"}, + "DEFAULT_SCOPES": ["mcp"], + "ALLOWED_GRANT_TYPES": [ + "authorization_code", + "refresh_token", + ], +} + # Github OAuth credentials GITHUB_CLIENT_ID = env.str("GITHUB_CLIENT_ID", default="") GITHUB_CLIENT_SECRET = env.str("GITHUB_CLIENT_SECRET", default="") diff --git a/api/app/urls.py b/api/app/urls.py index d5b68f85f6ca..b9a7e1181b32 100644 --- a/api/app/urls.py +++ b/api/app/urls.py @@ -6,6 +6,7 @@ from django.urls import include, path, re_path from django.views.generic.base import TemplateView +from oauth2_metadata.views import authorization_server_metadata from users.views import password_reset_redirect from . import views @@ -13,6 +14,11 @@ urlpatterns = [ *core_urlpatterns, path("processor/", include("task_processor.urls")), + path( + ".well-known/oauth-authorization-server", + authorization_server_metadata, + name="oauth-authorization-server-metadata", + ), ] if not settings.TASK_PROCESSOR_MODE: @@ -47,6 +53,8 @@ "robots.txt", TemplateView.as_view(template_name="robots.txt", content_type="text/plain"), ), + # Authorize template view for testing: this will be moved to the frontend in following issues + path("o/", include("oauth2_provider.urls", namespace="oauth2_provider")), ] if settings.DEBUG: # pragma: no cover diff --git a/api/oauth2_metadata/__init__.py b/api/oauth2_metadata/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/api/oauth2_metadata/apps.py b/api/oauth2_metadata/apps.py new file mode 100644 index 000000000000..531858ca6ec9 --- /dev/null +++ b/api/oauth2_metadata/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + + +class OAuth2MetadataConfig(AppConfig): + name = "oauth2_metadata" + + def ready(self) -> None: + from oauth2_metadata import tasks # noqa: F401 diff --git a/api/oauth2_metadata/authentication.py b/api/oauth2_metadata/authentication.py new file mode 100644 index 000000000000..901877b4ea3d --- /dev/null +++ b/api/oauth2_metadata/authentication.py @@ -0,0 +1,17 @@ +from typing import Any + +from oauth2_provider.contrib.rest_framework import OAuth2Authentication +from rest_framework.request import Request + + +class OAuth2BearerTokenAuthentication(OAuth2Authentication): # type: ignore[misc] + """DOT's default OAuth2Authentication also reads the request body + looking for an access_token, which consumes the stream and breaks + views that need to read request.body. + """ + + def authenticate(self, request: Request) -> tuple[Any, Any] | None: + auth_header = request.META.get("HTTP_AUTHORIZATION", "") + if not auth_header.startswith("Bearer "): + return None + return super().authenticate(request) # type: ignore[no-any-return] diff --git a/api/oauth2_metadata/tasks.py b/api/oauth2_metadata/tasks.py new file mode 100644 index 000000000000..372078267f9e --- /dev/null +++ b/api/oauth2_metadata/tasks.py @@ -0,0 +1,9 @@ +from datetime import timedelta + +from django.core.management import call_command +from task_processor.decorators import register_recurring_task + + +@register_recurring_task(run_every=timedelta(hours=24)) +def clear_expired_oauth2_tokens() -> None: + call_command("cleartokens") diff --git a/api/oauth2_metadata/views.py b/api/oauth2_metadata/views.py new file mode 100644 index 000000000000..25cbc77071d5 --- /dev/null +++ b/api/oauth2_metadata/views.py @@ -0,0 +1,37 @@ +from typing import Any + +from django.conf import settings +from django.http import HttpRequest, JsonResponse +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_GET + + +@csrf_exempt +@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", {}) + + 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()), + "response_types_supported": ["code"], + "grant_types_supported": ["authorization_code", "refresh_token"], + "code_challenge_methods_supported": ["S256"], + "token_endpoint_auth_methods_supported": [ + "client_secret_basic", + "client_secret_post", + "none", + ], + "introspection_endpoint_auth_methods_supported": ["none"], + } + + return JsonResponse(metadata) diff --git a/api/oauth2_test_server.mjs b/api/oauth2_test_server.mjs new file mode 100644 index 000000000000..1d0029a59faf --- /dev/null +++ b/api/oauth2_test_server.mjs @@ -0,0 +1,81 @@ +import { createServer } from "node:http"; +import { randomBytes, createHash } from "node:crypto"; + +const CLIENT_ID = "ZLsLu3hhJI4GlhNsGeFVC3K2U3QBGfXtmc0EcyiG"; +const REDIRECT_URI = "http://localhost:3000/oauth/callback"; +const API_URL = "http://localhost:8000"; +const PORT = 3000; + +// Generate PKCE values +const codeVerifier = randomBytes(96).toString("base64url").slice(0, 128); +const codeChallenge = createHash("sha256") + .update(codeVerifier) + .digest("base64url"); + +const authorizeUrl = + `${API_URL}/o/authorize/?` + + new URLSearchParams({ + response_type: "code", + client_id: CLIENT_ID, + redirect_uri: REDIRECT_URI, + scope: "mcp", + code_challenge: codeChallenge, + code_challenge_method: "S256", + }); + +const server = createServer(async (req, res) => { + const url = new URL(req.url, `http://localhost:${PORT}`); + + if (url.pathname === "/oauth/callback") { + const code = url.searchParams.get("code"); + const error = url.searchParams.get("error"); + + if (error) { + res.writeHead(400, { "Content-Type": "text/plain" }); + res.end(`Error: ${error}\n${url.searchParams.get("error_description")}`); + return; + } + + if (!code) { + res.writeHead(400, { "Content-Type": "text/plain" }); + res.end("No authorization code received"); + return; + } + + console.log(`\nReceived authorization code: ${code}`); + console.log("Exchanging for token...\n"); + + // Exchange code for token + const tokenRes = await fetch(`${API_URL}/o/token/`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: REDIRECT_URI, + client_id: CLIENT_ID, + code_verifier: codeVerifier, + }), + }); + + const tokenData = await tokenRes.json(); + console.log("Token response:", JSON.stringify(tokenData, null, 2)); + + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(`
${JSON.stringify(tokenData, null, 2)}`);
+
+ // Done - shut down
+ setTimeout(() => {
+ console.log("\nDone. Shutting down.");
+ process.exit(0);
+ }, 1000);
+ } else {
+ res.writeHead(302, { Location: authorizeUrl });
+ res.end();
+ }
+});
+
+server.listen(PORT, () => {
+ console.log(`OAuth test server running on http://localhost:${PORT}`);
+ console.log(`\nOpen http://localhost:${PORT} in your browser to start the flow.\n`);
+});
diff --git a/api/poetry.lock b/api/poetry.lock
index 191c8bef65d1..b4ee146b2494 100644
--- a/api/poetry.lock
+++ b/api/poetry.lock
@@ -967,7 +967,7 @@ description = "Cross-platform colored terminal text."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
groups = ["dev"]
-markers = "platform_system == \"Windows\" or sys_platform == \"win32\""
+markers = "sys_platform == \"win32\" or platform_system == \"Windows\""
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
@@ -1109,61 +1109,61 @@ toml = ["tomli ; python_full_version <= \"3.11.0a6\""]
[[package]]
name = "cryptography"
-version = "46.0.6"
+version = "46.0.5"
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
optional = false
python-versions = "!=3.9.0,!=3.9.1,>=3.8"
groups = ["main", "dev", "licensing", "saml"]
files = [
- {file = "cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8"},
- {file = "cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30"},
- {file = "cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a"},
- {file = "cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175"},
- {file = "cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463"},
- {file = "cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97"},
- {file = "cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c"},
- {file = "cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507"},
- {file = "cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19"},
- {file = "cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738"},
- {file = "cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c"},
- {file = "cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f"},
- {file = "cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2"},
- {file = "cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124"},
- {file = "cryptography-46.0.6-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275"},
- {file = "cryptography-46.0.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4"},
- {file = "cryptography-46.0.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b"},
- {file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707"},
- {file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361"},
- {file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b"},
- {file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca"},
- {file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013"},
- {file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4"},
- {file = "cryptography-46.0.6-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a"},
- {file = "cryptography-46.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d"},
- {file = "cryptography-46.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736"},
- {file = "cryptography-46.0.6-cp314-cp314t-win32.whl", hash = "sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed"},
- {file = "cryptography-46.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4"},
- {file = "cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a"},
- {file = "cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8"},
- {file = "cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77"},
- {file = "cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290"},
- {file = "cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410"},
- {file = "cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d"},
- {file = "cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70"},
- {file = "cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d"},
- {file = "cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa"},
- {file = "cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58"},
- {file = "cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb"},
- {file = "cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72"},
- {file = "cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c"},
- {file = "cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f"},
- {file = "cryptography-46.0.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:2ea0f37e9a9cf0df2952893ad145fd9627d326a59daec9b0802480fa3bcd2ead"},
- {file = "cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:a3e84d5ec9ba01f8fd03802b2147ba77f0c8f2617b2aff254cedd551844209c8"},
- {file = "cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:12f0fa16cc247b13c43d56d7b35287ff1569b5b1f4c5e87e92cc4fcc00cd10c0"},
- {file = "cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:50575a76e2951fe7dbd1f56d181f8c5ceeeb075e9ff88e7ad997d2f42af06e7b"},
- {file = "cryptography-46.0.6-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:90e5f0a7b3be5f40c3a0a0eafb32c681d8d2c181fc2a1bdabe9b3f611d9f6b1a"},
- {file = "cryptography-46.0.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6728c49e3b2c180ef26f8e9f0a883a2c585638db64cf265b49c9ba10652d430e"},
- {file = "cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759"},
+ {file = "cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad"},
+ {file = "cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b"},
+ {file = "cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b"},
+ {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263"},
+ {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d"},
+ {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed"},
+ {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2"},
+ {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2"},
+ {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0"},
+ {file = "cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731"},
+ {file = "cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82"},
+ {file = "cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1"},
+ {file = "cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48"},
+ {file = "cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4"},
+ {file = "cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2"},
+ {file = "cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678"},
+ {file = "cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87"},
+ {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee"},
+ {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981"},
+ {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9"},
+ {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648"},
+ {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4"},
+ {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0"},
+ {file = "cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663"},
+ {file = "cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826"},
+ {file = "cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d"},
+ {file = "cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a"},
+ {file = "cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4"},
+ {file = "cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31"},
+ {file = "cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18"},
+ {file = "cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235"},
+ {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a"},
+ {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76"},
+ {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614"},
+ {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229"},
+ {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1"},
+ {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d"},
+ {file = "cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c"},
+ {file = "cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4"},
+ {file = "cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9"},
+ {file = "cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72"},
+ {file = "cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595"},
+ {file = "cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c"},
+ {file = "cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a"},
+ {file = "cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356"},
+ {file = "cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da"},
+ {file = "cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257"},
+ {file = "cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7"},
+ {file = "cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d"},
]
[package.dependencies]
@@ -1176,7 +1176,7 @@ nox = ["nox[uv] (>=2024.4.15)"]
pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.14)", "ruff (>=0.11.11)"]
sdist = ["build (>=1.0.0)"]
ssh = ["bcrypt (>=3.1.5)"]
-test = ["certifi (>=2024)", "cryptography-vectors (==46.0.6)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"]
+test = ["certifi (>=2024)", "cryptography-vectors (==46.0.5)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"]
test-randomorder = ["pytest-randomly"]
[[package]]
@@ -1504,6 +1504,27 @@ files = [
[package.dependencies]
django = ">=3.2"
+[[package]]
+name = "django-oauth-toolkit"
+version = "3.1.0"
+description = "OAuth2 Provider for Django"
+optional = false
+python-versions = ">=3.8"
+groups = ["main"]
+files = [
+ {file = "django_oauth_toolkit-3.1.0-py3-none-any.whl", hash = "sha256:10ddc90804297d913dfb958edd58d5fac541eb1ca912f47893ca1e482bb2a11f"},
+ {file = "django_oauth_toolkit-3.1.0.tar.gz", hash = "sha256:d5a59d07588cfefa8818e99d65040a252eb2ede22512483e2240c91d0b885c8e"},
+]
+
+[package.dependencies]
+django = ">=4.2"
+jwcrypto = ">=1.5.0"
+oauthlib = ">=3.2.2"
+requests = ">=2.13.0"
+
+[package.extras]
+dev = ["m2r", "pytest", "pytest-cov", "sphinx-rtd-theme"]
+
[[package]]
name = "django-ordered-model"
version = "3.4.3"
@@ -2734,6 +2755,22 @@ files = [
[package.dependencies]
referencing = ">=0.31.0"
+[[package]]
+name = "jwcrypto"
+version = "1.5.6"
+description = "Implementation of JOSE Web standards"
+optional = false
+python-versions = ">= 3.8"
+groups = ["main"]
+files = [
+ {file = "jwcrypto-1.5.6-py3-none-any.whl", hash = "sha256:150d2b0ebbdb8f40b77f543fb44ffd2baeff48788be71f67f03566692fd55789"},
+ {file = "jwcrypto-1.5.6.tar.gz", hash = "sha256:771a87762a0c081ae6166958a954f80848820b2ab066937dc8b8379d65b1b039"},
+]
+
+[package.dependencies]
+cryptography = ">=3.4"
+typing-extensions = ">=4.5.0"
+
[[package]]
name = "lazy-object-proxy"
version = "1.10.0"
@@ -4467,26 +4504,25 @@ files = [
[[package]]
name = "requests"
-version = "2.33.0"
+version = "2.32.4"
description = "Python HTTP for Humans."
optional = false
-python-versions = ">=3.10"
+python-versions = ">=3.8"
groups = ["main", "dev", "saml"]
files = [
- {file = "requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b"},
- {file = "requests-2.33.0.tar.gz", hash = "sha256:c7ebc5e8b0f21837386ad0e1c8fe8b829fa5f544d8df3b2253bff14ef29d7652"},
+ {file = "requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c"},
+ {file = "requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422"},
]
[package.dependencies]
-certifi = ">=2023.5.7"
+certifi = ">=2017.4.17"
charset_normalizer = ">=2,<4"
idna = ">=2.5,<4"
-urllib3 = ">=1.26,<3"
+urllib3 = ">=1.21.1,<3"
[package.extras]
socks = ["PySocks (>=1.5.6,!=1.5.7)"]
-test = ["PySocks (>=1.5.6,!=1.5.7)", "pytest (>=3)", "pytest-cov", "pytest-httpbin (==2.1.0)", "pytest-mock", "pytest-xdist"]
-use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"]
+use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
[[package]]
name = "requests-futures"
@@ -5671,4 +5707,4 @@ files = [
[metadata]
lock-version = "2.1"
python-versions = ">3.11,<3.14"
-content-hash = "16599cda4178234d12de9910adfba08c54802e6e4f43b2d499b33eeebdb555df"
+content-hash = "27858d63787b154e4dd5bf7d976cc324625cc66f25e5b298905ff00662819ba6"
diff --git a/api/pyproject.toml b/api/pyproject.toml
index 392d981bb40d..2752955e0edc 100644
--- a/api/pyproject.toml
+++ b/api/pyproject.toml
@@ -94,6 +94,10 @@ ignore_missing_imports = true
module = ["saml.*"]
ignore_missing_imports = true
+[[tool.mypy.overrides]]
+module = ["oauth2_provider.*"]
+ignore_missing_imports = true
+
[tool.django-stubs]
django_settings_module = "app.settings.local"
@@ -114,7 +118,7 @@ django-cors-headers = "~3.5.0"
djangorestframework = "~3.15.2"
gunicorn = "~23.0.0"
pyparsing = "~2.4.7"
-requests = "~2.33.0"
+requests = "~2.32.4"
six = "~1.16.0"
whitenoise = "~6.0.0"
dj-database-url = "~3.0.1"
@@ -174,6 +178,7 @@ djangorestframework-simplejwt = "^5.5.1"
structlog = "^24.4.0"
prometheus-client = "^0.21.1"
django_cockroachdb = "~4.2"
+django-oauth-toolkit = "^3.0.1"
[tool.poetry.group.auth-controller]
optional = true
diff --git a/api/tests/unit/oauth2_metadata/__init__.py b/api/tests/unit/oauth2_metadata/__init__.py
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/api/tests/unit/oauth2_metadata/test_authentication.py b/api/tests/unit/oauth2_metadata/test_authentication.py
new file mode 100644
index 000000000000..bf75d43b181f
--- /dev/null
+++ b/api/tests/unit/oauth2_metadata/test_authentication.py
@@ -0,0 +1,52 @@
+from unittest.mock import MagicMock
+
+import pytest
+from rest_framework.request import Request
+from rest_framework.test import APIRequestFactory
+
+from oauth2_metadata.authentication import OAuth2BearerTokenAuthentication
+
+
+@pytest.mark.parametrize(
+ "auth_header",
+ [
+ "",
+ "Token some-token",
+ "Basic dXNlcjpwYXNz",
+ "Api-Key master-api-key",
+ ],
+)
+def test_authenticate__non_bearer_header__returns_none(
+ auth_header: str,
+) -> None:
+ # Given
+ factory = APIRequestFactory()
+ request = Request(factory.get("/", HTTP_AUTHORIZATION=auth_header))
+ auth = OAuth2BearerTokenAuthentication()
+
+ # When
+ result = auth.authenticate(request)
+
+ # Then
+ assert result is None
+
+
+def test_authenticate__bearer_header__delegates_to_dot(
+ mocker: MagicMock,
+) -> None:
+ # Given
+ mock_user = MagicMock()
+ mocker.patch(
+ "oauth2_provider.contrib.rest_framework.OAuth2Authentication.authenticate",
+ return_value=(mock_user, "test-token"),
+ )
+ request = Request(
+ APIRequestFactory().get("/", HTTP_AUTHORIZATION="Bearer test-token")
+ )
+ auth = OAuth2BearerTokenAuthentication()
+
+ # When
+ result = auth.authenticate(request)
+
+ # Then
+ assert result == (mock_user, "test-token")
diff --git a/api/tests/unit/oauth2_metadata/test_tasks.py b/api/tests/unit/oauth2_metadata/test_tasks.py
new file mode 100644
index 000000000000..d5ea32e9bc0c
--- /dev/null
+++ b/api/tests/unit/oauth2_metadata/test_tasks.py
@@ -0,0 +1,16 @@
+from unittest.mock import MagicMock
+
+from oauth2_metadata.tasks import clear_expired_oauth2_tokens
+
+
+def test_clear_expired_oauth2_tokens__called__invokes_cleartokens_command(
+ mocker: MagicMock,
+) -> None:
+ # Given
+ mock_call_command = mocker.patch("oauth2_metadata.tasks.call_command")
+
+ # When
+ clear_expired_oauth2_tokens()
+
+ # Then
+ mock_call_command.assert_called_once_with("cleartokens")
diff --git a/api/tests/unit/oauth2_metadata/test_views.py b/api/tests/unit/oauth2_metadata/test_views.py
new file mode 100644
index 000000000000..5f371446251b
--- /dev/null
+++ b/api/tests/unit/oauth2_metadata/test_views.py
@@ -0,0 +1,110 @@
+import pytest
+from django.test import Client
+from django.urls import reverse
+from pytest_django.fixtures import SettingsWrapper
+from rest_framework import status
+
+METADATA_URL = "oauth-authorization-server-metadata"
+
+
+@pytest.fixture()
+def client() -> Client:
+ return Client()
+
+
+def test_metadata_endpoint__unauthenticated__returns_200_with_rfc8414_json(
+ client: Client,
+ settings: SettingsWrapper,
+) -> None:
+ # Given
+ settings.FLAGSMITH_API_URL = "https://api.flagsmith.com"
+ settings.FLAGSMITH_FRONTEND_URL = "https://app.flagsmith.com"
+
+ # When
+ response = client.get(reverse(METADATA_URL))
+
+ # Then
+ assert response.status_code == status.HTTP_200_OK
+ assert response["Content-Type"] == "application/json"
+
+ data = response.json()
+ assert data["issuer"] == "https://api.flagsmith.com"
+ assert (
+ data["authorization_endpoint"] == "https://app.flagsmith.com/oauth/authorize/"
+ )
+ assert data["token_endpoint"] == "https://api.flagsmith.com/o/token/"
+ assert data["registration_endpoint"] == "https://api.flagsmith.com/o/register/"
+ assert data["revocation_endpoint"] == "https://api.flagsmith.com/o/revoke_token/"
+ assert data["introspection_endpoint"] == "https://api.flagsmith.com/o/introspect/"
+ assert data["response_types_supported"] == ["code"]
+ assert data["grant_types_supported"] == ["authorization_code", "refresh_token"]
+ assert data["code_challenge_methods_supported"] == ["S256"]
+ assert "none" in data["token_endpoint_auth_methods_supported"]
+ assert data["introspection_endpoint_auth_methods_supported"] == ["none"]
+
+
+def test_metadata_endpoint__custom_urls__endpoints_derived_from_settings(
+ client: Client,
+ settings: SettingsWrapper,
+) -> None:
+ # Given
+ settings.FLAGSMITH_API_URL = "https://custom-api.example.com"
+ settings.FLAGSMITH_FRONTEND_URL = "https://custom-app.example.com"
+
+ # When
+ response = client.get(reverse(METADATA_URL))
+
+ # Then
+ data = response.json()
+ assert data["issuer"] == "https://custom-api.example.com"
+ assert data["authorization_endpoint"].startswith("https://custom-app.example.com/")
+ assert data["token_endpoint"].startswith("https://custom-api.example.com/")
+ assert data["registration_endpoint"].startswith("https://custom-api.example.com/")
+ assert data["revocation_endpoint"].startswith("https://custom-api.example.com/")
+ assert data["introspection_endpoint"].startswith("https://custom-api.example.com/")
+
+
+def test_metadata_endpoint__trailing_slash_in_url__no_double_slash(
+ client: Client,
+ settings: SettingsWrapper,
+) -> None:
+ # Given
+ settings.FLAGSMITH_API_URL = "https://api.flagsmith.com/"
+ settings.FLAGSMITH_FRONTEND_URL = "https://app.flagsmith.com/"
+
+ # When
+ response = client.get(reverse(METADATA_URL))
+
+ # Then
+ data = response.json()
+ assert "//" not in data["token_endpoint"].split("://")[1]
+ assert "//" not in data["authorization_endpoint"].split("://")[1]
+
+
+def test_metadata_endpoint__scopes__reflect_oauth2_provider_settings(
+ client: Client,
+ settings: SettingsWrapper,
+) -> None:
+ # Given
+ settings.OAUTH2_PROVIDER = {
+ **settings.OAUTH2_PROVIDER,
+ "SCOPES": {"mcp": "MCP access", "read": "Read access"},
+ }
+
+ # When
+ response = client.get(reverse(METADATA_URL))
+
+ # Then
+ data = response.json()
+ assert set(data["scopes_supported"]) == {"mcp", "read"}
+
+
+def test_metadata_endpoint__post_request__returns_405() -> None:
+ # Given
+ csrf_client = Client(enforce_csrf_checks=True)
+
+ # When
+ response = csrf_client.post(reverse(METADATA_URL))
+
+ # Then
+ assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED