Skip to main content

Authentication Architecture

If you have ever shipped login yourself, you know the scary part is not the happy path—it is everything around it: sessions that survive deploys, OAuth redirects that do not leak open redirects, and checkout endpoints that bots love to hammer. This page maps how Express, Passport, express-session on Redis, GraphQL, and the React / Apollo portal fit together. Everything below is grounded in the repo; partial features are called out explicitly. RBAC (roles, scopes, middleware) is documented separately: RBAC.md.

1. Overview

High-level summary

  • Identity for GraphQL is established with a short-lived opaque access credential (user_session, 15 minutes) validated on every request. A paired refresh credential (user_refresh, ~30 days) rotates the pair. Both rows share a session_family_id.
  • Portal (browser) delivers access + refresh via httpOnly cookies auth_access / auth_refresh (apps/backend/src/utilities/authCookies.ts). Apollo uses credentials: 'include' and does not store session secrets in localStorage.
  • Opaque clients (mobile) send Authorization: Bearer <access> to GraphQL; refresh passes both tokens in the GraphQL mutation args. Bearer wins over cookie when both are present (buildBearerAuthMiddleware).
  • Platform MCP is a separate HTTP surface (POST /mcp). It authenticates with a dedicated audience=mcp Bearer from MCP OAuth — not a portal cookie and not GraphQL Bearer. See Platform MCP and §9.1.
  • GraphQL actor identity comes only from a valid access credential on req.user — never from Passport/express-session.
  • Password users verify email before sign-in; passwords are stored with bcrypt via Sequelize model hooks; sign-in uses User.validPassword (bcrypt).
  • Google OAuth is optional (env-gated); OAuth hits Express routes under /auth, then returns via a one-time httpOnly auth_exchange cookie (secret) plus a non-secret oauth_exchange=1 marker that tells the portal to call exchangeAuthTokens.
  • Multi-tenancy is modeled with organizations and OrganizationMember rows; User.current_membership selects which org context is active for scope checks (see RBAC.md).
  • CSRF for browser cookie auth: GraphQL mutations require an Origin/Referer in the allow-list. Missing origin is never allowed in production (apps/backend/src/middleware/csrfOrigin.ts).

Technologies and libraries

Key architectural decisions (as implemented)

  1. Access credential GraphQL auth — buildBearerAuthMiddleware attaches req.user from Bearer or auth_access cookie (Bearer preferred). Middleware chain (isLoggedIn, populateUserWithMemberships, hasScopes) uses that user; super-admin access is scope-based.
  2. Hybrid credential store — RDS is source of truth (auth_credentials.token_hash + session_family_id + metadata); Redis hot path via buildCacheHelper (${tokenHash}:auth_token:v2) with per-credential TTL. Redis miss → RDS rehydrate; revoke deletes both.
  3. Refresh rotation — refreshAccessToken requires a valid refresh and the paired access token (may be expired) from the same session_family_id. The refresh row is consumed with an atomic UPDATE … WHERE revoked_at IS NULL; only the winner mints a new pair. Credential lookup always re-checks Postgres revoked_at (Redis is not the revocation source of truth).
  4. Express session for non-identity cookies — Redis-backed express-session still supports OAuth helper cookies, GDPR consent, and analytics anonymous id. It is not used for GraphQL actor identity. Auth cookies use optional AUTH_COOKIE_DOMAIN (not SESSION_COOKIE_DOMAIN).
  5. Email verification gate — canSignInWithPassword rejects users that still have email_validation_token set (apps/backend/src/utilities/auth.ts).
  6. OAuth redirect UX — Post-auth browser redirect targets the SPA sign-in route with an optional validated internal redirect query param; the path is carried in a short-lived signed HTTP-only cookie (oauth_post_auth_redirect). Link mode uses GET /auth/google/prepare-link with Bearer or access cookie.

How we keep sign-in (and checkout) sane

Auth hardening is not only passwords and cookies—the same mindset extends to money mutations that sit behind GraphQL once a user is logged in. Think of the table below as “what stops the boring attacks from becoming incidents.” Those checkout limits share the same GraphQL boundary as normal session auth—no separate JWT gate on /graphql. For how billing uses sessions plus Stripe, continue to Billing architecture.

2. Authentication flow

2.1 Sign-up flow (password)

Entry: GraphQL mutation signUpWithPassword (apps/backend/src/gql/mutations/signUpWithPassword.ts). Notable implementation details:
  • createUser (apps/backend/src/utilities/user.ts) sets email_validated_at to null unless isAlreadyVerified; for password sign-up it calls resetEmailValidation, which sets email_validation_token (hex, 32 random bytes), 24h expiry (getEmailValidationExpiration), and queues verify email when enabled.
  • Verification link shape: buildVerifyEmailLink → {frontend}/verify/{token} with optional ?redirect= (apps/backend/src/utilities/redirect.ts).
  • If email already exists, resolver returns success: true (and may surface "Email verification required" when a validation token still exists) — intentional obfuscation of enumeration (signUpWithPassword).

