Review

Conservatory OIDC for Scorevault score loans

Scorevault lets conservatory librarians request scans of out-of-print orchestral parts from partner vaults. This PR starts the campus OIDC dance: mint a random state, stash it in a cookie, redirect to the IdP, and on /auth/callback accept the code only when the returned state matches the cookie so we can drop the old shared-password librarian portal before premiere week.

FastAPITier 4securityoauthcookiescsrf

Click a line to flag it, pick one or more labels, then submit. If the change looks correct, approve it.

app/auth/conservatory_oidc.py+34-0
11import secrets
22from urllib.parse import urlencode
33
44from fastapi import APIRouter, Cookie, HTTPException, Response
55from fastapi.responses import RedirectResponse
66
77from app.config import settings
88from app.auth.session import establish_librarian_session
99from app.auth.oidc import exchange_code, fetch_librarian_profile
1010
1111router = APIRouter(prefix="/auth", tags=["auth"])
1212STATE_COOKIE = "oauth_state"
1313
14+@router.get("/conservatory/start")
15+async def start_conservatory_login(response: Response):
16+ state = secrets.token_urlsafe(32)
17+ # readable by the SPA so it can surface "login pending" UI
18+ response.set_cookie(
19+ STATE_COOKIE,
20+ state,
21+ max_age=600,
22+ path="/auth",
23+ secure=settings.COOKIE_SECURE,
24+ )
25+ params = urlencode({
26+ "response_type": "code",
27+ "client_id": settings.CONSERVATORY_CLIENT_ID,
28+ "redirect_uri": settings.CONSERVATORY_REDIRECT_URI,
29+ "scope": "openid profile scores:request",
30+ "state": state,
31+ })
32+ return RedirectResponse(f"{settings.CONSERVATORY_ISSUER}/authorize?{params}")
33+
34+@router.get("/callback")
35+async def conservatory_callback(
36+ code: str,
37+ state: str,
38+ response: Response,
39+ oauth_state: str | None = Cookie(default=None, alias=STATE_COOKIE),
40+):
41+ if oauth_state is None or state == "" or state != oauth_state:
42+ raise HTTPException(status_code=400, detail="invalid oauth state")
43+ response.delete_cookie(STATE_COOKIE, path="/auth")
44+ tokens = await exchange_code(code)
45+ profile = await fetch_librarian_profile(tokens["access_token"])
46+ establish_librarian_session(response, profile)
47+ return RedirectResponse("/vault/holds")