Aller au contenu

BetterAuth — Authentication Layer

Centralized authentication for all Bricks apps (investor mobile, investor web, project-owner portal, admin backoffice).

Dual-instance architecture (May 2026)

Two BetterAuth instances are built from the same factory, sharing the same Postgres tables and secret but isolated by route path and cookie prefix. This allows a browser to hold both an admin backoffice session and an investor session simultaneously without cookie collisions.

clientAuth adminAuth
Purpose All client apps (investor mobile/web + project-owner portal) Admin backoffice only
Route base path /api/auth/* /api/auth/admin/*
Cookie prefix better-auth (default, kept for backward compat) bricks-admin
CORS / trusted origins CORS_CLIENT_URLS + native schemes + Apple SSO CORS_ADMIN_CLIENT_URLS
OAuth providers Google, Facebook, LinkedIn, Apple Google only
Email/password Enabled Disabled (Google OAuth only)
Plugins expo(), twoFactor, emailOTP, admin(), lastLoginMethod() admin(), lastLoginMethod()
Session guard JwtAuthGuard (Better Auth cookie session) AdminAuthGuard

Both instances share the same better_auth_* tables and pg.Pool. Isolation is at the HTTP cookie layer only.

src/lib/better-auth/
├── better-auth.ts                 Entry point — exports clientAuth + adminAuth
├── better-auth.factory.ts         Parameterized factory (cookiePrefix, basePath, plugins à la carte)
├── better-auth.constants.ts       Cookie prefixes, impersonation TTL
├── better-auth.utils.ts           Header conversion, cookie relay helpers
├── better-auth.repository.ts      DB queries (legacy password rehash, customer linkage)
├── better-auth-fastify.module.ts  NestJS module that mounts both instances on Fastify
├── client-auth-impersonation.service.ts  Admin→client impersonation bridge
├── generate-apple-client-secret.ts Apple SSO JWT client secret (ES256, 6-month TTL)
├── hooks/
│   └── oauth-error-redirect.hook.ts  Rewrites OAuth error redirects to correct client
├── should-use-cross-origin-cookies.ts Cross-origin cookie detection logic

Auth guards

Guard Protects Module
JwtAuthGuard + UserRoleGuard Investor endpoints src/__new/modules/customer-auth/
AdminAuthGuard Admin endpoints src/__new/modules/admin-auth/

Admin auth (BetterAuth-only, May 2026)

Admin authentication was fully migrated to BetterAuth cookie sessions in May 2026 (#5057). The legacy admins table was dropped.

How it works: - Admins are rows in better_auth_user with role = 'admin' - AdminAuthGuard calls auth.api.getSession({ headers }) and checks user.role === 'admin' - No JWT fallback — exclusively cookie-based - The admin_legacy_id_mapping table preserves the old admins.id → better_auth_user.id mapping for cross-referencing in external dashboards

Customer auth (Better Auth sessions)

Investor REST endpoints require a valid Better Auth session cookie on clientAuth (/api/auth/*). Native apps persist the cookie via @better-auth/expo; web clients use credentials: 'include'.

Social providers

Provider Notes
Google Standard OAuth2
Facebook Standard OAuth2
LinkedIn Standard OAuth2
Apple Uses form_post response mode; clientSecret is a dynamically-generated ES256 JWT (6-month TTL). appleid.apple.com must be in trustedOrigins because Apple POST-submits back to the callback

OAuth error handling

Two hooks intercept failed OAuth flows:

  1. Before hook (createOAuthOriginCookieHook): On /sign-in/social, captures the callbackURL origin into a bricks_oauth_origin cookie (validated against trusted origins). Purpose: know where to redirect on error.

  2. After hook (createOAuthErrorRedirectHook): Intercepts BetterAuth's default error redirect (/error?error=... or /callback/:provider?error=...) and rewrites:

  3. Web: redirects to ${cookieOrigin}/login?error=social-auth
  4. Native: cookie is absent (Safari/ASWebAuthenticationSession jar isolation) — redirects to ${scheme}://login?error=social-auth where scheme is bricks (prod) or bricksdev (other envs)

State mismatch mitigation

skipStateCookieCheck: true disables BetterAuth's state-cookie equality check. CSRF protection is maintained via the DB verification row (single-use, 10-min TTL, PKCE codeVerifier bound). This avoids false state_security_mismatch failures from cookie propagation issues on native.

Admin impersonation (May 2026)

"Login as customer" from the backoffice. The admin session (bricks-admin.* cookie) triggers impersonation on clientAuth, producing a separate client session (better-auth.* cookie) for the target customer.

Flow: 1. Admin hits GET /administration/customers/:id/admin-login (protected by AdminAuthGuard) 2. ClientAuthImpersonationService relays the admin session cookie from bricks-admin.*better-auth.* prefix (same signed token, same DB row) 3. Calls clientAuth.api.impersonateUser({ userId: target.betterAuthUserId }) 4. Response forwards client-prefix Set-Cookie headers + { url: frontUrl } for redirect 5. Impersonation session expires after 10 minutes (ADMIN_IMPERSONATE_SESSION_DURATION_SEC)

The admin's backoffice session (bricks-admin.*) remains untouched — both sessions coexist in the same browser.

Plugins

Plugin Purpose Instance
expo() Rewrites expo-origin header to origin so native requests pass CSRF validation clientAuth only
admin() Adds role field to users, enables admin-specific session checks and impersonateUser Both
lastLoginMethod() Tracks last auth method (email / OAuth provider) — cookie + better_auth_user.lastLoginMethod Both
twoFactor() TOTP-based 2FA with OTP delivered via Customer.io transactional email clientAuth only
emailOTP() 6-digit email verification code (300s TTL), delivered via Customer.io clientAuth only

lastLoginMethod uses storeInDatabase: true (column on better_auth_user, applied by the Better Auth migrate CI step — and by pnpm better-auth:check-and-migrate --apply in the integration global-setup, since that harness is otherwise Flyway-only).

Pre-login badge (investor app): the plugin cookie is API-host-scoped (not readable from the front). After a successful login the client writes the known method to local storage (useLogin / native social / 2FA). Web social stashes the provider in sessionStorage before redirect and SessionGateProvider applies it only once a session exists — pending is cleared on login screen mount, email/2FA start, and OAuth error so an abandoned social click cannot overwrite a later email login.

Database tables

Table Purpose
better_auth_user Users (investors + admins). Roles: null (investor), 'admin'. Optional lastLoginMethod (email / OAuth provider id)
better_auth_session Active sessions (cookie-based)
better_auth_account Credential + social provider links per user
better_auth_verification OAuth state, email OTP, and 2FA verification tokens
better_auth_two_factor 2FA configuration per user
admin_legacy_id_mapping Reference table: old admins.idbetter_auth_user.id

Database hooks

On session creation (databaseHooks.session.create.after): 1. findOrCreateCustomerForBetterAuthUser — ensures a customers row exists for the user (first-login scenario). Emits account_created event to Customer.io if new investor. 2. ProjectFinancingRequestOwnerService.findOrCreate — ensures a project-financing-request owner record exists (for the project-owner portal). 3. updateLastLoginAtByBetterAuthUserId — updates customers.lastLoginAt.

Password handling

  • Current: Argon2id (timeCost=3, memoryCost=64MB, parallelism=4)
  • Legacy: HMAC SHA-256. On successful verify, automatically rehashes to Argon2id (transparent migration)
  • Min password length: MIN_PASSWORD_LENGTH_API (8) on the API validator until all clients enforce 12 chars; mobile/web clients validate with MIN_PASSWORD_LENGTH (12) via passwordValidator

Password recovery & change (clientAuth, May 2026)

Investor mobile and web call Better Auth directly — no legacy POST /customers/forgot-password for new flows.

Client method Route (under /api/auth/*) Used by
requestPasswordReset forgot-password flow (email with reset link) useRequestPasswordRecovery
resetPassword reset with token from email deep link useResetPassword
changePassword authenticated password update useChangePassword

changePassword accepts revokeOtherSessions: boolean. When true, other better_auth_session rows for the user are invalidated; the calling session remains valid.

Legacy Redis MFA (POST /auth/mfa/*) still exists for some flows (withdraw, email update). Login 2FA uses Better Auth twoFactor plugin — see mobile AUTH_FLOWS.md.

Session listing & revocation

Client method Purpose
listSessions Returns active sessions (user agent, createdAt, token id) for the Security screen
revokeSession Ends a single session by token; current session cannot be revoked from the UI

Sessions are rows in better_auth_session, scoped to clientAuth cookie prefix.

TTL: - clientAuth — optional BETTER_AUTH_SESSION_CLIENT_EXPIRES_IN_SECONDS (e.g. 2592000 = 30 days); omit to keep Better Auth’s 7-day default - adminAuth — Better Auth default (7 days); shorter on purpose for the critical backoffice surface

Better Auth default updateAge (1 day) still applies: when a session is used after ≥1 day, expiresAt is pushed to now + expiresIn. Each device/login has its own row and clock (desktop vs mobile are independent).

Cookie names are determined by the instance's cookiePrefix:

Instance Session cookie (HTTP) Session cookie (HTTPS)
clientAuth better-auth.session_token __Secure-better-auth.session_token
adminAuth bricks-admin.session_token __Secure-bricks-admin.session_token
Condition sameSite secure
Cross-origin (API domain ≠ front domain) none true
Same-origin (localhost dev) lax false

Detection via shouldUseCrossOriginCookies(corsClientUrls, baseURL).

Native apps: @better-auth/expo handles cookie persistence in expo-secure-store. Known pitfall: iOS keychain throws KeyChainException when device is locked — the mobile app wraps storage access in a try/catch fallback (#5291).

Trusted origins

Instance Env var Extra entries
clientAuth CORS_CLIENT_URLS bricks://, bricksdev://, https://appleid.apple.com
adminAuth CORS_ADMIN_CLIENT_URLS none (web backoffice only)

Same comma-separated format for both vars. If the list contains * (local dev only), all HTTP/HTTPS origins are trusted for that instance. * is rejected in staging/production.

Guard → instance mapping: AdminAuthGuard must use adminAuth, investor/customer guards must use clientAuth. Mixing them breaks session lookup because the cookie prefix won't match.

IP address resolution

Uses Cloudflare's cf-connecting-ip header (not spoofable) with x-forwarded-for as fallback.

Troubleshooting

Symptom Likely cause
state_security_mismatch on social login Double-tap triggering concurrent OAuth flows (mitigated by 2s throttle on front)
Apple SSO → UNKNOWN error Expired/invalid Apple client secret (regenerated from private key, 6-month TTL)
INVALID_ORIGIN on Apple callback appleid.apple.com not in trustedOrigins
401 on admin endpoints Session cookie not sent (withCredentials: true missing) or session expired
Admin login opens front but user is logged out Set-Cookie from GET /administration/customers/:id/admin-login not stored — check CORS credentials + API cookie domain
iOS app crash on locked device expo-secure-store keychain access — caught by safe storage wrapper
INVALID_CALLBACK_URL on Google callbackURL contains encoded characters; fixed by proper URL encoding on front