● Zion Boggan
repos/Sightline/backend/app/api/routes/oidc.py
zionboggan.com ↗
111 lines · python
History for this file →
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}")