Authentication Architecture
End-to-end authentication for this stack: Express, Passport, express-session with Redis, GraphQL (Apollo Server), and a React / Apollo Client SPA. All behavior below is grounded in the repository; where a capability is only partially present (e.g. mail templates without a public API), that is called out explicitly. RBAC (roles, scopes, middleware) is documented separately: RBAC.md.1. Overview
High-level summary
- Identity is established with Passport using a server-side session (serialized user id → deserialized
User). - Primary API for sign-in / sign-up / sign-out is GraphQL (
/graphql), withcredentials: trueCORS and Apollo configured to send cookies. - 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, proxied from the Vite dev server. - Multi-tenancy is modeled with organizations and
OrganizationMemberrows;User.current_membershipselects which org context is active for scope checks (see RBAC.md).
Technologies and libraries
| Layer | Technology |
|---|---|
| HTTP server | Express (apps/backend/src/index.ts) |
| Session | express-session + connect-redis (apps/backend/src/session.ts) |
| Auth | passport, passport-local, passport-google-oauth20 (apps/backend/src/auth/passport.ts, apps/backend/src/auth/strategies/google.ts) |
| Password hashing | bcrypt on User model hooks + validPassword (apps/backend/src/tools/rds/sequelize/models/user/index.ts) |
| API | Apollo Server 4 + expressMiddleware (apps/backend/src/index.ts) |
| Frontend transport | Apollo Client createHttpLink → /graphql, credentials implied via same-site cookie usage; authLink reads localStorage key __jwt (apps/frontend/src/main.tsx) |
Key architectural decisions (as implemented)
- Session cookie auth for GraphQL — Context is
{ req, res, tools }; resolvers callreq.isAuthenticated()/req.login/req.logout. There is no backend middleware that validates a JWT fromAuthorizationfor GraphQL in this codebase. - Redis-backed sessions — Horizontally scalable session store with key prefix
session:(apps/backend/src/session.ts). - 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) so it survives the Google round-trip even when the session id cookie is not preserved (apps/backend/src/utilities/oauthRedirectCookie.ts,apps/backend/src/routers/auth/index.ts). OAuthstateremains Passport’s CSRF mechanism.
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).
Core gate:
2.3 Sign-in flow (Google OAuth)
Entry: Browser navigates to/auth/google (proxied to backend in dev — apps/frontend/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/index.ts) — CORS allows frontend + backend origins with credentials: true; /graphql stack includes passport.session() so req.user is populated from the session cookie.
Expiry: maxAge is 30 days (milliseconds expression in code). There is no refresh-token style rotation implemented for sessions beyond store TTL / browser cookie behavior.
2.5 Sign-out flow
Entry:signOut mutation (apps/backend/src/gql/mutations/signOut.ts).
3. Token & session architecture
| Mechanism | Purpose | Storage | Validated where |
|---|---|---|---|
| Express session id | Authenticated browser sessions | HTTP cookie (session middleware) | Redis session store + Passport deserialize on each request |
| Email validation token | One-time email proof | users.email_validation_token + expiry columns | validateEmailToken / validateEmail mutation (apps/backend/src/utilities/user.ts, apps/backend/src/gql/mutations/validateEmail.ts) |
| Invitation token | Org invite acceptance | organization_members.invitation_token + invitation_expires_at | acceptInvitation, invitation query, completeInvitedProfile |
localStorage.__jwt | Optional Bearer for Apollo | Client localStorage | Not read by backend GraphQL in this repo; no setItem("__jwt") usage found — see §10 |
Authorization: Bearer … only if __jwt is set (apps/frontend/src/main.tsx); the server continues to rely on session cookies for me and protected mutations.
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 LocalStrategy vs actual password login
apps/backend/src/auth/passport.ts registers passport-local and verifies passwords with scrypt against user.password as a raw string comparison. That does not match bcrypt storage on User. No passport.authenticate('local', …) route was found in apps/backend/src/routers. Effective password authentication for the product is GraphQL + bcrypt via canSignInWithPassword.
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 a session). Sets name + password, validates email token + invitation, then req.login.
When an invite expires
| Scenario | Behavior |
|---|---|
acceptInvitation | getOpenInvitationByToken uses invitation_expires_at: { [Op.gt]: new Date() } — no row → generic failure (STANDARD_HIDDEN_ERROR_MESSAGE). |
inviteMember (re-invite) | If pending invite exists and invitation_expires_at < now, resetInvitation issues a new token + expiry and sends email (handleExistingOrganizationMember). |
completeInvitedProfile | Returns explicit message: “Invitation has expired, please ask an administrator to send you a new invitation” when isInvitationExpired(invitation). |
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
- Passport loads
Userby id; 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, then updatescurrent_membership.
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/:
| Middleware | Behavior |
|---|---|
isLoggedIn | req.isAuthenticated() else 401 (getGQLError) |
isLoggedOut | Authenticated users get 403 |
populateUserWithMemberships | Loads User + memberships, attaches context.user |
hasScopes | Super-admin bypass; else canAccessScopes on current membership |
isAdmin | Current membership role === PublicRoles.ADMIN |
isAdminOrSuperAdmin | Super admin or org admin |
isSuperAdmin | context.user.is_super_admin |
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
There is no sharedrequireAuth Express middleware in apps/backend/src/routers beyond Passport’s session. Current patterns:
- OAuth routes —
passport.authenticatehandles auth (apps/backend/src/routers/auth/index.ts). - Webhooks — e.g. Stripe uses Stripe signature verification, not session (
apps/backend/src/routers/api/stripe.ts).
req.isAuthenticated() (and optionally reloads the user) after initSession / passport.session() are applied (apps/backend/src/session.ts, apps/backend/src/index.ts).
Example sketch (new code — not present verbatim):
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/frontend/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).
10. Common pitfalls
passport-local+ scrypt vs bcrypt — Do not assumeLocalStrategymatches stored passwords; password sign-in iscanSignInWithPassword+ bcrypt. Usingpassport.authenticate('local')without changing verification would be incorrect.localStorage.__jwt— Frontend sends Bearer token if present, but backend GraphQL does not implement JWT auth here; session cookie is what matters.__jwtis never set in this repository’s frontend sources — dead path unless a fork adds it.- 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).- Session cookie
domain— OptionalSESSION_COOKIE_DOMAINenv setscookie.domain; omit in local dev so the browser uses a host-only cookie (works with Vite on:4000proxying to the API). - Super-admin deserialize edge case —
deserializeUsermay calldone(null, user || { id })when user missing — downstream code generally expects a fullUser; monitor for deleted users with live sessions. - 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
| Concern | Path |
|---|---|
| Server bootstrap + GraphQL | apps/backend/src/index.ts |
| Session + Passport attach | apps/backend/src/session.ts |
| Strategies | apps/backend/src/auth/passport.ts, apps/backend/src/auth/strategies/google.ts |
| OAuth routes + redirect | apps/backend/src/routers/auth/index.ts |
| OAuth redirect cookie (signed) | apps/backend/src/utilities/oauthRedirectCookie.ts |
| Redirect validation | apps/backend/src/utilities/redirect.ts |
| Password sign-in gate | apps/backend/src/utilities/auth.ts |
| User + validation tokens | apps/backend/src/utilities/user.ts |
| User model + bcrypt | apps/backend/src/tools/rds/sequelize/models/user/index.ts |
| Invitations | apps/backend/src/utilities/organization.ts, apps/backend/src/gql/mutations/inviteMember.ts, acceptInvitation.ts, completeInvitedProfile.ts, apps/backend/src/gql/queries/invitation.ts |
| GraphQL context types | apps/backend/src/gql/type.ts |
| Apollo Client | apps/frontend/src/main.tsx |
| Dev proxy to backend | apps/frontend/vite.config.ts |
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.