Authentication — login, JWT tokens, sessions & per-request identity

The whole auth model reduces to one idea: the backend is a stateless JWT issuer, and the admin is a stateful session proxy. The BE signIn mutation validates credentials, mints a short-lived access token + long-lived refresh token, and persists their hashes in a tokens row. Every subsequent GraphQL call carries the access token as a Bearer header; the GqlAuthGuard decodes it (it does not re-load the user from the DB) and stamps the decoded claims onto the per-request ApContextService. The Next.js admin wraps all of this in a next-auth JWT session so the browser never sees the raw refresh logic.

Source: BE src/modules/auth (+ src/modules/otp, src/modules/hr/ess, src/modules/company, src/modules/branch) · Admin src/modules/auth, src/modules/ess/auth, src/pages/api/auth/[...nextauth].ts, src/ApolloClient.tsx

Cross-links: permissions-access.md (the @ApGqlAuthorize permission/role layer that runs after auth) · multi-tenancy.md (how companyId/branchId from the token scope every query).


1. Purpose & scope

This module is responsible for:

  • Credential login (email + password) and OTP login/signup (phone + WhatsApp/email OTP).
  • JWT issuance: access + refresh tokens, their payload shape, secrets, and TTLs.
  • Token persistence & revocation via the tokens collection (single active token per user).
  • Token refresh and re-issue on company/branch switch (newToken).
  • Passport JWT strategy + the GraphQL auth guard/decorator stack (@ApGqlAuthorize).
  • Password lifecycle: forgot (email + phone), reset-by-token, change, and email/phone verification.
  • ESS employee login (a separate JWT scheme keyed on employeeId, not userId).
  • Populating the per-request context so downstream resolvers know who the caller is and which tenant/company/branch they act in.

It does NOT do:

  • Permission/role checks — those live in permissions-access.md (GqlRolesGuardGqlPermissionGuard/CASL). Auth only proves identity; access decides capability.
  • Tenant DB resolution — that's the multi-tenancy connection layer.
  • User CRUD — owned by the user module; auth only reads users via UserService.

2. Data model

2.1 tokens — the active-session ledger

src/modules/auth/token/token.schema.ts. One row = one logged-in session for one user. Login deletes any pre-existing row for that user before creating a new one (single active session — see §4).

field type required? description
userId ObjectId yes The User._id this session belongs to.
accessToken string bcrypt hash of the access JWT (never the raw token). Default "".
accessTokenExpiresIn number Absolute expiry as a unix-ms timestamp. Default 0.
refreshToken string bcrypt hash of the refresh JWT. Default "".
refreshTokenExpiresIn number Absolute expiry (unix ms). Default 0.

Extends BaseSchema (so it also carries _id, createdAt, updatedAt, soft-delete via mongoose-delete with deletedAt/deletedBy). Collection name: tokens.

The raw JWTs are returned to the client once (in the Auth payload) and never stored in plaintext. Storing only the hash means the DB cannot leak usable tokens; refresh compares the incoming raw token against the stored hash with bcrypt.compareSync.

2.2 otp — one-time codes

src/modules/otp/otp.schema.ts. Backs phone/email OTP login, signup, and verification.

field type description
otp string The 6-digit code (helper.randomDigits(6)).
message string Composed message body sent to the user.
requestId string Provider request id (WhatsApp/email gateway).
requestTo string Destination — phone number (special chars stripped) or email.
operation OTPOp What the code is for.
type OTPTypes Delivery channel.
status OTPStatus Lifecycle, default PENDING.
expireAt number unix-ms expiry; set to now + 3 minutes on create.
export enum OTPOp {
  SIGN_IN = "SIGN_IN",
  SIGN_UP = "SIGN_UP",
  VERIFY_EMAIL = "VERIFY_EMAIL",
  VERIFY_PHONE = "VERIFY_PHONE",
  BENEFICIARY_UPDATE = "BENEFICIARY_UPDATE",
}
export enum OTPTypes { PHONE = "PHONE", EMAIL = "EMAIL" }
export enum OTPStatus { PENDING = "PENDING", COMPLETED = "COMPLETED", CANCELLED = "CANCELLED" }

2.3 The Auth GraphQL payload (login response)

