added quick connect auth from jellyfin, still needs to have some more cleaning before push to prod
This commit is contained in:
+189
@@ -0,0 +1,189 @@
|
||||
"""
|
||||
Auth API — generic endpoints for linking Discord users to external services.
|
||||
|
||||
GET /api/v1/auth/login?service=X&token=Y&discord_id=Z
|
||||
Validates the link token and serves a service-specific login form.
|
||||
|
||||
POST /api/v1/auth/login
|
||||
Accepts the form submission, validates credentials against the service,
|
||||
stores the session, and returns a result page.
|
||||
|
||||
GET /api/v1/auth/status?discord_id=Z
|
||||
Returns which services are linked for this Discord user.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Form, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from auth import get_auth_service, list_auth_services
|
||||
from core import auth_store
|
||||
|
||||
logger = logging.getLogger("api.auth")
|
||||
|
||||
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /auth/login — serve the login form
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/login")
|
||||
async def login_form(
|
||||
service: str,
|
||||
token: str,
|
||||
discord_id: int,
|
||||
):
|
||||
"""Validate the one-time link token and return a service-specific login form."""
|
||||
|
||||
# Validate the token WITHOUT consuming it (the POST will consume it)
|
||||
result = auth_store.validate_token(token)
|
||||
if result is None:
|
||||
raise HTTPException(status_code=400, detail="Invalid or expired link token.")
|
||||
|
||||
uid, svc = result
|
||||
if uid != discord_id or svc != service:
|
||||
raise HTTPException(status_code=400, detail="Token does not match the request.")
|
||||
|
||||
# Look up the AuthService
|
||||
svc_obj = get_auth_service(service)
|
||||
if svc_obj is None:
|
||||
raise HTTPException(status_code=404, detail=f"Unknown service: {service}")
|
||||
|
||||
logger.info("Serving login form: user=%s service=%s", discord_id, service)
|
||||
return HTMLResponse(svc_obj.render_login_form(token, discord_id))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /auth/login — handle form submission
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/login")
|
||||
async def login_submit(request: Request):
|
||||
"""Handle the login form POST: validate credentials, store auth, show result."""
|
||||
|
||||
# Parse form data
|
||||
form = await request.form()
|
||||
token = form.get("token", "")
|
||||
discord_id_str = form.get("discord_id", "")
|
||||
service = form.get("service", "")
|
||||
|
||||
if not token or not discord_id_str or not service:
|
||||
raise HTTPException(status_code=400, detail="Missing required fields.")
|
||||
|
||||
try:
|
||||
discord_id = int(discord_id_str)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(status_code=400, detail="Invalid discord_id.")
|
||||
|
||||
# Consume the token on POST (the GET only validated, didn't consume)
|
||||
result = auth_store.consume_token(token)
|
||||
if result is None:
|
||||
raise HTTPException(status_code=400, detail="Invalid or expired link token.")
|
||||
|
||||
# Look up the AuthService
|
||||
svc_obj = get_auth_service(service)
|
||||
if svc_obj is None:
|
||||
raise HTTPException(status_code=404, detail=f"Unknown service: {service}")
|
||||
|
||||
# Collect service-specific form fields (everything except token, discord_id, service)
|
||||
form_data: dict[str, str] = {}
|
||||
for key, value in form.items():
|
||||
if key not in ("token", "discord_id", "service"):
|
||||
form_data[key] = str(value)
|
||||
|
||||
# Authenticate against the service
|
||||
auth_result = await svc_obj.authenticate(form_data)
|
||||
|
||||
if not auth_result.success:
|
||||
return HTMLResponse(
|
||||
status_code=401,
|
||||
content=f"""<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8"><title>Login Failed</title>
|
||||
<style>
|
||||
body {{ font-family: system-ui, sans-serif; max-width: 420px; margin: 60px auto; padding: 0 20px; }}
|
||||
h2 {{ color: #d32f2f; }}
|
||||
a {{ color: #aa5cc3; }}
|
||||
</style></head><body>
|
||||
<h2>❌ Login Failed</h2>
|
||||
<p>{auth_result.error_message or "Authentication failed. Please try again."}</p>
|
||||
<p><a href="javascript:history.back()">← Go back and try again</a></p>
|
||||
</body></html>""",
|
||||
)
|
||||
|
||||
# Store the successful auth
|
||||
auth_store.store_auth(
|
||||
discord_user_id=discord_id,
|
||||
service=service,
|
||||
external_user_id=auth_result.external_user_id or "",
|
||||
external_name=auth_result.external_name or "",
|
||||
credentials=auth_result.credentials,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Auth linked: discord=%s → %s (%s)",
|
||||
discord_id,
|
||||
service,
|
||||
auth_result.external_name,
|
||||
)
|
||||
|
||||
return HTMLResponse(f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Account Linked</title>
|
||||
<style>
|
||||
body {{ font-family: system-ui, sans-serif; max-width: 420px; margin: 60px auto; padding: 0 20px; text-align: center; }}
|
||||
h1 {{ color: #388e3c; }}
|
||||
.name {{ font-weight: bold; color: #aa5cc3; }}
|
||||
p {{ color: #666; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>✅ Account Linked!</h1>
|
||||
<p>Logged in as <span class="name">{auth_result.external_name}</span> on <strong>{svc_obj.display_name}</strong>.</p>
|
||||
<p>You can close this page and return to Discord.</p>
|
||||
</body>
|
||||
</html>""")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /auth/status — check which services are linked
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get("/status")
|
||||
async def auth_status(discord_id: int):
|
||||
"""Return which services this Discord user has linked."""
|
||||
services: dict[str, bool] = {}
|
||||
for svc_name in list_auth_services():
|
||||
services[svc_name] = auth_store.is_authenticated(discord_id, svc_name)
|
||||
return {"discord_id": discord_id, "services": services}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /auth/reset — wipe auth store (DEV ONLY)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from core.config import get_config # noqa: E402
|
||||
|
||||
@router.post("/reset")
|
||||
async def reset_auth():
|
||||
"""
|
||||
Reset the entire auth store — clears all link tokens and user auth records.
|
||||
|
||||
Only enabled when ALLOW_AUTH_RESET=true in the environment.
|
||||
Returns 403 in production.
|
||||
"""
|
||||
if get_config("ALLOW_AUTH_RESET", "false").lower() != "true":
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Auth reset is disabled. Set ALLOW_AUTH_RESET=true to enable (dev only).",
|
||||
)
|
||||
|
||||
auth_store.reset_all()
|
||||
logger.warning("Auth store reset via API endpoint.")
|
||||
return {"status": "ok", "message": "Auth store cleared — all tokens and auth records removed."}
|
||||
Reference in New Issue
Block a user