2.2 Sign-in flow (password)

Entry: signInWithPassword (apps/backend/src/gql/mutations/signInWithPassword.ts). Portal browsers receive httpOnly cookies as the primary credential delivery. Opaque clients (no portal Origin, or X-Auth-Delivery: bearer) still get accessToken / refreshToken in the JSON body. Server access TTL is 15 minutes; both cookies use refresh-aligned maxAge (~30 days) so an expired access value remains present for refresh pairing. Core gate:

2.3 Sign-in flow (Google OAuth)

Entry: Browser navigates to /auth/google (proxied to backend in dev — apps/portal/vite.config.ts). Router: apps/backend/src/routers/auth/index.ts. Strategy: apps/backend/src/auth/strategies/google.ts. Callback URL configured on the strategy is ${tools.configuration.frontend.url}/auth/google/callback, which matches the SPA origin and Vite proxy rules.

2.4 Session management

Configuration (apps/backend/src/session.ts):
Passport serialization (apps/backend/src/auth/passport.ts):
GraphQL wiring (apps/backend/src/routers/index.ts) — CORS allows frontend + backend origins with credentials: true; /graphql runs buildBearerAuthMiddleware so req.user is populated from Authorization: Bearer (not from Passport session). Access expiry: access tokens use a 15-minute TTL; refresh (~30 days) rotates via refreshAccessToken with paired access (expired OK). Express session cookies (OAuth helpers / analytics) still use the Redis store maxAge (30 days) but are not GraphQL identity.

2.5 Sign-out flow

Entry: signOut mutation (apps/backend/src/gql/mutations/signOut.ts). Ordinary signOut is this device (session family). Signing out everywhere uses session governance / credentials_revision bumps, not this mutation.

3. Token & session architecture

Cookie vs server TTL: GraphQL identity still fails once the access credential expires (15m). The browser keeps auth_access until refresh maxAge so refreshAccessToken can present the expired value with auth_refresh. Optional AUTH_COOKIE_DOMAIN is separate from SESSION_COOKIE_DOMAIN. Auth cookies use path=/. JWT: There is no JWT issuance. Tokens are opaque random strings; only SHA-256 hashes are stored server-side.

4. Password handling

Hashing strategy

  • Algorithm: bcrypt (apps/backend/src/tools/rds/sequelize/models/user/index.ts).
  • Salt rounds: Number(process.env.SALT_ROUNDS) in beforeCreate / beforeUpdate when password changes.
  • Verification: User.validPassword uses bcrypt.compare.

Passport vs password login

apps/backend/src/auth/passport.ts registers Google OAuth only. Password authentication is GraphQL + bcrypt via canSignInWithPassword (apps/backend/src/utilities/auth.ts).

Password strength

validateUserPassword (apps/backend/src/utilities/user.ts) enforces:
  • 8–40 chars, at least one upper, lower, digit, and one of @.#$!%*?&.

Password reset flow

Not yet implemented — planned for a future release as an end-to-end user flow (no GraphQL mutation for “request password reset” / “confirm reset token” was found in apps/backend/src/gql). What exists today:
  • Mailer template type AvailableTemplates.PASSWORD_RESET and queue consumer (apps/backend/src/tools/mailer/emails/index.ts, apps/backend/src/tools/queue/consumers/mailer/passwordReset.ts).
  • Template builder apps/backend/src/tools/mailer/emails/passwordReset.ts.
These are infrastructure only until wired to resolvers and persistence for reset tokens.

Email verification / reset token behavior

  • validateEmail mutation calls validateEmailToken (apps/backend/src/gql/mutations/validateEmail.ts).
  • Expired validation tokens can trigger resend logic with cooldown (1 hour — TOKEN_RESEND_COOLDOWN_HOURS in apps/backend/src/utilities/user.ts).

5. Team invites

How invites are generated

  • inviteMember (apps/backend/src/gql/mutations/inviteMember.ts) uses createInvitation → resetInvitation (apps/backend/src/utilities/organization.ts).
  • Token: crypto.randomUUID() (generateInvitationToken).
  • Expiry: INVITATION_EXPIRY_DAYS = 7 → getInvitationExpiration.
  • Email link: {frontend}/invitation/{invitation_token} (queued in resetInvitation).

Accept flow (logged-in user)

