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:
obervinov
2026-06-24 19:15:31 +04:00
parent 9641e169f2
commit 4273c541c5
23 changed files with 1505 additions and 6 deletions
@@ -0,0 +1,58 @@
from datetime import timedelta
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone
from apps.users.models import APIToken
class Command(BaseCommand):
help = "Creates a hashed API token for a WYGIWYH user and prints the raw token once."
def add_arguments(self, parser):
parser.add_argument("email", help="WYGIWYH user email that will own this token.")
parser.add_argument(
"--name",
default="n8n",
help="Human-readable token name. Defaults to 'n8n'.",
)
parser.add_argument(
"--expires-in-days",
type=int,
default=None,
help="Optional token lifetime in whole days.",
)
def handle(self, *args, **options):
email = options["email"].strip()
name = options["name"].strip()
expires_in_days = options["expires_in_days"]
if not email:
raise CommandError("Email is required.")
if not name:
raise CommandError("Token name cannot be empty.")
if expires_in_days is not None and expires_in_days <= 0:
raise CommandError("--expires-in-days must be greater than zero.")
user = get_user_model().objects.filter(email__iexact=email).first()
if user is None:
raise CommandError(f"No WYGIWYH user exists for '{email}'.")
expires_at = None
if expires_in_days is not None:
expires_at = timezone.now() + timedelta(days=expires_in_days)
token, raw_token = APIToken.objects.create_token(
user=user,
name=name,
expires_at=expires_at,
)
self.stdout.write(
self.style.SUCCESS(
f"Created API token '{token.name}' for {user.email} ({token.token_key})."
)
)
self.stdout.write(raw_token)
@@ -0,0 +1,129 @@
import os
from django.contrib.auth.hashers import check_password
from django.core.exceptions import ValidationError
from django.core.management.base import BaseCommand, CommandError
from oauth2_provider.models import get_application_model
Application = get_application_model()
def _get_env(name: str) -> str:
return os.getenv(name, "").strip()
def _get_bool_env(name: str, default: bool = False) -> bool:
raw = _get_env(name)
if not raw:
return default
return raw.lower() in {"1", "true", "yes", "on"}
class Command(BaseCommand):
help = (
"Creates or updates the OAuth application used by MCP clients when "
"MCP_OAUTH_CLIENT_* environment variables are configured."
)
def handle(self, *args, **options):
client_id = _get_env("MCP_OAUTH_CLIENT_ID")
client_secret = _get_env("MCP_OAUTH_CLIENT_SECRET")
redirect_uris = " ".join(_get_env("MCP_OAUTH_REDIRECT_URIS").split())
name = _get_env("MCP_OAUTH_CLIENT_NAME") or "WYGIWYH MCP"
skip_authorization = _get_bool_env("MCP_OAUTH_SKIP_AUTHORIZATION", default=False)
if not any([client_id, client_secret, redirect_uris]):
self.stdout.write(
self.style.NOTICE(
"MCP OAuth client env vars are not set. Skipping OAuth application setup."
)
)
return
missing = []
if not client_id:
missing.append("MCP_OAUTH_CLIENT_ID")
if not client_secret:
missing.append("MCP_OAUTH_CLIENT_SECRET")
if not redirect_uris:
missing.append("MCP_OAUTH_REDIRECT_URIS")
if missing:
raise CommandError(
"Missing required MCP OAuth settings: " + ", ".join(missing)
)
application, created = Application.objects.get_or_create(
client_id=client_id,
defaults={
"name": name,
"client_type": Application.CLIENT_CONFIDENTIAL,
"authorization_grant_type": Application.GRANT_AUTHORIZATION_CODE,
"redirect_uris": redirect_uris,
"skip_authorization": skip_authorization,
"client_secret": client_secret,
"hash_client_secret": True,
},
)
updated_fields = []
if application.name != name:
application.name = name
updated_fields.append("name")
if application.client_type != Application.CLIENT_CONFIDENTIAL:
application.client_type = Application.CLIENT_CONFIDENTIAL
updated_fields.append("client_type")
if (
application.authorization_grant_type
!= Application.GRANT_AUTHORIZATION_CODE
):
application.authorization_grant_type = Application.GRANT_AUTHORIZATION_CODE
updated_fields.append("authorization_grant_type")
if application.redirect_uris != redirect_uris:
application.redirect_uris = redirect_uris
updated_fields.append("redirect_uris")
if application.skip_authorization != skip_authorization:
application.skip_authorization = skip_authorization
updated_fields.append("skip_authorization")
if application.hash_client_secret is not True:
application.hash_client_secret = True
updated_fields.append("hash_client_secret")
if not application.client_secret or not check_password(
client_secret,
application.client_secret,
):
application.client_secret = client_secret
updated_fields.append("client_secret")
try:
application.full_clean()
except ValidationError as exc:
errors = "; ".join(
f"{field}: {', '.join(messages)}"
for field, messages in exc.message_dict.items()
)
raise CommandError(f"Invalid MCP OAuth application settings: {errors}") from exc
if created:
application.save()
self.stdout.write(
self.style.SUCCESS(
f"Created MCP OAuth application '{application.client_id}'."
)
)
return
if updated_fields:
application.save(update_fields=updated_fields)
self.stdout.write(
self.style.SUCCESS(
f"Updated MCP OAuth application '{application.client_id}'."
)
)
return
self.stdout.write(
self.style.SUCCESS(
f"MCP OAuth application '{application.client_id}' is already up to date."
)
)