mirror of
https://github.com/eitchtee/WYGIWYH.git
synced 2026-07-05 04:21:43 +02:00
Add API tokens and OAuth2 client support for external integrations
- Personal API tokens (model, user-settings UI, admin, management command, DRF auth class) for non-interactive API access from automations like n8n. Raw token shown once; only a SHA-256 hash is stored; last_used_at writes are throttled. - OAuth2 authorization server via django-oauth-toolkit with authorization server metadata and optional, off-by-default Dynamic Client Registration (RFC 7591), so remote OAuth/MCP clients can authenticate and self-register. - Tests for token auth, DCR gating and the management commands, plus .env.example and README documentation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from secrets import token_urlsafe
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.http import JsonResponse
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from oauth2_provider.models import get_application_model
|
||||
|
||||
|
||||
Application = get_application_model()
|
||||
|
||||
SUPPORTED_TOKEN_ENDPOINT_AUTH_METHODS = {
|
||||
"none": Application.CLIENT_PUBLIC,
|
||||
"client_secret_basic": Application.CLIENT_CONFIDENTIAL,
|
||||
"client_secret_post": Application.CLIENT_CONFIDENTIAL,
|
||||
}
|
||||
SUPPORTED_GRANT_TYPES = {"authorization_code", "refresh_token"}
|
||||
SUPPORTED_RESPONSE_TYPES = {"code"}
|
||||
|
||||
|
||||
def _base_url(request):
|
||||
return settings.PUBLIC_BASE_URL or request.build_absolute_uri("/").rstrip("/")
|
||||
|
||||
|
||||
def _json_error(error, error_description, status=400):
|
||||
response = JsonResponse(
|
||||
{"error": error, "error_description": error_description},
|
||||
status=status,
|
||||
)
|
||||
response["Cache-Control"] = "no-store"
|
||||
response["Pragma"] = "no-cache"
|
||||
return response
|
||||
|
||||
|
||||
def _set_no_store_headers(response):
|
||||
response["Cache-Control"] = "no-store"
|
||||
response["Pragma"] = "no-cache"
|
||||
return response
|
||||
|
||||
|
||||
def _parse_json_request_body(request):
|
||||
try:
|
||||
payload = json.loads(request.body.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise ValueError("Request body must be valid JSON.") from exc
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("Request body must be a JSON object.")
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def _get_string_list(payload, field_name, *, required=False, default=None):
|
||||
value = payload.get(field_name, default)
|
||||
if value is None:
|
||||
if required:
|
||||
raise ValueError(f"'{field_name}' is required.")
|
||||
return None
|
||||
|
||||
if not isinstance(value, list) or not value:
|
||||
raise ValueError(f"'{field_name}' must be a non-empty array of strings.")
|
||||
|
||||
normalized = []
|
||||
for item in value:
|
||||
if not isinstance(item, str) or not item.strip():
|
||||
raise ValueError(f"'{field_name}' must contain only non-empty strings.")
|
||||
normalized.append(item.strip())
|
||||
return normalized
|
||||
|
||||
|
||||
def _get_supported_scopes():
|
||||
return set(settings.OAUTH2_PROVIDER.get("SCOPES", {}).keys())
|
||||
|
||||
|
||||
def _dcr_initial_access_token_ok(request):
|
||||
"""Validate the optional RFC 7591 initial access token, if one is configured."""
|
||||
expected = settings.OAUTH2_DCR_INITIAL_ACCESS_TOKEN
|
||||
if not expected:
|
||||
return True
|
||||
|
||||
header = request.META.get("HTTP_AUTHORIZATION", "")
|
||||
scheme, _, value = header.partition(" ")
|
||||
if scheme.lower() != "bearer" or not value:
|
||||
return False
|
||||
return hmac.compare_digest(value, expected)
|
||||
|
||||
|
||||
@require_http_methods(["GET"])
|
||||
def authorization_server_metadata(request):
|
||||
base_url = _base_url(request)
|
||||
metadata = {
|
||||
"issuer": base_url,
|
||||
"authorization_endpoint": f"{base_url}/oauth/authorize/",
|
||||
"token_endpoint": f"{base_url}/oauth/token/",
|
||||
"revocation_endpoint": f"{base_url}/oauth/revoke_token/",
|
||||
"introspection_endpoint": f"{base_url}/oauth/introspect/",
|
||||
"scopes_supported": sorted(settings.OAUTH2_PROVIDER["SCOPES"].keys()),
|
||||
"response_types_supported": ["code"],
|
||||
"grant_types_supported": ["authorization_code", "refresh_token"],
|
||||
"token_endpoint_auth_methods_supported": [
|
||||
"none",
|
||||
"client_secret_basic",
|
||||
"client_secret_post",
|
||||
],
|
||||
"code_challenge_methods_supported": ["S256"],
|
||||
}
|
||||
# Only advertise registration when DCR is actually enabled.
|
||||
if settings.OAUTH2_DCR_ENABLED:
|
||||
metadata["registration_endpoint"] = f"{base_url}/oauth/register/"
|
||||
return JsonResponse(metadata)
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
@require_http_methods(["POST"])
|
||||
def dynamic_client_registration(request):
|
||||
if not settings.OAUTH2_DCR_ENABLED:
|
||||
return _json_error(
|
||||
"not_found",
|
||||
"Dynamic client registration is disabled.",
|
||||
status=404,
|
||||
)
|
||||
|
||||
if not _dcr_initial_access_token_ok(request):
|
||||
return _json_error(
|
||||
"invalid_token",
|
||||
"A valid initial access token is required to register a client.",
|
||||
status=401,
|
||||
)
|
||||
|
||||
try:
|
||||
payload = _parse_json_request_body(request)
|
||||
redirect_uris = _get_string_list(payload, "redirect_uris", required=True)
|
||||
grant_types = _get_string_list(
|
||||
payload,
|
||||
"grant_types",
|
||||
default=["authorization_code"],
|
||||
)
|
||||
response_types = _get_string_list(
|
||||
payload,
|
||||
"response_types",
|
||||
default=["code"],
|
||||
)
|
||||
except ValueError as exc:
|
||||
return _json_error("invalid_client_metadata", str(exc))
|
||||
|
||||
unsupported_grant_types = sorted(set(grant_types) - SUPPORTED_GRANT_TYPES)
|
||||
if unsupported_grant_types:
|
||||
return _json_error(
|
||||
"invalid_client_metadata",
|
||||
"Unsupported grant_types: " + ", ".join(unsupported_grant_types),
|
||||
)
|
||||
|
||||
if "authorization_code" not in grant_types:
|
||||
return _json_error(
|
||||
"invalid_client_metadata",
|
||||
"grant_types must include 'authorization_code'.",
|
||||
)
|
||||
|
||||
unsupported_response_types = sorted(set(response_types) - SUPPORTED_RESPONSE_TYPES)
|
||||
if unsupported_response_types:
|
||||
return _json_error(
|
||||
"invalid_client_metadata",
|
||||
"Unsupported response_types: "
|
||||
+ ", ".join(unsupported_response_types),
|
||||
)
|
||||
|
||||
if "code" not in response_types:
|
||||
return _json_error(
|
||||
"invalid_client_metadata",
|
||||
"response_types must include 'code'.",
|
||||
)
|
||||
|
||||
token_endpoint_auth_method = payload.get(
|
||||
"token_endpoint_auth_method",
|
||||
"client_secret_basic",
|
||||
)
|
||||
if token_endpoint_auth_method not in SUPPORTED_TOKEN_ENDPOINT_AUTH_METHODS:
|
||||
return _json_error(
|
||||
"invalid_client_metadata",
|
||||
"Unsupported token_endpoint_auth_method: "
|
||||
+ token_endpoint_auth_method,
|
||||
)
|
||||
|
||||
supported_scopes = _get_supported_scopes()
|
||||
raw_scope = payload.get("scope", "mcp")
|
||||
if not isinstance(raw_scope, str):
|
||||
return _json_error(
|
||||
"invalid_client_metadata",
|
||||
"'scope' must be a space-delimited string.",
|
||||
)
|
||||
requested_scope = raw_scope.strip() or "mcp"
|
||||
requested_scopes = set(requested_scope.split())
|
||||
unsupported_scopes = sorted(requested_scopes - supported_scopes)
|
||||
if unsupported_scopes:
|
||||
return _json_error(
|
||||
"invalid_client_metadata",
|
||||
"Unsupported scope values: " + ", ".join(unsupported_scopes),
|
||||
)
|
||||
|
||||
client_name = str(payload.get("client_name", "Dynamic MCP Client")).strip()
|
||||
if not client_name:
|
||||
client_name = "Dynamic MCP Client"
|
||||
|
||||
client_secret = None
|
||||
client_type = SUPPORTED_TOKEN_ENDPOINT_AUTH_METHODS[token_endpoint_auth_method]
|
||||
if client_type == Application.CLIENT_CONFIDENTIAL:
|
||||
client_secret = token_urlsafe(48)
|
||||
|
||||
application = Application(
|
||||
name=client_name,
|
||||
client_type=client_type,
|
||||
authorization_grant_type=Application.GRANT_AUTHORIZATION_CODE,
|
||||
redirect_uris=" ".join(redirect_uris),
|
||||
skip_authorization=False,
|
||||
hash_client_secret=True,
|
||||
client_secret=client_secret or "",
|
||||
)
|
||||
|
||||
try:
|
||||
application.full_clean()
|
||||
except ValidationError as exc:
|
||||
errors = []
|
||||
for field, messages in exc.message_dict.items():
|
||||
errors.extend(f"{field}: {message}" for message in messages)
|
||||
return _json_error(
|
||||
"invalid_client_metadata",
|
||||
"; ".join(errors),
|
||||
)
|
||||
|
||||
application.save()
|
||||
|
||||
response_payload = {
|
||||
"client_id": application.client_id,
|
||||
"client_id_issued_at": int(time.time()),
|
||||
"client_name": client_name,
|
||||
"redirect_uris": redirect_uris,
|
||||
# Report what was actually provisioned, not the raw request echo. The app
|
||||
# is created with the authorization_code grant; refresh_token is implicit
|
||||
# to that grant in django-oauth-toolkit rather than a separate capability.
|
||||
"grant_types": sorted(set(grant_types) & SUPPORTED_GRANT_TYPES),
|
||||
"response_types": sorted(set(response_types) & SUPPORTED_RESPONSE_TYPES),
|
||||
"scope": " ".join(sorted(requested_scopes)),
|
||||
"token_endpoint_auth_method": token_endpoint_auth_method,
|
||||
}
|
||||
if client_secret is not None:
|
||||
response_payload["client_secret"] = client_secret
|
||||
response_payload["client_secret_expires_at"] = 0
|
||||
|
||||
return _set_no_store_headers(JsonResponse(response_payload, status=201))
|
||||
Reference in New Issue
Block a user