Auth interaction architecture
Why this repo's auth server stays headless and delegates login/consent/account-selection screens to `apps/web`, and why those calls go through a same-origin BFF proxy instead of hitting the auth server directly.
This page explains two choices the platform's auth flow is built around:
- The auth server is headless — it has no UI. All user-facing screens
(login, consent, account selection, post-login org pick) are rendered by
apps/weband reached via HTTP redirects. - The browser talks to the auth server through
apps/web's same-origin BFF proxy (/api/auth/*) instead of hittingauth.todayai.devdirectly from the page.
These are independent decisions and both are load-bearing. Knowing one without the other is enough to ship a broken auth change.
The interaction model
The auth server (OAuth2/OIDC) owns identity logic — session storage, token
issuance, social-provider integration. It owns no HTML. When a client
hits /oauth2/authorize and the server needs the user to do something
(log in, grant consent, pick an account), it redirects the browser to a
page in apps/web and waits to be called back.
Client (SPA / native)
│
│ GET /oauth2/authorize?client_id=…&redirect_uri=…
▼
Auth server
│
├─ user not logged in? → 302 to https://apps.web/login?continue=…
├─ consent required? → 302 to https://apps.web/oauth/consent?…
├─ multiple sessions? → 302 to https://apps.web/oauth/select-account?…
├─ post-login org pick? → 302 to https://apps.web/oauth/select-organization?…
└─ all interactions done? → 302 to client redirect_uri with codeThe web pages POST back to the auth server's API to actually perform the operation (verify credentials, record consent), then redirect into the next stage of the OAuth flow.
The auth server is configured with the URL of each interaction page, set
through an env var (e.g. OAUTH_UI_ORIGIN) so the same auth-server image
can serve today.ai and todayai.dev by config alone.
BFF proxy: why not call the auth server directly
The web app could in principle call auth.todayai.dev directly from the
browser. It doesn't. Every browser → auth-server call goes through a
catch-all route handler at
apps/web/src/app/api/auth/[...all]/route.ts
that proxies to ${AUTH_SERVER_URL}/api/auth/*. The proxy forwards request
headers (Cookie, Authorization, Content-Type), forwards response
headers (Set-Cookie, Location), and uses redirect: 'manual' so
3xx responses pass through unfollowed.
What we get from doing it this way:
- No CORS config. Every browser fetch is same-origin. The auth server
doesn't need
Access-Control-Allow-Originfor the web app — only for any external client that talks to it directly. - Client secrets stay server-side. Token exchange, introspection, and
revocation calls that need
OIDC_CLIENT_SECREThappen inside the BFF route, not in browser JS. - Auth server can live on an internal network. The BFF is the only public path; the auth-server origin can be reachable only via the internal load balancer if we want to tighten it.
- Cookies are scoped to the app origin. The session cookie the BFF
relays from the auth server is restamped to
Set-Cookieon the web app's origin (withDomainstripped in localhost dev mode — see Localhost-direct dev mode). - One auth pattern across native + web. Native clients open the same web pages in a system browser or embedded WebView; the BFF handles auth communication uniformly.
The cost is one extra network hop (under 10ms intra-region) and one catch-all route handler to maintain. We pay both.
The alternative would be cross-subdomain cookies — the browser calls
auth.todayai.dev directly and the auth server sets the session cookie
on .todayai.dev so both origins read it. This is what the OAuth 2.0
for Browser-Based Apps draft
explicitly warns against for production web apps. We don't use it.
Social-login callback — the BFF can't proxy this
OAuth social login (Google, GitHub, etc.) is the one path that bypasses
the BFF. The redirect_uri registered with Google has to point at the
auth server — that's what Google calls — not at our BFF.
Google
│ 302 → https://auth.todayai.dev/api/auth/callback/google?code=…
▼
Auth server
│ exchanges code with Google
│ creates/links user, writes session
│ Set-Cookie: session=…; Domain=.todayai.dev; Secure ← on auth origin
│ 302 → https://todayai.dev/<callbackURL>
▼
Browser arrives at apps/web carrying the .todayai.dev session cookieConsequences for the rest of the architecture:
- The browser must briefly visit
auth.todayai.devdirectly during social login. There's no way around it — Google's redirect URI is fixed. - The auth-server-domain session cookie set during the callback has to
be visible to
today.aiafter the redirect. In prod we get this by scoping the cookie to.today.ai(the parent of bothtoday.aiandauth.today.ai). The BFF proxy still owns subsequent API calls; the callback redirect is a one-time bypass. - Local dev needs the cookie-domain story to mirror prod or this flow breaks. The full dev-mode rationale is in Local development → Localhost-direct dev mode.
Production domain shape
today.ai apps/web (Next.js on Vercel)
auth.today.ai auth server (API only; no UI)
api.today.ai API gateway
Session cookie domain: .today.aitodayai.dev (dev and preview) follows the same
shape — see Three-tier domains
for the per-tier breakdown.
Related
- Better-auth quirks — the
client-side
oauthProviderClientplugin's request hook that injectsoauth_queryinto every non-GET body, and the flash-then-redirect race it causes - Auth cookie current state —
per-route map of which
apps/webcode paths assume the session cookie is present, plus the parallel surface intodayai-labs/today-admin - Local development — operational
setup including the
LOCALHOST_BFFcookie-domain strip
Vercel deploy model
How Vercel turns code into a running URL — build time vs deploy time vs runtime, what `vercel.json` controls, and the four archetypes of "Vercel project linked to a Git repo".
Auth cookie current state
Current-state map of cookie, domain, and auth relationships for `apps/web`. The admin app lives in `todayai-labs/today-admin`; its parallel surface is covered here for context. Cites code paths inline; update this page when the cited paths move.