Auth model
Purpose
Explain how VibeSwitch's authentication works as a system: who issues tokens, who verifies them, what's public, what's protected, and what changes between public and protected deployments. The conceptual view here is the counterpart to the step-by-step setup in Auth setup.
Prerequisites
- Required: Basic understanding of Bearer JWT authorization (an access token carried in the
Authorizationheader). - Useful: Familiarity with Firebase Auth / Google Identity Platform, though the concept works the same with any OIDC-style provider.
Inputs
- Server env:
AUTH_REQUIRED,FIREBASE_PROJECT_ID(and, in production, a runtime service account). - Client env (build-time):
VITE_FIREBASE_API_KEY,VITE_FIREBASE_AUTH_DOMAIN,VITE_FIREBASE_PROJECT_ID. - Requests: protected endpoints expect
Authorization: Bearer <idToken>from signed-in users.
Outputs
- Public mode (
AUTH_REQUIRED=false): API routes behave without auth. Anyone can call them. - Protected mode (
AUTH_REQUIRED=true): the server rejects unauthenticated calls to protected routes with401. Public bootstrap endpoints stay reachable. - UI behavior: the app calls
/api/auth/config, seesauthRequired: true/false, and renders a sign-in screen or skips it accordingly.
Constraints
- Server verification is via Firebase Admin SDK. The server reuses Google's Application Default Credentials (ADC) to verify ID tokens. Don't reimplement verification.
- The browser never sees server credentials. It holds only Firebase web config (API key, auth domain, project ID), which is intentionally public — identity, not authority.
- Build-time vs. runtime. Client config is baked into the JS bundle at build time. Server config is read at runtime. Rotations affect different things: changing
VITE_FIREBASE_*means rebuild + redeploy; changing server env means restart. - Tokens expire. The client SDK refreshes ID tokens before expiry automatically. The server accepts any valid non-expired token from the configured project.
- Authorization (who can do what) is out of scope here. This page is about authentication (is the caller signed in?). Role-based authorization is a separate concern; today, the system is effectively flat.
How a request actually flows
- Client boot: the SPA fetches
GET /api/auth/config. This endpoint is always public. - If
authRequired: true: the UI shows the Firebase sign-in screen. The user signs in with Google; Firebase issues a short-lived ID token held in local storage. - API calls: every protected
/api/*request includesAuthorization: Bearer <idToken>. The Fastify server usesfirebase-admin(with ADC) to verify the token's signature, issuer, audience, and expiry. - Token refresh: before expiry, the Firebase client SDK silently refreshes. The user doesn't re-authenticate unless they sign out.
- Sign-out: the UI clears the token and drops back to the sign-in screen. Server-side sessions aren't a thing — the server is stateless with respect to auth.
Examples
Detect whether auth is required
curl -sS http://localhost:3000/api/auth/config
Expected: {"authRequired":true} or {"authRequired":false}. This endpoint is deliberately public so the UI can bootstrap before the user has a token.
What's always public vs. protected
Always public (even when AUTH_REQUIRED=true):
/api/auth/config/api/openapi.json/api/swagger/api/docs/indexand/api/docs/page/:slugfor non-gated pages
Protected when AUTH_REQUIRED=true:
- Anything that mutates state (submissions, reports, analysis triggers).
/api/report/today,/api/chat, and most read endpoints that return ingested data.- Gated doc pages (those with
gated: truein their frontmatter — for example, internal playbooks).
Client-side code responsibilities
- Call
/api/auth/configfirst; render the sign-in screen if required. - Use the Firebase web SDK to handle sign-in and token lifecycle — don't try to manage tokens by hand.
- Attach
Authorization: Bearer <idToken>to every fetch. - On
401, re-check/api/auth/configand refresh the token if needed; if still401, prompt the user to sign in again.
Server-side responsibilities
- Verify tokens via
firebase-admin, using ADC in production orGOOGLE_APPLICATION_CREDENTIALSlocally. - Reject unverified/expired tokens with
401. - Never log token bodies or refresh tokens.
- Keep the public endpoints public — breaking bootstrap means the UI can't sign anyone in.
Troubleshooting
- Auth required but client shows a blank / "config error" screen
- Check: the client was built with all three
VITE_FIREBASE_*values. Look for Firebase init errors in the browser console. - Fix: set the values, rebuild
client/dist, redeploy. Don't try to inject them at runtime.
- Check: the client was built with all three
- Server rejects tokens as invalid
- Check: the server's
FIREBASE_PROJECT_IDmatches the client'sVITE_FIREBASE_PROJECT_ID. Check clock skew between server and Firebase (if the server clock is far off, tokens look expired). - Fix: align project IDs; ensure NTP is running on the server.
- Check: the server's
- Works locally but fails in production with
Could not load credentials- Check: in production, the workload needs a runtime service account (ADC). Don't ship a JSON key in the image.
- Fix: attach a service account on Cloud Run / GKE / wherever; drop
GOOGLE_APPLICATION_CREDENTIALSfrom prod env.
- Sign-in closes with "unauthorized domain"
- Check: the domain (including preview URLs) is listed in Firebase → Authentication → Settings → Authorized domains.
- Fix: add the domain and retry.
- Everyone shares one account because everyone uses one browser profile
- That's expected — the session is browser-scoped. If you need per-user attribution, sign each user into their own browser profile, and consider adding user metadata to the submissions they create.
See Auth setup for concrete wiring steps.