Mutation: acceptInvitation (apps/backend/src/gql/mutations/acceptInvitation.ts). Middleware: isLoggedIn → populateUserWithMemberships. Steps:
  1. getOpenInvitationByToken requires invitation_token, matching user_id, invitation_expires_at > now, invitation_accepted_at null.
  2. clearInvitation sets token/expiry null and invitation_accepted_at to now.
  3. Updates user.current_membership to the accepted membership id.

Invite preview query (pre-login)

Query: invitation (apps/backend/src/gql/queries/invitation.ts). Resolver chain has no isLoggedIn middleware — callable without session; returns org name, role, expiry, optional vToken for email verification flows.

Completing profile for invited users

Mutation: completeInvitedProfile (apps/backend/src/gql/mutations/completeInvitedProfile.ts) — guarded by isLoggedOut (must not already have Bearer req.user). Sets name + password, validates email token + invitation, then mints access + refresh tokens for the portal.

When an invite expires

6. Relationship to RBAC

Authorization after authentication uses organization membership, active current_membership, and scopes resolved per subscription product. See RBAC.md for:
  • PublicRoles / Roles and User.is_super_admin
  • hasScopes, isAdmin, isAdminOrSuperAdmin, isSuperAdmin
  • AllScopes, Redis caching, ProductRoleScope
Invite-side RBAC: inviteMember requires hasScopes([AllScopes.MEMBERS_INVITE], ScopeMatchMode.EVERY); resetInvitation also checks MEMBERS_INVITE for the initiator’s membership (apps/backend/src/utilities/organization.ts).

7. Multi-tenancy

Isolation model

  • Organization is the tenant boundary (Organization model).
  • Membership links user_id + organization_id with role (OrganizationMember).
  • Active tenant for the session user is User.current_membership pointing at one OrganizationMember id.

How tenant context flows

  1. buildBearerAuthMiddleware loads User from the access token; GraphQL middlewares often reload with memberships included (populateUserWithMemberships, hasScopes, isAdmin, etc.).
  2. getCurrentMembership(user) picks the membership matching current_membership.
  3. switchOrganization (apps/backend/src/gql/mutations/switchOrganization.ts) validates the user belongs to the target org, updates current_membership, and reissues Bearer tokens so org snapshot metadata stays honest.

RBAC + multi-tenancy

  • Scopes are computed for organizationMember + active subscription’s product (getScopesForOrganizationMembership in apps/backend/src/utilities/rbac.ts).
  • Wrong or missing current_membership → getCurrentMembership returns null → middleware typically throws 403 or 500 depending on handler.

8. Middleware & guards

GraphQL middleware modules

Located in apps/backend/src/gql/middlewares/: Chaining uses resolveWithMiddlewares (apps/backend/src/gql/middlewares/index.ts), which invokes the first function in the array with the rest as a nested pipeline.

How to protect a GraphQL resolver

Pattern: resolveWithMiddlewares(_parent, params, context, [middlewares..., actualResolver]). Example (authenticated + memberships):
Example (scopes):

How to protect an Express route

GraphQL identity is access-credential based (Bearer or auth_access cookie). For non-GraphQL Express routes, current patterns:
  • OAuth routes — passport.authenticate (often { session: false }) plus token mint / exchange handoff (apps/backend/src/routers/auth/).
  • Webhooks — e.g. Stripe uses Stripe signature verification, not Bearer (apps/backend/src/routers/api/stripe.ts).
  • Access-protected helpers — e.g. GET /auth/google/prepare-link validates Bearer or access cookie before setting the link cookie.
To protect a new Express route with the same identity as GraphQL, reuse buildBearerAuthMiddleware (or call lookupAuthCredential / resolveAccessTokenFromRequest) rather than Passport session checks.

9. Extending the auth system

Add a new auth provider (OAuth / SSO)

  1. Add a Passport strategy module under apps/backend/src/auth/strategies/.
  2. Register it inside configurePassport (apps/backend/src/auth/passport.ts) with appropriate env guards (mirror GOOGLE_CLIENT_ID pattern).
  3. Mount routes under buildAuthRouter (apps/backend/src/routers/auth/index.ts) or a new router registered in buildRouters (apps/backend/src/routers/index.ts).
  4. Ensure callback URLs match how the SPA proxies to the backend (apps/portal/vite.config.ts for dev).
  5. Decide user provisioning rules (see googleStrategyCallback for create/link user + default org) (apps/backend/src/auth/strategies/google.ts).

Add a new role or permission

See RBAC.md — roles and scopes are enums + database mappings + cache.

Customize the invite flow

Touch points:
  • GraphQL: inviteMember, acceptInvitation, invitation, completeInvitedProfile.
  • Utilities: createInvitation, resetInvitation, clearInvitation, getInvitationExpiration (apps/backend/src/utilities/organization.ts).
  • Mail template: ORGANIZATION_INVITATION payload includes invitationLink (resetInvitation).

