ADR-019: BFF cookie exchange for OAuth callback¶
Status: Accepted Date: 2026-05-28 Deciders: Development Team
TL;DR¶
The OAuth callback at GET /v1/auth/callback does not set session cookies directly. It writes a single-use AuthExchangeCode (60 s TTL) and redirects the browser to <FRONTEND_URL>/auth/complete#code=<token>. The SPA's /auth/complete page POSTs the code to POST /v1/session/exchange, which is the only endpoint that emits auth_token / refresh_token cookies for a real login. Same-origin POST → cookies always land.
Context¶
The previous flow set Set-Cookie on the OAuth-callback's 302 to FRONTEND_URL. Two problems showed up:
- Safari ITP can drop
Set-Cookieon cross-site redirect tails. The callback is served from the backend origin; the redirect target is the frontend origin. Safari's Intelligent Tracking Prevention may classify the redirect as a tracking pattern and silently discard the cookies. The user sees a successful login that 401s on every follow-up call — the exact failure mode #458 F2 (dev-modeCOOKIE_SECUREover HTTP) documented as a "looks like login succeeded but doesn't actually" footgun. - No defensible separation between OAuth machinery and the session resource.
/v1/auth/*housed both browser-driven OAuth endpoints and JSON-API session CRUD. The trust boundaries differed (browser redirect chain vs. SPA fetch) but the URL surface did not.
Decision¶
Split the trust boundaries by URL namespace and introduce a BFF cookie-exchange step.
GET /v1/auth/login→ kick off OAuth.GET /v1/auth/callback→ consume the OAuth code, upsert the user, audit the event, mint a single-useAuthExchangeCode, redirect to<FRONTEND_URL>/auth/complete#code=<token>. No cookies set here.POST /v1/session/exchange→ atomically consume the code, set cookies on the same-origin response, return{id, email}.GET /v1/session(whoami),POST /v1/session(refresh),DELETE /v1/session(logout) → the rest of the session resource.
The code is placed in the URL fragment (#code=...) not the query string. Fragments are not sent to the server in Referer headers and do not appear in access logs, so a third-party asset the SPA loads cannot leak the code.
The exchange endpoint is rate-limited to 10 requests/minute per client IP (in-process token bucket). A leaked code grants at most a 60 s brute-force window before expiry, and the bucket caps how many guesses can land in that window.
Alternatives Considered¶
Direct cookies on the callback (the previous design). ✓ One round-trip instead of two. ✗ Safari ITP drops cookies on the cross-site redirect tail. ✗ Couples OAuth flow concerns to the session resource.
Storage Access API. ✓ Browser-blessed escape hatch from ITP. ✗ Safari-specific behaviors, still maturing. ✗ Requires a user-gesture prompt in some configurations — bad UX for a silent post-login bounce.
Server-side session cookie (BFF storing JWT, issuing a session id). ✓ Eliminates JWT-in-cookie entirely. ✗ Bigger refactor than the trust-boundary problem warrants right now; ADR-012 (JWT) remains in force. Track separately.
Consequences¶
Positive:
- Works across browsers, including Safari ITP, without per-browser shims.
- Cookies emitted on a same-origin POST; no cross-site cookie reliance.
- Code in fragment → no leakage via logs or
Refererheaders. - URL surface (
/auth/*for the browser-driven IdP dance vs/session/*for the SPA-facing session resource) keeps the trust boundaries visible without forcing aredirect_urireconfiguration in Entra.
Negative:
- One extra round-trip on login (callback → exchange POST).
- Requires the
auth_exchange_codeDB table and an Alembic migration (no backfill — v0.x drops the DB between deploys). - Adds a +60 s attacker window if the code leaks between callback and exchange. Mitigated by: short TTL, single-use atomic consumption, fragment placement, IP rate limit on exchange.
Operational:
- In-process rate limiter is per-pod. With multiple pods an attacker could 10× the rate cap; acceptable for pre-v1.x. A shared limiter is future work with F6 (logout denylist) in a later PR.
DELETE /v1/sessiondoes not invalidate any outstanding exchange codes; they self-clear at TTL. Cleanup of consumed/expired rows is not implemented in this PR (table is small; sweep with F6).
Implementation¶
# Callback (excerpt)
code = secrets.token_urlsafe(48)
db.add(AuthExchangeCode(
code=code,
user_id=user.id,
expires_at=_naive_utcnow() + timedelta(seconds=60),
))
await db.commit()
return RedirectResponse(
url=f"{settings.FRONTEND_URL}/auth/complete#code={code}",
status_code=status.HTTP_302_FOUND,
)
# Exchange (excerpt)
row = await _consume_exchange_code(db, body.code) # 401 on any failure
user = await UserService(db).get_by_id(row.user_id)
_set_auth_cookies(response, ...)
return {"id": user.id, "email": user.email}
References¶
- Safari ITP
- ADR-012 — JWT authentication strategy (the cookies emitted here)
- Plan
458-security-authentication-integration-hardening.md