src/modules/auth/auth.dto.ts — what every credential/OTP/refresh/switch mutation returns. This is the only place the raw tokens leave the server.

@ObjectType()
export class Auth {
  _id: string;                 // (default "")
  tokenId: string;             // the tokens-row _id — used by signOut
  xHash: boolean;              // whether this session was opened with the x_hash suffix
  userId: string;
  groupId: string;             // permission access-group id (see permissions-access.md)
  companyId: string;           // selected company (null until a company is chosen)
  branchId: string;            // user's home branch
  username: string;
  email: string;
  kind: string;                // UserKind: SuperAdmin | Admin | Company | StoreAdmin | ...
  name: string;
  activeBranchId: string;      // currently-active branch (drives multi-tenancy branch scope)
  roles: UserRoleTypes[];      // registered as GraphQL enum "Role"
  courseId: string;
  phoneNumber: string;
  accessToken: string;
  accessTokenExpiresIn: number;   // unix-ms absolute expiry
  refreshToken: string;
  refreshTokenExpiresIn: number;  // unix-ms absolute expiry
}

2.4 JWT token payloads (what's actually signed)

AuthService.getTokens() (auth.service.ts) signs two different payloads with two different secrets:

Access token (jwt.accessTokenSecrete):

{
  _id, email, roles, name, kind, accountId,
  branchId,           // = activeBranchId || branchId
  xHash,
  groupId,
  company: companyId, // kept under both keys for back-compat
  companyId,
  activeBranchId,     // = branchId resolved above
}

Refresh token (jwt.refreshTokenSecret) — a thinner payload:

{ _id, branchId, xHash, activeBranchId, companyId }

There is no sub claim; the strategy reads payload._id. branchId/activeBranchId are resolved as user.activeBranchId || user.branchId at sign time, so the token always carries the branch the user is currently working in.

2.5 Config / secrets

src/config/configuration.ts maps env → config keys:

config key env var used for
jwt.accessTokenSecrete jwt_access_token_secret sign + verify access tokens (note the misspelling — it is the literal key)
jwt.refreshTokenSecret jwt_refresh_token_secret sign + verify refresh tokens (also used by ESS)
jwt.accessTokenExpiresIn jwt_access_token_expires_in access TTL string (e.g. "15m", "8h")
jwt.refreshTokenExpiresIn jwt_refresh_token_expires_in refresh TTL string
bcryptSalt bcrypt_salt bcrypt rounds for password + token hashing
passwordTokenTimeSpan password_token_timespan forgot-password token validity, in hours
(env only) x_hash optional password suffix that marks a session (see §9)

AuthService.getRelativeTime() parses TTL strings naively: numeric prefix + last char as the dayjs unit (e.g. "15m"{value:15, unit:'m'}), then computes the absolute *ExpiresIn timestamps with dayjs().add(...).


3. API surface

All auth mutations are exposed by AuthResolver, which carries a class-level @ApGqlAuthorize({ ignoreCompanyQuery: true, authNotRequired: true }) — i.e. every operation here is reachable without a token unless it reads @GqlCurrentUser (which requires one to be present).

Operation Type Input Returns Auth
signIn mutation email, password, client, company? Auth public
signOut mutation tokenId? Boolean public (deletes the tokens row)
refreshToken mutation userId, refreshToken Auth public (validates the refresh token)
phoneSignin mutation PhoneSignInInput { phoneNumber, otp } Auth public
phoneSignup mutation PhoneSignUpInput { name, phoneNumber, otp, agreeTerms? } Auth public
emailSignup mutation EmailSignUpInput { name, email, password } Auth public
forgotPassword mutation email String (message) public
phoneForgotPassword mutation phoneNumber String (message) public
resetPassword mutation ResetPasswordInput { token, password } String public
confirmEmail mutation ConfirmEmailInput { token, password } String public
changePassword mutation ChangePasswordInput { oldPassword, newPassword } String requires session (@GqlCurrentUser); audited
requestVerifyEmailOtp / requestVerifyPhoneOtp mutation Boolean requires session
verifyEmail / verifyPhone mutation otp String / Boolean requires session

OTP module (OTPResolver, also authNotRequired: true):

Operation Type Input Returns
requestOTP mutation RequestOTPInput { requestTo, type, operation } OTP (the otp field is deprecated/null)
verifyOTP mutation VerifyOTPInput { _id?, otp } OTP
findOTP query OTPQueryInput OTP

Company / branch (session-bound, re-issue tokens):

Operation Type Input Returns Source
switchCompany mutation SwitchCompanyInput { _id } SwitchCompanyResponse { auth: Auth, stores: Branch[] } company.resolver.tscompany.service.ts#switch
switchBranch mutation branchId Auth branch.resolver.tsbranch.service.ts#switchBranch (resolver method literally named newToken)
userCompanies query [Company] companies the logged-in user may select

ESS (EssResolver, separate scheme — see §6.2):

Operation Type Input Returns Auth
essSignIn mutation EssSignInInput { employeeRef, pin } EssAuthResult public (authNotRequired)
essRefreshToken mutation EssRefreshInput { employeeId, refreshToken } EssAuthResult public
essMe query EssProfileResult @ApGqlAuthorize()
essSetPin mutation EssSetPinInput { pin } Boolean session
adminSetEmployeePin mutation AdminSetEmployeePinInput { employeeId, pin } Boolean session
attendanceQrToken / qrClockIn query / mutation — / token QR results public / session

4. Business rules & calculations

4.1 Credential sign-in — AuthService.signIn(payload)

  1. userSvc.findOne({ email }). email is overloaded — phone login passes the phone number here. No user → 401 Invalid username or password (or payload.invalidPassError).
  2. if (!user.active)ApolloError("Your user account has been suspended").
  3. Single active session: if a tokens row already exists for this userId, it is deleted (signOut) before issuing new ones.
  4. Password check (skipped when payload.skipPassword is true — used by OTP login):
    • Detect the optional x_hash suffix: hasXhash = password.endsWith(process.env.x_hash).
    • bcrypt.compareSync(passwordWithoutXhashSuffix, user.password). Mismatch → 401.
  5. getTokens({ ...user, xHash }) mints both JWTs (see §2.4).
  6. updateTokens(userId, ...) bcrypt-hashes both tokens and creates the tokens row (concurrently re-saving the user with ignoreStoreId/ignoreCompanyId flags so the write isn't tenant-scoped).
  7. mapTokenResponse(...) returns the Auth payload (raw tokens + identity fields + tokenId).

Note: at first sign-in companyId is whatever is already on the user record (often null). The admin therefore lands on /select-company, and the real company-scoped token is minted by switchCompany (§4.6).

4.2 Token refresh — AuthService.refreshTokens(userId, token)

  1. Load user + the tokens row for userId. Missing either → ForbiddenException("Access Denied").
  2. bcrypt.compareSync(incomingRefreshToken, storedRefreshTokenHash) must match, else Access Denied.
  3. jwtService.decode(token) (decode, not verify — the stored-hash match is the integrity check) to recover the branch/company claims.
  4. getTokens({ ...user, ...decoded }) → new pair → updateTokens (replaces the row) → Auth.

On the admin, refresh happens server-side inside the next-auth jwt callback (§7.2): when Date.now() >= accessTokenExpiresIn, it calls the refreshToken mutation; if the refresh token itself is past refreshTokenExpiresIn, it short-circuits to { error: 'RefreshAccessTokenError' } without a network call.

4.3 OTP sign-in / sign-up — phoneSignin / phoneSignup

  • phoneSignin: find a PENDING OTP for requestTo = phoneNumber, operation = SIGN_IN, expireAt > now. If otp.otp !== payload.otp203 Invalid verification code. Then delegate to signIn({ email: phoneNumber, skipPassword: true }) (password skipped — the OTP is the proof).
  • phoneSignup: same OTP check with operation = SIGN_UP, then userSvc.create({ ...payload, username: phoneNumber, password: randomDigits(6) }) (a throwaway password since the account is OTP-only), then signIn(..., skipPassword: true).
  • OTP creation (OTPService.create): for SIGN_IN it first asserts the phone number is registered (406 Phone number not registered); generates a 6-digit code; cancels any existing PENDING OTP for the same requestTo; sends via WhatsApp (type=PHONE) or email (type=EMAIL); stores with expireAt = now + 3min.
  • OTP verify (OTPService.verify): find PENDING OTP by requestTo+operation, compare codes (400 Invalid OTP on mismatch), mark COMPLETED.

4.4 emailSignup

409 Conflict if email already exists; otherwise create the user (password stored hashed by the user module) and immediately signIn({ email, password }).

4.5 Password lifecycle

Method Rule
forgotPassword(email) Always returns the same success string (no user enumeration). If the user exists, stamp forgetPasswordToken = randomDigits(6) and forgetPasswordTokenExpiresIn = now + passwordTokenTimeSpan hours. (Email delivery of the token is handled by the user/mailer side.)
phoneForgotPassword(phoneNumber) Same shape; sends the 6-digit token over WhatsApp (msgSvc.whatsapp.send), swallowing send errors.
changePasswordByToken(token, newPassword) (resetPassword) Find user by forgetPasswordToken; Invalid token if none; Reset token has expired if forgetPasswordTokenExpiresIn < now; bcrypt-hash the new password, save, null out both token fields.
changePassword({ userId, oldPassword, newPassword }) Verify oldPassword with bcrypt (Invalid password on mismatch), then hash + save. Audited (@AuditMeta module:'auth').
changeUserPassword Admin-style reset with no old-password check (sets the new hash directly).
changePasswordByEmailToken(token, newPassword) (confirmEmail) Find by confirmEmailToken; hash password; clear confirmEmailToken/confirmEmailExpiredIn. Returns "Email confirmed".
verifyEmail / verifyPhoneNumber otpSvc.verify({ operation: VERIFY_EMAIL/VERIFY_PHONE, requestTo: user.email/phone, otp }), then set emailVerified/phoneNumberVerified on the user.

4.6 Company switch (re-issuing a company-scoped token) — CompanyService.switch(companyId)

  1. Temporarily set ignoreCompanyQuery = true on the context so the company lookup isn't itself company-scoped, load the company, reset the flag.
  2. Reject if company.status === ARCHIVED.
  3. In parallel: load that company's stores (branches) and authSvc.newToken({ userId, companyId }).
  4. Return { stores, auth }.

AuthService.newToken(info) re-signs both tokens from the current user merged with { companyId, activeBranchId }, persists a fresh tokens row, and returns the Auth payload — so the new access token now carries the chosen companyId/branchId. This is how a company/branch selection actually takes effect: a brand-new JWT.

4.7 Branch switch — BranchService.switchBranch(branchId)

Load branch (Branch not found if missing) → authSvc.newToken({ userId, activeBranchId: branchId, companyId: branch.companyId }) → returns Auth with the branch baked into the token.

4.8 Sign-out

signOut(tokenId) simply deletes the tokens row. Because access tokens are stateless JWTs, an already-issued access token remains technically valid until expiry — but the next refresh fails (the row is gone), and TokenResolver.deleteToken additionally guards that a user can only delete their own token row.


5. Permissions

Auth proves identity; it does not grant capability. The bridge is @ApGqlAuthorize:

  • src/modules/auth/decorators/gql-auth.decorator.ts applies UseGuards(GqlRolesGuard, GqlClientGuard) plus metadata: authNotRequired, ignoreCompanyQuery, includeBranchQuery (default true), branchIdRequired (default false), roles, and an optional permission rule.
  • GqlRolesGuard (guards/gql-roles.guard.ts) runs the auth guard first, then: branchIdCheck (throws "This action requires a BRANCH…" if branchIdRequired and no activeBranchId), permissionCheck (delegates to GqlPermissionGuard — see permissions-access.md), updateUserContext, checkCompanyArchiveStatus (blocks non-SuperAdmin callers whose company is ARCHIVED), and resolveEmployeeId (best-effort lookup of the caller's employees row, stamping employeeId onto the context).
  • GqlClientGuard only records req.headers.host as contextSvc.client and the x-audit header flag.
  • ApInitGqlAuthorize() is a lighter variant for bootstrap/init endpoints: authNotRequired: true, ignoreCompanyQuery: true, no branch query.

Full detail of the role/permission/CASL layer is in permissions-access.md.


6. Flows

6.1 Admin credential login → company → module (the happy path)

1. /login  (SigninPage)
   └─ Formik(email,password) → next-auth signIn('credentials', { email, password, redirect:false })
2. next-auth CredentialProvider.authorize()  [src/pages/api/auth/[...nextauth].ts]
   └─ graphql-request → BE  signIn(email, password, client:'admin', company)
        BE: validate creds → mint access+refresh JWT → persist tokens row → return Auth
   └─ mapTokens(res.signIn) becomes the next-auth `user`
3. jwt callback  → token = mapTokens(user)              (stores tokens in the encrypted session JWT)
   session callback → session.token = accessToken; session.user = { ...token }
   Cookie: admin-next-auth.session-token (httpOnly, lax)
4. SigninPage: findUserAccess() (prefetch permissions) → router.replace('/select-company')
5. /select-company  getServerSideProps:
   ├─ ApGuardBuilder(session).isAuth()  → redirect /login if no session
   ├─ fetchUserCompanies(session.token)  (Bearer access token)
   └─ if exactly ONE company & not SuperAdmin → auto switchCompanyAsync → redirect /select-module
6. Pick a company → CompanyContext.switchCompany(id):
   └─ BE switchCompany → { auth (NEW company-scoped token), stores }
   └─ session.update(data.auth)   ← next-auth `trigger:'update'` re-maps the token
   └─ switchBranch(stores[0]._id) if any → router.push('/')
7. /select-module: gate cards by useFeatures().hasFeature(gateKey); 1 module → auto-redirect.

Every authenticated GraphQL call from the browser then flows through authLink in src/ApolloClient.tsx, which does getSession() and sets authorization: Bearer <accessToken> + x-audit: true.

6.2 ESS employee login (kiosk / self-service — a separate token scheme)

ESS is not the user/JWT scheme above. It authenticates an Employee by employee number + PIN and mints employee-scoped tokens.

1. EssAuthContextProvider.signIn(employeeRef, pin)  [admin src/modules/ess/auth/context.tsx]
   └─ GraphQL essSignIn({ employeeRef, pin })
2. BE EssService.signIn  [src/modules/hr/ess/ess.service.ts]
   ├─ employeeRepo.findOneForAuth({ ref }) → 401 if none / no PIN
   ├─ bcrypt.compare(pin, employee.pin)    → 401 on mismatch
   ├─ computeIsManager = employeeRepo.hasDirectReports(employeeId)
   └─ buildTokens(): sign payload { employeeId, companyId, branchId, isManager, role:'EMPLOYEE' }
        access TTL 8h  (jwt_access_token_secret)
        refresh: { employeeId, type:'refresh' }  TTL 30d  (jwt_refresh_token_secret)
   → EssAuthResult { accessToken, refreshToken, employeeId, name, isManager }
3. Admin stores the session in a NON-httpOnly cookie `ess-token` (JSON), maxAge 8h
   via POST /api/ess/session  [src/pages/api/ess/session.ts]
4. Protected ESS resolvers use @EssAuthorize()  [BE src/core/guards/ess-authorize.guard.ts]:
   ├─ require Bearer token, jwtSvc.verify(accessSecret)
   ├─ reject unless payload.role === 'EMPLOYEE' and employeeId+companyId present
   └─ contextSvc.setUser({ _id:employeeId, employeeId, companyId, branchId, activeBranchId:branchId })

So an ESS context has _id == employeeId and role == 'EMPLOYEE', distinct from a normal user context. essRefreshToken verifies the refresh JWT (type==='refresh', employeeId match) and re-issues.

6.3 OTP phone login

requestOTP({ requestTo: phone, type: PHONE, operation: SIGN_IN })
  → BE asserts phone registered → 6-digit code, 3-min TTL, WhatsApp send, prior PENDING cancelled
phoneSignin({ phoneNumber, otp })
  → match PENDING SIGN_IN OTP (expireAt > now) → signIn(email:phone, skipPassword:true) → Auth

6.4 Forgot / reset password (admin)

/forgot-password (ForgotPasswordPage) → PasswordContext.forgotPassword(email)
  → forgotPassword(email)  → always "Password reset link sent…" (no enumeration)
     BE stamps forgetPasswordToken (6 digits) + expiry (passwordTokenTimeSpan hours)
reset → resetPassword({ token, password }) → changePasswordByToken
  → "Invalid token" | "Reset token has expired" | success (clears token fields)

Admin gap: the admin's /reset-password page (src/modules/auth/password/reset/page.tsx) is currently a non-wired stub — its handleSubmit is empty and both fields share name="email". The functional resetPassword mutation exists on the BE and is exercised elsewhere/manually, but the admin reset screen does not yet call it. (Flagged, not invented.)

6.5 Unhappy paths

  • Wrong password / unknown email → 401 Invalid username or password.
  • Suspended user (active=false) → "Your user account has been suspended".
  • Missing/expired/invalid access token at the guard → GqlAuthGuard.handleRequest throws an ApolloError with extensions.code in { NO_AUTH_TOKEN, TOKEN_EXPIRED, INVALID_TOKEN, UNAUTHENTICATED } (deliberately not a NestJS UnauthorizedException, so the code survives serialization for the client). The admin's Apollo errorLink recognizes these (isAuthError) and retries the operation once; WS subscription errors instead trigger useSignOutOnAuthError → client-side signOut()/login.
  • Refresh token expired/mismatch → ForbiddenException("Access Denied"); next-auth marks the session error: 'RefreshAccessTokenError'.
  • Archived company → switch is rejected, and GqlRolesGuard.checkCompanyArchiveStatus blocks ongoing access for non-SuperAdmin.

7. Admin UI & next-auth integration

7.1 Pages / routes

Route Renders Notes
/login SigninPage (wrapped in PermissionContextProvider) Formik email+password, show/hide toggle, next-auth signIn('credentials'), on success findUserAccess() then /select-company.
/forgot-password ForgotPasswordPage Formik email → PasswordContext.forgotPassword → toast + back to /login.
/reset-password ResetPasswordPage Stub (not wired) — see §6.4.
/select-company SelectCompanyScreenSelectCompany SSR-guarded; lists userCompanies; auto-switch on single company; archive/delete controls for SuperAdmin+masterAccess.
/select-module SelectModulePage Gates a static MODULE_REGISTRY by useFeatures().hasFeature(gateKey); auto-redirects when exactly one module is enabled.

7.2 next-auth config — src/pages/api/auth/[...nextauth].ts

  • Strategy: JWT session (no DB sessions). secret: process.env.TOKEN_SECRET.
  • Cookie: admin-next-auth.session-token, httpOnly, sameSite:'lax', secure in prod, optional domain from SESSION_COOKIE_DOMAIN (enables shared-cookie subdomains).
  • Single CredentialProvider (name:'credentials') whose authorize calls the BE signIn mutation with a fixed client:'admin', mapping the result through mapTokens.
  • jwt callback: on trigger:'update' (used by switchCompany/switchBranch) it re-maps from session; otherwise from the freshly-authorized user. It then checks expiry and lazily calls refreshAccessToken (the BE refreshToken mutation) when the access token is past accessTokenExpiresIn.
  • session callback: exposes session.token = accessToken and session.user = { ...token, sub: userId, xHash }, plus session.error for the refresh-failure case.

7.3 Apollo auth wiring — src/ApolloClient.tsx

  • authLink = setContext(async () => { token = (await getSession()).user.accessToken; return { authorization: 'Bearer '+token, 'x-audit':'true' } }).
  • errorLink retries once on auth-error codes/messages (isAuthError).
  • WS subscriptions use connectionParams that read the session token (with a waitForWsToken race) and reconnect when the token changes (setWsToken/clearWsToken).
  • getGqlClient() is the SSR/getServerSideProps client (graphql-request) that takes the Bearer token from ApSsrGlobal — used by fetchUserCompanies, switchCompanyAsync, etc.

7.4 Contexts & methods

  • AuthContext (modules/auth/context.tsx): exposes signOut() → BE signOut mutation → next-auth signOut({redirect:false})/login.
  • PasswordContext (modules/auth/password/context.tsx): forgotPassword(email), changePassword(old,new).
  • EssAuthContext (modules/ess/auth/context.tsx): session, signIn(employeeRef,pin), signOut() — persisted via the /api/ess/session cookie route, not next-auth.
  • CompanyContext.switchCompany(companyId, redirect?): BE switchCompanysession.update(auth) → refresh current company → switch to first store → navigate.

8. Dependencies & integrations

  • UserService — credential lookup, user create on signup, password/verification field writes (auth never queries the DB directly).
  • OTPService — code generation/verification; sends via WhatsApp and email (MessageService).
  • TokenService — CRUD over the tokens collection (extends AbstractBaseService).
  • JwtService (@nestjs/jwt) — sign/verify; JwtModule is registered with jwt.accessTokenSecrete as its default secret. ESS verifies refresh tokens with jwt_refresh_token_secret directly.
  • ApContextService — the per-request AsyncLocalStorage store that the guards populate (see below).
  • CompanyService / BranchService — re-issue tokens on company/branch switch via newToken.
  • PermissionModule — re-exported by AuthModule; the role/permission guards depend on it.
  • All cross-module imports use forwardRef() (auth ↔︎ user ↔︎ otp ↔︎ company are mutually dependent).

How auth populates the per-request context

ApContextMiddleware wraps each request in contextSvc.runContext(next) (an AsyncLocalStorage scope). Then:

  1. GqlAuthGuard.handleRequest: on success, contextSvc.setUser({ ...decodedJwtPayload }). setUser derives branchId = activeBranchId || branchId and, if a companyId is present, also sets store.company._id and user.companyId.
  2. GqlRolesGuard.updateUserContext: merges req.user again plus the includeBranchQuery/ignoreCompanyQuery flags from the decorator, then resolveEmployeeId stamps employeeId.
  3. Downstream repositories read contextSvc.companyId, contextSvc.branchId (= activeBranchId), contextSvc.userId, contextSvc.isPrivileged (kind/role ∈ {SuperAdmin, Admin, Company, StoreAdmin}), etc. to scope every query — see multi-tenancy.md.

For ESS, the EssAuthorizeGuard populates the same context but with _id == employeeId and role == 'EMPLOYEE'.


9. Gotchas & project-specific rules

  • The strategy does NOT load the user. JwtStrategy.validate(payload) just returns the decoded payload (the DB lookup is commented out). Identity = whatever the token claims. Revoking a user mid-session relies on token expiry / the tokens-row check on refresh, not on a per-request DB check.
  • Config key is misspelled on purpose. It is jwt.accessTokenSecrete (env jwt_access_token_secret). Match it exactly when porting.
  • Tokens are stored hashed, returned raw once. The DB never holds usable tokens; refresh compares raw-vs-hash.
  • Single active session per user. Sign-in deletes any existing tokens row first — a new login elsewhere invalidates the old refresh path.
  • Company/branch changes require a new JWT. There is no mutable "active company" claim you can flip; switchCompany/switchBranch mint fresh tokens (newToken) and the admin must session.update(auth) to adopt them.
  • x_hash suffix. If a password ends with process.env.x_hash, the suffix is stripped before bcrypt compare and the session is flagged xHash:true (propagated through the JWT and into the context's isXHash). It marks special/elevated sessions; the suffix is not part of the stored password.
  • OTP TTL is 3 minutes, forgot-password token TTL is passwordTokenTimeSpan hours; don't conflate them.
  • No user enumeration on forgot-password — both branches return the same message.
  • email is overloaded in signIn/ISignin — OTP login passes the phone number into the email field.
  • Auth-error codes are an explicit contract between GqlAuthGuard and the admin Apollo errorLink (NO_AUTH_TOKEN/TOKEN_EXPIRED/INVALID_TOKEN/UNAUTHENTICATED). Throwing a plain UnauthorizedException would serialize as INTERNAL_SERVER_ERROR and break the client's retry-once recovery — keep the ApolloError.
  • Admin /reset-password is a stub (empty submit, duplicate field names) — the BE mutation works but the screen is not wired. Treat as a known TODO, not as a working flow.
  • ESS is a parallel, employee-scoped JWT world (8h access / 30d refresh, role:'EMPLOYEE', PIN auth, non-httpOnly ess-token cookie). Do not assume a single unified token model — there are two.