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 asession_family_id. - Portal (browser) delivers access + refresh via httpOnly cookies
auth_access/auth_refresh(apps/backend/src/utilities/authCookies.ts). Apollo usescredentials: 'include'and does not store session secrets inlocalStorage. - 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 dedicatedaudience=mcpBearer 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 httpOnlyauth_exchangecookie (secret) plus a non-secretoauth_exchange=1marker that tells the portal to callexchangeAuthTokens. - Multi-tenancy is modeled with organizations and
OrganizationMemberrows;User.current_membershipselects which org context is active for scope checks (see RBAC.md). - CSRF for browser cookie auth: GraphQL mutations require an
Origin/Refererin the allow-list. Missing origin is never allowed in production (apps/backend/src/middleware/csrfOrigin.ts).
Technologies and libraries
Key architectural decisions (as implemented)
- Access credential GraphQL auth —
buildBearerAuthMiddlewareattachesreq.userfrom Bearer orauth_accesscookie (Bearer preferred). Middleware chain (isLoggedIn,populateUserWithMemberships,hasScopes) uses that user; super-admin access is scope-based. - Hybrid credential store — RDS is source of truth (
auth_credentials.token_hash+session_family_id+ metadata); Redis hot path viabuildCacheHelper(${tokenHash}:auth_token:v2) with per-credential TTL. Redis miss → RDS rehydrate; revoke deletes both. - Refresh rotation —
refreshAccessTokenrequires a valid refresh and the paired access token (may be expired) from the samesession_family_id. The refresh row is consumed with an atomicUPDATE … WHERE revoked_at IS NULL; only the winner mints a new pair. Credential lookup always re-checks Postgresrevoked_at(Redis is not the revocation source of truth). - Express session for non-identity cookies — Redis-backed
express-sessionstill supports OAuth helper cookies, GDPR consent, and analytics anonymous id. It is not used for GraphQL actor identity. Auth cookies use optionalAUTH_COOKIE_DOMAIN(notSESSION_COOKIE_DOMAIN). - Email verification gate —
canSignInWithPasswordrejects users that still haveemail_validation_tokenset (apps/backend/src/utilities/auth.ts). - OAuth redirect UX — Post-auth browser redirect targets the SPA sign-in route with an optional validated internal
redirectquery param; the path is carried in a short-lived signed HTTP-only cookie (oauth_post_auth_redirect). Link mode usesGET /auth/google/prepare-linkwith 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 mutationsignUpWithPassword (apps/backend/src/gql/mutations/signUpWithPassword.ts).
Notable implementation details:
createUser(apps/backend/src/utilities/user.ts) setsemail_validated_atto null unlessisAlreadyVerified; for password sign-up it callsresetEmailValidation, which setsemail_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):
apps/backend/src/auth/passport.ts):
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)inbeforeCreate/beforeUpdatewhen password changes. - Verification:
User.validPasswordusesbcrypt.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 inapps/backend/src/gql).
What exists today:
- Mailer template type
AvailableTemplates.PASSWORD_RESETand 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.
Email verification / reset token behavior
validateEmailmutation callsvalidateEmailToken(apps/backend/src/gql/mutations/validateEmail.ts).- Expired validation tokens can trigger resend logic with cooldown (1 hour —
TOKEN_RESEND_COOLDOWN_HOURSinapps/backend/src/utilities/user.ts).
5. Team invites
How invites are generated
inviteMember(apps/backend/src/gql/mutations/inviteMember.ts) usescreateInvitation→resetInvitation(apps/backend/src/utilities/organization.ts).- Token:
crypto.randomUUID()(generateInvitationToken). - Expiry:
INVITATION_EXPIRY_DAYS = 7→getInvitationExpiration.
- Email link:
{frontend}/invitation/{invitation_token}(queued inresetInvitation).
Accept flow (logged-in user)
Mutation:acceptInvitation (apps/backend/src/gql/mutations/acceptInvitation.ts).
Middleware: isLoggedIn → populateUserWithMemberships.
Steps:
getOpenInvitationByTokenrequiresinvitation_token, matchinguser_id,invitation_expires_at > now,invitation_accepted_atnull.clearInvitationsets token/expiry null andinvitation_accepted_atto now.- Updates
user.current_membershipto 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, activecurrent_membership, and scopes resolved per subscription product. See RBAC.md for:
PublicRoles/RolesandUser.is_super_adminhasScopes,isAdmin,isAdminOrSuperAdmin,isSuperAdminAllScopes, Redis caching,ProductRoleScope
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 (
Organizationmodel). - Membership links
user_id+organization_idwithrole(OrganizationMember). - Active tenant for the session user is
User.current_membershippointing at oneOrganizationMemberid.
How tenant context flows
buildBearerAuthMiddlewareloadsUserfrom the access token; GraphQL middlewares often reload withmembershipsincluded (populateUserWithMemberships,hasScopes,isAdmin, etc.).getCurrentMembership(user)picks the membership matchingcurrent_membership.switchOrganization(apps/backend/src/gql/mutations/switchOrganization.ts) validates the user belongs to the target org, updatescurrent_membership, and reissues Bearer tokens so org snapshot metadata stays honest.
RBAC + multi-tenancy
- Scopes are computed for
organizationMember+ active subscription’s product (getScopesForOrganizationMembershipinapps/backend/src/utilities/rbac.ts). - Wrong or missing
current_membership→getCurrentMembershipreturns null → middleware typically throws 403 or 500 depending on handler.
8. Middleware & guards
GraphQL middleware modules
Located inapps/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):
How to protect an Express route
GraphQL identity is access-credential based (Bearer orauth_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-linkvalidates Bearer or access cookie before setting the link cookie.
buildBearerAuthMiddleware (or call lookupAuthCredential / resolveAccessTokenFromRequest) rather than Passport session checks.
9. Extending the auth system
Add a new auth provider (OAuth / SSO)
- Add a Passport strategy module under
apps/backend/src/auth/strategies/. - Register it inside
configurePassport(apps/backend/src/auth/passport.ts) with appropriate env guards (mirrorGOOGLE_CLIENT_IDpattern). - Mount routes under
buildAuthRouter(apps/backend/src/routers/auth/index.ts) or a new router registered inbuildRouters(apps/backend/src/routers/index.ts). - Ensure callback URLs match how the SPA proxies to the backend (
apps/portal/vite.config.tsfor dev). - Decide user provisioning rules (see
googleStrategyCallbackfor 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_INVITATIONpayload includesinvitationLink(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 mintaudience=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:
cursor by default. Staging/production start with an empty allowList until an operator adds entries there.
Enabling Cursor (non-dev)
- Add a
cursorentry undermcp.allowListin the target env config module (exact callback URIs only). - Restart backend (ensure
MCP_ENABLEDis not false). - Point Cursor MCP at
{APP_URL}/mcpwith OAuth (never paste a portal Bearer). - 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). SPAnavigatewould 404 on the portal. - After OAuth, Cursor uses Streamable HTTP JSON-RPC on
POST /mcp(ALA-263). Authenticatedtools/listincludesmcp_whoamiwhen the actor hasmcp:whoami.
rejected_redirect_uris on the 201 response.
Adding another harness later
In the env config file, add another key underallowList 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
- httpOnly cookies — Portal JS cannot read
auth_access/auth_refresh. Refresh uses cookie fallback (optional mutation args for opaque clients). Apollo must sendcredentials: 'include'. - Anonymous gate probes —
FetchMeon/signin//signup(and other public auth routes) returns GraphQL 401 when logged out. Apollo’s error link still attempts refresh; on failurecompleteClientLogoutclears client artifacts but must not setwindow.location.hrefwhile already on a public auth path (that reload looped forever). - OAuth
auth_exchangerace — After Google OAuth, the callback stores a short-lived exchange payload, sets an httpOnlyauth_exchangecookie, and redirects to/signin?oauth_exchange=1without minting DB credentials or putting the secret in the URL. The portal must skipFetchMeand gate-page refresh untilexchangeAuthTokensfinishes 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. - CSRF — Cookie-authenticated mutations need a valid Origin/Referer; production never allows missing origin.
- Email verification state — Sign-in checks
email_validation_tokentruthiness, not onlyemail_validated_at(canSignInWithPassword). Keep those fields consistent when seeding users. invitationquery typing — Resolver usesContextWithUserin its signature but does not runisLoggedIn; it is effectively public for invite landing pages (apps/backend/src/gql/queries/invitation.ts).- Cookie domains — Optional
AUTH_COOKIE_DOMAINfor auth cookies andSESSION_COOKIE_DOMAINfor express-session; omit in local dev for host-only cookies. - Google OAuth deserialize — Passport still serializes for OAuth adapter flows; GraphQL actor identity comes from access credential lookup, not
deserializeUser. - No password reset API — Do not point users at reset URLs until GraphQL + persistence exist (§4).
- 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.
