Sign-in, callback, and sign-out are implemented in the web app as an invite-only
email OTP flow plus Supabase SSR PKCE. Protected server APIs use the same
verified bearer-or-cookie boundary, and protected learner pages use the
server-side session gate. The generic packages/auth contracts still
exist, but they are not the wired web implementation.
| Contract / route | Source | Current purpose / status |
|---|---|---|
| Web auth route config | apps/web/src/lib/supabase/auth-route-config.ts |
Derives the exact trusted origin and callback URL for the web auth flow |
| Protected request auth | apps/web/src/lib/supabase/request-auth.ts |
Verifies bearer or SSR cookie sessions with Supabase Auth verification |
| Learner page session gate | apps/web/src/lib/supabase/learner-session.ts |
Requires a verified user, active profile, and active learner role before SSR rendering |
| Email OTP route | apps/web/src/server/auth/email-otp-route.ts |
Invite-only request with shouldCreateUser: false, safe return cookie, generic 202 |
| Callback route | apps/web/src/server/auth/callback-route.ts |
Browser GET /auth/callback PKCE exchange and safe redirect |
| Sign-out route | apps/web/src/server/auth/sign-out-route.ts |
Cookie-session-only local sign-out with empty JSON body |
| Catalog route | apps/web/src/app/api/v1/learning/catalog/route.ts |
Protected learner-catalog read path |
| Session identity route | apps/web/src/app/api/v1/auth/session/route.ts |
Returns only verified userId and server-derived sessionEpoch for browser offline replay |
| Session cookies | packages/contracts/src/auth/auth-session.ts |
Hardened web cookie attributes used by the auth/session helpers |
| Generic auth contracts | packages/auth/src/* |
Provider-agnostic PKCE, callback, nonce, and session helpers; contract-level only here |
| Auth API request envelopes | packages/api-client/src/auth/auth-api-requests.ts |
Email OTP and sign-out request builders only; no callback builder exists |
| Native Supabase foundation | apps/mobile/src/lib/supabase/*, apps/mobile/src/lib/secure-session/*, and apps/mobile/src/lib/api/* |
SecureStore persistence, installation-bound cleanup, PKCE shadow registry, native sign-in/callback UI, AppState refresh, session epochs, account-switch request cancellation, and a bearer-only catalog read client |
/sign-inreads a safe relativereturnTopath and keeps the form invite-only.POST /api/v1/auth/email-otpvalidatesemailandreturnTo, asks Supabase for an OTP withshouldCreateUser: false, stores the safe return cookie, and returns the generic accepted response without leaking account existence.- Supabase redirects back to
GET /auth/callback?code=...&sb_flow_id=.... - The callback parser rejects token-bearing query fields, consumes the stored return path, and exchanges the code through Supabase SSR.
- The callback route redirects with
303to the normalized return target or to/sign-in?reason=...when exchange or provider state fails. - Protected learner pages re-read the current profile and learner role after
auth.getUser(); frozen, pending-deletion, or role-revoked accounts fail closed. The catalog route uses the request-auth helper for bearer or cookie sessions and its RPC independently rechecks active account state. GET /api/v1/auth/sessionexposes a private, no-store replay identity; it never exposes access-token or raw session-ID material.POST /api/v1/auth/sign-outaccepts only a verified cookie session and an empty JSON body, clears local sync/media state, then callssignOut({ scope: 'local' }).
The native foundation follows a separate runtime boundary:
- The native client factory accepts only a Supabase HTTPS origin and a publishable/legacy anonymous key; loopback HTTP is allowed only for local development.
- Session material is persisted through the chunked Expo SecureStore adapter. The installation-bound storage layer clears retained credentials before the first read/write after a reinstall, and the PKCE shadow registry keeps track of active flow verifier slots.
- Native auth options set PKCE, disabled URL detection, persisted secure
storage, and explicit flow-ID redirects. The refresh controller starts and
stops the SDK refresh ticker from AppState, while the session store exposes
a user-bound
sessionEpochand initial anonymous hydration state for later native API guards. - The native sign-in screen uses direct PKCE OTP with
shouldCreateUser: false. It normalizes email, keeps ambiguous 400/422 responses non-enumerating, and stores a one-use state/nonce transaction in installation-bound SecureStore before requesting the link. - The callback screen accepts only an exact configured callback base plus an authorization code, Supabase PKCE flow ID, and unique state/nonce values. It rejects bearer tokens, wrong origins, duplicates, expired/replayed state, and exchanges the code with its exact flow ID.
- Root navigation hydrates a native session before exposing learner routes: anonymous users receive sign-in/callback routes, authenticated users receive the learner shell, and the AppState refresh lifecycle is disposed on teardown.
- The mobile learner catalog client validates its public API origin, sends the current access token as a bearer header with cookies omitted, and aborts in-flight reads when the session identity changes or the requesting view is superseded. The response is parsed by the shared catalog contract before Today or Lesson renders it.
APP_ORIGINmust match the exact web origin used by the app, and local dev is aligned tohttp://127.0.0.1:3000.- The Supabase redirect allowlist must include the callback origin and the
query-bearing callback path (
/auth/callback*) so PKCE flow IDs can round-trip safely. The localsupabase/config.tomlalready shows that shape; production config still needs separate verification. - Bearer tokens in callback payloads are rejected.
- Callback redirects use
Referrer-Policy: no-referrer; flow identifiers accept ASCII only and never become unvalidated cookie names. - Expired or replayed PKCE state is rejected.
- Return targets are limited to 256 raw characters and 768 encoded characters. The app stores either the legacy generic cookie or the flow-specific cookie, never both, and retains at most four pending flow targets.
- Protected request parsing is strict: bearer credentials must use the
Authorization: Bearer ...form, and the bearer client does not persist or refresh sessions. - Web session cookies are hardened by the shared cookie options before they are written back to the SSR store.
- 202 is used for the generic OTP acceptance envelope; 303 is used for the callback redirect; 200 is used for same-origin local sign-out.
- Web session cookies are server-only,
httpOnly,SameSite=lax, andsecurein production. - Local sign-out is not global revocation and does not delete user data.
- Browser queued mutations drain only after the session identity endpoint confirms both stored user and epoch. An unavailable endpoint is not treated as signed-out and cannot silently replay retained writes.
- OTP requests have a bounded in-process, hashed defense-in-depth limiter: 5 attempts per normalized email and, only when a trusted ingress is explicitly enabled, 30 attempts per verified proxy IP per 15 minutes. Supabase/provider controls remain authoritative; horizontally scaled production needs a distributed limiter before widening access.
TRUST_PROXY_IP_HEADERSdefaults tofalse. Enable it only when ingress strips client-supplied proxy headers and writes its own values.- Native production builds require
EXPO_PUBLIC_AUTH_CALLBACK_URLto be an exact claimed HTTPS universal/app link ending in/auth/callback; it may not contain credentials, a query, or a fragment. Theideogram-learning://auth/callbackscheme is development-only. - Native callback URLs never carry access, refresh, or ID tokens. The callback accepts only a one-use authorization code bound to the app-owned state/nonce transaction and the exact Supabase PKCE flow ID.
EXPO_PUBLIC_API_ORIGINcontains only the public API origin: no credentials, path, query, or fragment. It is HTTPS outside development; a loopback HTTP exception is deliberately limited to development builds.private.session_claim_matches(subject_id, candidate_session_id)only checks the current JWT claims against the subject. It does not prove session revocation state.
- Claimed production HTTPS Universal Link / App Link association and device validation for the selected domain
- Authoritative session revocation checks for sensitive server actions
- Any additional auth provider beyond the current invite-only email OTP flow
- An app-owned OIDC/nonce adapter if the generic
packages/authcontract path is wired later
- Final launch auth provider set
- Which production HTTPS domain and store-owned universal/app-link association will be approved for native callbacks
- Whether web sign-in remains email OTP only or later expands to social login