Skip to main content

Auth and Users

See also: Data Model · Firestore and Security Rules · Pricing and Subscriptions · Page Builder · Mobile App · Chrome Extension

Firebase Auth for identity, a single Firestore document per user for data, client-side encryption for sensitive story answers, and Cloud Functions only where Security Rules can't reach (deletions needing re-auth).

Identity layer

PieceWhereWhat it does
AuthUserlibs/stretched-types/src/auth/auth.tsThe subset of the Firebase Auth user the app consumes (uid, email, displayName, photoURL, emailVerified, isAnonymous).
AuthServiceapps/stretched/src/app/core/services/auth.service.tsThin wrapper: currentUser$ (authState) + isAdmin(), which awaits authStateReady() before reading claims so a cold page load doesn't report false prematurely.
UserServicelibs/stretched-components/src/services/user.service.tsThe shared signal-based auth surface: currentUser / isLoggedIn / displayName / email / uid computeds, loginWithEmail / register / Google sign-in / signOut, plus user-doc preferences (loadPreferences, savePreferred* — Firestore resolved lazily via Injector so Storybook's Firestore-less hosts keep working).
UserLoginlibs/stretched-components/src/organisms/user/user-login/user-login.tssc-user-login — the sign-in/register organism used by /account, /partners, and the mobile login page. isGoogleHidden input exists because signInWithPopup can't run in a Capacitor WebView.

Google sign-in is a seam: UserService injects the GOOGLE_SIGN_IN_FN token (libs/stretched-components/src/tokens/google-sign-in-fn.token.ts); web uses the popup, mobile provides a native implementation — see Mobile App.

Storybook substitutes a mock auth layer (apps/storybook/.storybook/storybook-auth.ts) so login-dependent organisms render without Firebase.

Admin: the admin: true custom claim

Set out-of-band via the Admin SDK — there is no in-app path to grant it.

  • Client UX: adminGuard (apps/stretched/src/app/core/guards/admin.guard.ts) gates /admin/story-builder (see Page Builder); non-admins are redirected home. The guard is the UX layer, not the security boundary.
  • Server enforcement: firestore.rules requires the same claim for the stories collection and for admin reads of partners/* — see Firestore and Security Rules and Partner Program.

The tier custom claim (subscription level) rides the same token — see Pricing and Subscriptions.

ACL: claims are the facts, @stretched/types is the policy

The workspace-wide access-control model (2026-07-17), built so per-tier and per-role gating works identically in the web app, mobile app, chrome extension, Cloud Functions, and firestore.rules:

  • Facts on the account — auth-token custom claims: tier (one access level per tier = its rank), roles (e.g. admin), sparse grants overrides. Legacy admin: true is honored as the admin role. Claims have a 1,000-byte budget — carry roles, never expanded permission lists.
  • Policy in codelibs/stretched-types/src/acl/acl.ts: the PERMISSIONS registry (each key unlocked by min access level and/or role) and can(claims, key); claimsFromToken normalizes any decoded token.
  • CONTENT vs FUNCTION (2026-07-30) — the registry is split, and the split is spec-enforced. Content permissions (the tier ladder compare/imagine/…/adviser/legacy/gathering .access, plus worth-it.access, news.analysis, insights.spendable, stories.deep) are gated by minAccessLevel only — never roles, so an admin previewing a plan via /account's switcher sees exactly what that plan's members see. Function permissions (stories.manage, partners.review, feedback.feature-request) keep roles: ['admin']: staff need them on any previewed plan. Before this split every tier permission carried roles: ['admin'], so can() short-circuited on the role, the tier claim never mattered, and the secret Decades/Generations pages stayed visible on every tier. To unlock everything in a test world, grant the generations tier claim, not the admin role. Per-tier limits live next door in entitlements/entitlements.ts (TIER_ENTITLEMENTS, first consumer: the layaway item cap) with a drift spec pinning the firestore.rules mirror.
  • One writerapps/firebase-functions/src/shared/claims.ts applyAccountClaims(uid, updates): merges + normalizes claims and bumps users/{uid}.claimsVersion so clients watching their own doc know to force-refresh the ID token (claims otherwise lag up to ~1h). The Stripe webhook (pending) and admin tooling call this; nothing else may.
  • The extension reads claims without the SDK by decoding its stored ID token's JWT payload (storedAccountClaims in the account feature).
  • The web app reads them through AuthService.accountClaims() (claimsFromToken over a fresh ID token; isAdmin() rides it). First tier-gated UX consumer: the /stories paid gate via stories.deep — see Story Engine § the public surface. The /worth-it/:slug teasers use worth-it.access the same way (paid/admin sees "you're covered" instead of the upsell CTA).

The user document — users/{uid}

Owner-only read/write via the client SDK (allow read, write: if request.auth.uid == userId); delete is blocked in rules entirely (see below). Two shapes currently describe it — two rival answers models that have not been merged (a known data decision, flagged in libs/stretched-types/CLAUDE.md; don't add a third):

  1. User (libs/stretched-types/src/user/user.ts) — the "map" model: profile, consents, preferences, subscription (tier, billing, Stripe ids), and stories keyed storyKey → year → UserStoryEntry. Answers link to a Story only by a bare string key (story_REF), not a typed reference.
  2. UserDataPayload (libs/stretched-types/src/user/user-data.ts) — the flat-array model used by the settings UI: a discriminated union over encryption state:
type UserDataPayload =
| { encrypted: true; payload: string } // AES-GCM ciphertext of the whole UserData
| { encrypted: false; payload: UserData }; // { stories: StoryEntry[]; encryption?: EncryptionMeta }

Date fields are the in-app shape; Firestore round-trips them as Timestamp — convert at the boundary. See Data Model for the generated ERD.

Encryption model

Client-side, password-derived; the server never sees a usable key.

  • EncryptionMeta (stored top-level in the user doc when a password is set): salt (random base64, fed into PBKDF2) and check (AES-GCM ciphertext of the sentinel "stretched-verify-v1", so a password can be verified without storing it).
  • EncryptionService (libs/stretched-components/src/services/encryption.service.ts): PBKDF2-HMAC-SHA256 at 600,000 iterations (OWASP 2023 minimum) → 256-bit AES-GCM key with 96-bit IVs. setup() mints meta and unlocks; unlock() verifies against the sentinel; lock() drops the in-memory key; verify() checks a password without mutating state.
  • Individual entries carry their own flag (StoryEntry.encrypted / UserStoryEntry.isEncrypted) — data is a ciphertext string when encrypted, a plain record otherwise.
  • Mobile adds biometric unlock and an unlock route on top of this service — see Mobile App.

Deletion — the only Function-mediated path

Reads and writes go straight through the SDK + rules; deletes go through the users Cloud Function (apps/firebase-functions/src/users/users.ts) because rules cannot inspect token issue time, and destructive actions require re-authentication (token issued within 5 minutes, assertRecentAuth):

EndpointEffect
DELETE /users/dataDeletes only the Firestore document.
DELETE /users/accountDeletes the Firestore document then the Firebase Auth account — data first, so a failed data delete leaves the account intact for retry.

Contracts: UserApi in libs/stretched-types/src/user/user-data.ts. Correspondingly, firestore.rules has allow delete: if false on users/{userId} — the function's Admin SDK bypasses rules, making its re-auth check the only guard.

Emulator & tests

bun run emulate seeds test@test.com / testtest with the admin: true custom claim (re-asserted every start, merged into its other claims); the e2e suites run auth flows against a fresh emulator (bun run e2e:emulated) — see Local Development and Testing.

The Chrome extension's auth (REST, no SDK)

The Chrome Extension signs into the same Firebase Auth project, but over plain REST (identitytoolkit + securetoken endpoints) because it bundles no Firebase SDK: email/password sign-in, registration with optional display name, password-reset emails, and Google via chrome.identity.launchWebAuthFlow -> signInWithIdp. Sessions and refresh tokens live in chrome.storage.local; the resulting ID tokens hit Firestore REST for the layaways/{uid} document (see Firestore and Security Rules). Rules treat REST traffic identically to SDK traffic - no special-casing anywhere.