-
Notifications
You must be signed in to change notification settings - Fork 500
feat: setup-dot-and-metadata-endpoint #7057
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
fccaa3a
e190637
cf0c378
02d5fba
eeb1584
6780f71
e330174
ccd5ffd
262ebda
1f0d84e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| from oauth2_provider.contrib.rest_framework import ( | ||
| OAuth2Authentication, # type: ignore[import-untyped] | ||
|
Check failure on line 2 in api/oauth2_metadata/authentication.py
|
||
| ) | ||
| 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[object, str] | None: | ||
| auth_header = request.META.get("HTTP_AUTHORIZATION", "") | ||
| if not auth_header.startswith("Bearer "): | ||
| return None | ||
| return super().authenticate(request) # type: ignore[return-value] | ||
|
Check failure on line 17 in api/oauth2_metadata/authentication.py
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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") |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(`<pre>${JSON.stringify(tokenData, null, 2)}</pre>`); | ||
|
|
||
| // 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`); | ||
| }); |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Reminder