Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
23 changes: 23 additions & 0 deletions api/app/settings/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -165,6 +166,7 @@
"softdelete",
"metadata",
"app_analytics",
"oauth2_metadata",
]

SILENCED_SYSTEM_CHECKS = ["axes.W002"]
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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="")
Expand Down
8 changes: 8 additions & 0 deletions api/app/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,19 @@
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

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:
Expand Down Expand Up @@ -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")),
Comment on lines +56 to +57
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reminder

]

if settings.DEBUG: # pragma: no cover
Expand Down
Empty file.
8 changes: 8 additions & 0 deletions api/oauth2_metadata/apps.py
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
17 changes: 17 additions & 0 deletions api/oauth2_metadata/authentication.py
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

View workflow job for this annotation

GitHub Actions / API Unit Tests (3.13)

Unused "type: ignore" comment

Check failure on line 2 in api/oauth2_metadata/authentication.py

View workflow job for this annotation

GitHub Actions / API Unit Tests (3.11)

Unused "type: ignore" comment

Check failure on line 2 in api/oauth2_metadata/authentication.py

View workflow job for this annotation

GitHub Actions / API Unit Tests (3.12)

Unused "type: ignore" comment
)
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

View workflow job for this annotation

GitHub Actions / API Unit Tests (3.13)

Returning Any from function declared to return "tuple[object, str] | None"

Check failure on line 17 in api/oauth2_metadata/authentication.py

View workflow job for this annotation

GitHub Actions / API Unit Tests (3.13)

Unused "type: ignore" comment

Check failure on line 17 in api/oauth2_metadata/authentication.py

View workflow job for this annotation

GitHub Actions / API Unit Tests (3.11)

Returning Any from function declared to return "tuple[object, str] | None"

Check failure on line 17 in api/oauth2_metadata/authentication.py

View workflow job for this annotation

GitHub Actions / API Unit Tests (3.11)

Unused "type: ignore" comment

Check failure on line 17 in api/oauth2_metadata/authentication.py

View workflow job for this annotation

GitHub Actions / API Unit Tests (3.12)

Returning Any from function declared to return "tuple[object, str] | None"

Check failure on line 17 in api/oauth2_metadata/authentication.py

View workflow job for this annotation

GitHub Actions / API Unit Tests (3.12)

Unused "type: ignore" comment
9 changes: 9 additions & 0 deletions api/oauth2_metadata/tasks.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")
37 changes: 37 additions & 0 deletions api/oauth2_metadata/views.py
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)
81 changes: 81 additions & 0 deletions api/oauth2_test_server.mjs
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`);
});
62 changes: 49 additions & 13 deletions api/poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions api/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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
Expand Down
Empty file.
Loading
Loading