Today Platform Web — Dev Docs
Architecture

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:

  1. 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/web and reached via HTTP redirects.
  2. The browser talks to the auth server through apps/web's same-origin BFF proxy (/api/auth/*) instead of hitting auth.todayai.dev directly 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 code

The 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-Origin for 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_SECRET happen 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-Cookie on the web app's origin (with Domain stripped 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 cookie

Consequences for the rest of the architecture:

  • The browser must briefly visit auth.todayai.dev directly 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.ai after the redirect. In prod we get this by scoping the cookie to .today.ai (the parent of both today.ai and auth.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.ai

todayai.dev (dev and preview) follows the same shape — see Three-tier domains for the per-tier breakdown.

  • Better-auth quirks — the client-side oauthProviderClient plugin's request hook that injects oauth_query into every non-GET body, and the flash-then-redirect race it causes
  • Auth cookie current state — per-route map of which apps/web code paths assume the session cookie is present, plus the parallel surface in todayai-labs/today-admin
  • Local development — operational setup including the LOCALHOST_BFF cookie-domain strip

On this page