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
| 1 | 1 | import secrets | |
| 2 | 2 | from urllib.parse import urlencode | |
| 3 | 3 | ||
| 4 | 4 | from fastapi import APIRouter, Cookie, HTTPException, Response | |
| 5 | 5 | from fastapi.responses import RedirectResponse | |
| 6 | 6 | ||
| 7 | 7 | from app.config import settings | |
| 8 | 8 | from app.auth.session import establish_librarian_session | |
| 9 | 9 | from app.auth.oidc import exchange_code, fetch_librarian_profile | |
| 10 | 10 | ||
| 11 | 11 | router = APIRouter(prefix="/auth", tags=["auth"]) | |
| 12 | 12 | STATE_COOKIE = "oauth_state" | |
| 13 | 13 | ||
| 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") |