| 1 | """Optional OIDC / SSO login. Disabled unless SIGHTLINE_OIDC_ENABLED=true and an issuer is |
| 2 | configured. Implements the standard authorization-code flow: |
| 3 | |
| 4 | /auth/oidc/login -> redirect to the IdP authorize endpoint (state signed with our key) |
| 5 | /auth/oidc/callback -> exchange code, read userinfo, upsert the user, mint a local JWT |
| 6 | |
| 7 | Local username/password JWT remains the primary auth path; this lets Sightline drop into an |
| 8 | existing identity provider (Okta, Auth0, Keycloak, Entra ID, Google) without code changes. |
| 9 | |
| 10 | NOTE: fully exercising this requires a reachable IdP, so it is config-gated and not part of the |
| 11 | automated demo. The discovery + token exchange use standard OIDC endpoints.""" |
| 12 | from __future__ import annotations |
| 13 | |
| 14 | from datetime import datetime, timedelta, timezone |
| 15 | |
| 16 | import httpx |
| 17 | from fastapi import APIRouter, Depends, HTTPException, Query |
| 18 | from fastapi.responses import RedirectResponse |
| 19 | from jose import jwt, JWTError |
| 20 | from sqlalchemy import select |
| 21 | from sqlalchemy.orm import Session |
| 22 | |
| 23 | from app.api.deps import get_db |
| 24 | from app.core.config import settings |
| 25 | from app.core.security import create_access_token, generate_api_key |
| 26 | from app.models import AuditLog, User |
| 27 | |
| 28 | router = APIRouter(prefix="/auth/oidc", tags=["auth"]) |
| 29 | |
| 30 | _STATE_TTL = 600 |
| 31 | |
| 32 | |
| 33 | def _require_enabled(): |
| 34 | if not settings.oidc_enabled or not settings.oidc_issuer: |
| 35 | raise HTTPException(501, "OIDC not enabled (set SIGHTLINE_OIDC_ENABLED and issuer)") |
| 36 | |
| 37 | |
| 38 | def _discover() -> dict: |
| 39 | url = settings.oidc_issuer.rstrip("/") + "/.well-known/openid-configuration" |
| 40 | r = httpx.get(url, timeout=10.0) |
| 41 | r.raise_for_status() |
| 42 | return r.json() |
| 43 | |
| 44 | |
| 45 | def _sign_state() -> str: |
| 46 | now = datetime.now(timezone.utc) |
| 47 | return jwt.encode({"k": "oidc_state", "iat": now, "exp": now + timedelta(seconds=_STATE_TTL)}, |
| 48 | settings.secret_key, algorithm=settings.jwt_algorithm) |
| 49 | |
| 50 | |
| 51 | def _verify_state(state: str) -> bool: |
| 52 | try: |
| 53 | payload = jwt.decode(state, settings.secret_key, algorithms=[settings.jwt_algorithm]) |
| 54 | return payload.get("k") == "oidc_state" |
| 55 | except JWTError: |
| 56 | return False |
| 57 | |
| 58 | |
| 59 | @router.get("/login") |
| 60 | def oidc_login(): |
| 61 | _require_enabled() |
| 62 | try: |
| 63 | conf = _discover() |
| 64 | except Exception as e: |
| 65 | raise HTTPException(502, f"OIDC discovery failed: {e}") |
| 66 | params = { |
| 67 | "response_type": "code", |
| 68 | "client_id": settings.oidc_client_id, |
| 69 | "redirect_uri": settings.oidc_redirect_uri, |
| 70 | "scope": "openid email profile", |
| 71 | "state": _sign_state(), |
| 72 | } |
| 73 | q = "&".join(f"{k}={httpx.QueryParams({k: v})[k]}" for k, v in params.items()) |
| 74 | return RedirectResponse(f"{conf['authorization_endpoint']}?{q}") |
| 75 | |
| 76 | |
| 77 | @router.get("/callback") |
| 78 | def oidc_callback(code: str = Query(...), state: str = Query(...), db: Session = Depends(get_db)): |
| 79 | _require_enabled() |
| 80 | if not _verify_state(state): |
| 81 | raise HTTPException(400, "invalid or expired state") |
| 82 | try: |
| 83 | conf = _discover() |
| 84 | token_resp = httpx.post(conf["token_endpoint"], data={ |
| 85 | "grant_type": "authorization_code", "code": code, |
| 86 | "redirect_uri": settings.oidc_redirect_uri, |
| 87 | "client_id": settings.oidc_client_id, |
| 88 | "client_secret": settings.oidc_client_secret, |
| 89 | }, timeout=10.0) |
| 90 | token_resp.raise_for_status() |
| 91 | access = token_resp.json()["access_token"] |
| 92 | userinfo = httpx.get(conf["userinfo_endpoint"], |
| 93 | headers={"Authorization": f"Bearer {access}"}, timeout=10.0).json() |
| 94 | except Exception as e: |
| 95 | raise HTTPException(502, f"OIDC token exchange failed: {e}") |
| 96 | |
| 97 | email = userinfo.get("email") |
| 98 | if not email: |
| 99 | raise HTTPException(400, "IdP did not return an email claim") |
| 100 | user = db.scalar(select(User).where(User.email == email)) |
| 101 | if user is None: |
| 102 | _, pw_hash = generate_api_key() |
| 103 | user = User(email=email, hashed_password=pw_hash, |
| 104 | full_name=userinfo.get("name"), role=settings.oidc_default_role) |
| 105 | db.add(user) |
| 106 | db.flush() |
| 107 | db.add(AuditLog(actor_user_id=user.id, action="user.login.oidc", |
| 108 | entity_type="user", entity_id=user.id, meta={"issuer": settings.oidc_issuer})) |
| 109 | db.commit() |
| 110 | token = create_access_token(user.id, user.role) |
| 111 | return RedirectResponse(f"http://localhost:3000/login?token={token}") |