""" 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 gateway.auth import get_auth_service, list_auth_services from src import auth_store logger = logging.getLogger("gateway.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""" Login Failed

❌ Login Failed

{auth_result.error_message or "Authentication failed. Please try again."}

← Go back and try again

""", ) # 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""" Account Linked

✅ Account Linked!

Logged in as {auth_result.external_name} on {svc_obj.display_name}.

You can close this page and return to Discord.

""") # --------------------------------------------------------------------------- # GET /auth/status — get all linked services for a Discord user # --------------------------------------------------------------------------- @router.get("/Discord/status") async def auth_status(discord_id: int): """ Return all services linked to this Discord user with full details. Response: { "discord_id": 123456789, "linked_services": { "jellyfin": { "external_user_id": "abc123", "external_name": "Tim", "linked_at": "2026-05-25T10:00:00", "credentials": { "token": "jwt...", "url": "http://jellyfin:8096", "user_id": "abc123" } } } } """ auths = auth_store.get_all_auths(discord_id) linked_services: dict[str, dict] = {} for auth in auths: svc_name = auth["service"] linked_services[svc_name] = { "external_user_id": auth["external_user_id"], "external_name": auth["external_name"], "linked_at": auth["linked_at"], "credentials": auth["credentials"], } return { "discord_id": discord_id, "linked_services": linked_services, } # --------------------------------------------------------------------------- # POST /auth/reset — wipe auth store (DEV ONLY) # --------------------------------------------------------------------------- from src.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."}