9.1 Credential types: human sessions vs later machine keys

v1 Platform MCP is a human super-admin OAuth session, not a machine PAT. Bearer → actor resolution stays one lookup path: hash → metadata → load user + membership → existing RBAC. MCP does not join GRAPHQL_BEARER_ACCEPTED_AUDIENCES. Do not put a portal token in mcp.json. Full control-plane layout (registry, scopes, audit): Platform MCP.

9.1.1 Platform MCP OAuth redirect policy (client integrations)

Platform MCP uses OAuth 2.1 + PKCE and Dynamic Client Registration (DCR) so clients like Cursor can mint audience=mcp credentials. Redirect URI policy is intentionally strict and owned per environment via top-level mcp config.

Decisions

Single source of truth

Each environment module (development.ts / staging.ts / production.ts / test.ts) sets:
Development and test include cursor by default. Staging/production start with an empty allowList until an operator adds entries there.

Enabling Cursor (non-dev)

  1. Add a cursor entry under mcp.allowList in the target env config module (exact callback URIs only).
  2. Restart backend (ensure MCP_ENABLED is not false).
  3. Point Cursor MCP at {APP_URL}/mcp with OAuth (never paste a portal Bearer).
  4. Complete authorize as a super admin. If you are signed out, authorize 302s to /signin?redirect=/auth/mcp/continue/:requestId; after sign-in the portal full-document-navigates to that path (Caddy → backend consent HTML). SPA navigate would 404 on the portal.
  5. After OAuth, Cursor uses Streamable HTTP JSON-RPC on POST /mcp (ALA-263). Authenticated tools/list includes mcp_whoami when the actor has mcp:whoami.
With Cursor absent from allowList, Cursor’s mixed DCR still succeeds for the loopback URI alone; other URIs appear in rejected_redirect_uris on the 201 response.

Adding another harness later

In the env config file, add another key under allowList with exact callback URIs (and optional label / future fields). Do not open host wildcards.

9.2 Session governance (super admin)

Super admins can inspect and revoke active Bearer credentials from /admin/super/sessions (portal) backed by GraphQL: Credential rows snapshot organization_id, membership_id, auth_method, issue IP/UA, session_family_id, and throttled last_seen_at / last_seen_ip. Org switch revokes the prior family and reissues a pair (switchOrganization also sets auth cookies). Active session rows expose isCurrent from the request’s access credential hash.

10. Common pitfalls

  1. httpOnly cookies — Portal JS cannot read auth_access / auth_refresh. Refresh uses cookie fallback (optional mutation args for opaque clients). Apollo must send credentials: 'include'.
  2. Anonymous gate probes — FetchMe on /signin / /signup (and other public auth routes) returns GraphQL 401 when logged out. Apollo’s error link still attempts refresh; on failure completeClientLogout clears client artifacts but must not set window.location.href while already on a public auth path (that reload looped forever).
  3. OAuth auth_exchange race — After Google OAuth, the callback stores a short-lived exchange payload, sets an httpOnly auth_exchange cookie, and redirects to /signin?oauth_exchange=1 without minting DB credentials or putting the secret in the URL. The portal must skip FetchMe and gate-page refresh until exchangeAuthTokens finishes minting + setting cookies; otherwise an in-flight refresh can rotate the brand-new pair (even with cookies flushed). If a different user is already signed in, the mutation burns the code and does not swap accounts.
  4. CSRF — Cookie-authenticated mutations need a valid Origin/Referer; production never allows missing origin.
  5. Email verification state — Sign-in checks email_validation_token truthiness, not only email_validated_at (canSignInWithPassword). Keep those fields consistent when seeding users.
  6. invitation query typing — Resolver uses ContextWithUser in its signature but does not run isLoggedIn; it is effectively public for invite landing pages (apps/backend/src/gql/queries/invitation.ts).
  7. Cookie domains — Optional AUTH_COOKIE_DOMAIN for auth cookies and SESSION_COOKIE_DOMAIN for express-session; omit in local dev for host-only cookies.
  8. Google OAuth deserialize — Passport still serializes for OAuth adapter flows; GraphQL actor identity comes from access credential lookup, not deserializeUser.
  9. No password reset API — Do not point users at reset URLs until GraphQL + persistence exist (§4).
  10. Subscription required for scopes — After login, RBAC may still deny everything if the org has no active subscription (RBAC.md).

Quick reference: key files

What’s next?

  • RBAC — how roles and scopes gate feature access once a user is authenticated.
  • Billing — Stripe subscriptions, checkout, and how subscription state drives RBAC.
  • Architecture overview — the full system map and layer summary.