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