"""Single sign-on through an OpenID Connect provider. Three of these routes are unauthenticated, and have to be: they are how somebody who is *not* signed in gets signed in. They are the login surface, and each one is deliberately quiet about what it knows — see the individual docstrings. """ from __future__ import annotations from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi.responses import RedirectResponse from sqlmodel import Session from auth import require_admin, require_admin_session from database import get_session from models.oidc import OidcConfig, OidcConfigRead, OidcConfigWrite, OidcStatus from models.user import User from services import audit_service, crypto_service, oidc_service router = APIRouter(prefix="/api/auth/oidc", tags=["auth"]) def _ip(request: Request) -> str: return request.client.host if request.client else "unknown" def _suggested_redirect(request: Request) -> str: """The callback URL as this request makes it look. Correct behind a proxy only if it forwards the scheme — which is why the field is configurable and the UI shows this as a suggestion, not a promise. """ return str(request.base_url).rstrip("/") + "/api/auth/oidc/callback" def _effective_redirect(config: OidcConfig, request: Request) -> str: return (config.redirect_uri or "").strip() or _suggested_redirect(request) # --------------------------------------------------------------------------- # # The login surface (unauthenticated) # --------------------------------------------------------------------------- # @router.get("/status", response_model=OidcStatus) def status(session: Session = Depends(get_session)) -> OidcStatus: """Whether to show the button, and what to write on it. Public because the login screen asks before anybody has signed in. It says nothing about the provider — no issuer, no client id — so an unauthenticated caller learns only that SSO is on, which the button would tell them anyway. """ config = oidc_service.get_config(session) enabled = oidc_service.is_enabled(session) return OidcStatus( enabled=enabled, button_label=(config.button_label if config else "") or "Sign in with SSO", ) @router.get("/login") async def start_login(request: Request, session: Session = Depends(get_session)): """Redirect the browser to the provider.""" config = oidc_service.get_config(session) if not config or not oidc_service.is_enabled(session): raise HTTPException(status_code=404, detail="Single sign-on is not configured") state = oidc_service.begin(session, _effective_redirect(config, request)) try: url = await oidc_service.authorize_url(config, state) except oidc_service.OidcError as exc: return _fail(request, str(exc)) return RedirectResponse(url, status_code=302) @router.get("/callback") async def callback( request: Request, code: str | None = Query(default=None), state: str | None = Query(default=None), error: str | None = Query(default=None), error_description: str | None = Query(default=None), session: Session = Depends(get_session), ): """Where the provider sends the browser back. Ends by setting the same httpOnly refresh cookie a password login sets and redirecting to the app — which trades it for an access token on boot. No token is ever placed in a URL, where a proxy log or the browser's history would keep it. """ from routers.auth import _issue if error: return _fail(request, error_description or error) config = oidc_service.get_config(session) if not config or not oidc_service.is_enabled(session): raise HTTPException(status_code=404, detail="Single sign-on is not configured") # Consumed here, so a replayed callback finds nothing. stored = oidc_service.take_state(session, state or "") if not stored: return _fail(request, "This sign-in link has expired or was already used") if not code: return _fail(request, "The provider returned no authorization code") try: tokens = await oidc_service.exchange(config, code, stored) claims = await oidc_service.verify_id_token(config, tokens, stored) user = oidc_service.resolve_user(session, config, claims) except oidc_service.OidcError as exc: audit_service.record( session, user="oidc", action="auth.oidc-failed", target=(state or "")[:12], detail=str(exc)[:300], ip=_ip(request), ) return _fail(request, str(exc)) response = RedirectResponse("/", status_code=302) _issue(user, response, request) audit_service.record( session, user=user.username, action="auth.oidc-login", target=user.username, detail=f"role={user.role}", ip=_ip(request), ) return response def _fail(request: Request, message: str) -> RedirectResponse: """Back to the login screen with something readable in the URL. The message describes *our* end of the exchange — a bad nonce, an expired state, a provider error — and never anything the caller did not already send us. """ from urllib.parse import quote return RedirectResponse(f"/login?sso_error={quote(message[:300])}", status_code=302) # --------------------------------------------------------------------------- # # Configuration (admin) # --------------------------------------------------------------------------- # def _to_read(config: OidcConfig | None, request: Request) -> OidcConfigRead: config = config or OidcConfig() return OidcConfigRead( enabled=config.enabled, issuer=config.issuer, client_id=config.client_id, has_client_secret=bool(config.client_secret), scopes=config.scopes, button_label=config.button_label, username_claim=config.username_claim, auto_create=config.auto_create, default_role=config.default_role, admin_claim=config.admin_claim, admin_value=config.admin_value, redirect_uri=config.redirect_uri, suggested_redirect_uri=_suggested_redirect(request), ) @router.get("/config", response_model=OidcConfigRead) def read_config( request: Request, session: Session = Depends(get_session), _user: User = Depends(require_admin), ) -> OidcConfigRead: return _to_read(oidc_service.get_config(session), request) @router.put("/config", response_model=OidcConfigRead) def write_config( body: OidcConfigWrite, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin_session), ) -> OidcConfigRead: """Save the provider settings. Needs a real session rather than an API token, like every other route that changes how people authenticate: turning on SSO with a provider you control is otherwise a way to grant yourself a permanent second door. """ config = oidc_service.get_config(session) or OidcConfig(id=1) if body.enabled and not (body.issuer.strip() and body.client_id.strip()): raise HTTPException( status_code=400, detail="An issuer URL and a client id are required to enable single sign-on", ) if body.default_role not in ("admin", "user"): raise HTTPException(status_code=400, detail="Default role must be admin or user") config.enabled = body.enabled config.issuer = body.issuer.strip().rstrip("/") config.client_id = body.client_id.strip() if body.client_secret: config.client_secret = crypto_service.encrypt(body.client_secret) config.scopes = body.scopes.strip() or "openid profile email" config.button_label = body.button_label.strip() or "Sign in with SSO" config.username_claim = body.username_claim.strip() or "preferred_username" config.auto_create = body.auto_create config.default_role = body.default_role config.admin_claim = body.admin_claim.strip() config.admin_value = body.admin_value.strip() config.redirect_uri = body.redirect_uri.strip() config.updated_at = datetime.now(timezone.utc) session.add(config) session.commit() session.refresh(config) audit_service.record( session, user=user.username, action="settings.oidc", target=config.issuer or "-", detail=f"enabled={config.enabled}", ip=_ip(request), ) return _to_read(config, request) @router.post("/test") async def test_config( request: Request, session: Session = Depends(get_session), _user: User = Depends(require_admin), ) -> dict: """Fetch the provider's metadata and report what was found. Checks the half that can be checked without a browser: that the issuer is reachable, that it publishes the endpoints the flow needs, and that it publishes signing keys. """ config = oidc_service.get_config(session) if not config or not config.issuer: raise HTTPException(status_code=400, detail="Set an issuer URL first") try: document = await oidc_service.discover(config.issuer, force=True) keys = await oidc_service._signing_keys(document, force=True) except oidc_service.OidcError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc return { "ok": True, "issuer": document.get("issuer"), "authorization_endpoint": document.get("authorization_endpoint"), "token_endpoint": document.get("token_endpoint"), "signing_keys": len(keys.get("keys") or []), "scopes_supported": document.get("scopes_supported") or [], "redirect_uri": _effective_redirect(config, request), }