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
signInmutation validates credentials, mints a short-lived access token + long-lived refresh token, and persists their hashes in atokensrow. Every subsequent GraphQL call carries the access token as aBearerheader; theGqlAuthGuarddecodes it (it does not re-load the user from the DB) and stamps the decoded claims onto the per-requestApContextService. The Next.js admin wraps all of this in anext-authJWT 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
tokenscollection (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, notuserId). - 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 (
GqlRolesGuard→GqlPermissionGuard/CASL). Auth only proves identity; access decides capability. - Tenant DB resolution — that's the multi-tenancy connection layer.
- User CRUD — owned by the
usermodule; auth only reads users viaUserService.
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
Authpayload) 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 withbcrypt.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.ts → company.service.ts#switch |
switchBranch |
mutation | branchId |
Auth |
branch.resolver.ts → branch.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)
userSvc.findOne({ email }).emailis overloaded — phone login passes the phone number here. No user →401 Invalid username or password(orpayload.invalidPassError).if (!user.active)→ApolloError("Your user account has been suspended").- Single active session: if a
tokensrow already exists for thisuserId, it is deleted (signOut) before issuing new ones. - Password check (skipped when
payload.skipPasswordis true — used by OTP login):- Detect the optional
x_hashsuffix:hasXhash = password.endsWith(process.env.x_hash). bcrypt.compareSync(passwordWithoutXhashSuffix, user.password). Mismatch →401.
- Detect the optional
getTokens({ ...user, xHash })mints both JWTs (see §2.4).updateTokens(userId, ...)bcrypt-hashes both tokens and creates thetokensrow (concurrently re-saving the user withignoreStoreId/ignoreCompanyIdflags so the write isn't tenant-scoped).mapTokenResponse(...)returns theAuthpayload (raw tokens + identity fields +tokenId).
Note: at first sign-in
companyIdis whatever is already on the user record (often null). The admin therefore lands on /select-company, and the real company-scoped token is minted byswitchCompany(§4.6).
4.2 Token refresh — AuthService.refreshTokens(userId, token)
- Load
user+ thetokensrow foruserId. Missing either →ForbiddenException("Access Denied"). bcrypt.compareSync(incomingRefreshToken, storedRefreshTokenHash)must match, elseAccess Denied.jwtService.decode(token)(decode, not verify — the stored-hash match is the integrity check) to recover the branch/company claims.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 aPENDINGOTP forrequestTo = phoneNumber,operation = SIGN_IN,expireAt > now. Ifotp.otp !== payload.otp→203 Invalid verification code. Then delegate tosignIn({ email: phoneNumber, skipPassword: true })(password skipped — the OTP is the proof).phoneSignup: same OTP check withoperation = SIGN_UP, thenuserSvc.create({ ...payload, username: phoneNumber, password: randomDigits(6) })(a throwaway password since the account is OTP-only), thensignIn(..., skipPassword: true).- OTP creation (
OTPService.create): forSIGN_INit first asserts the phone number is registered (406 Phone number not registered); generates a 6-digit code; cancels any existing PENDING OTP for the samerequestTo; sends via WhatsApp (type=PHONE) or email (type=EMAIL); stores withexpireAt = now + 3min. - OTP verify (
OTPService.verify): find PENDING OTP byrequestTo+operation, compare codes (400 Invalid OTPon mismatch), markCOMPLETED.
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)
- Temporarily set
ignoreCompanyQuery = trueon the context so the company lookup isn't itself company-scoped, load the company, reset the flag. - Reject if
company.status === ARCHIVED. - In parallel: load that company's
stores(branches) andauthSvc.newToken({ userId, companyId }). - 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.tsappliesUseGuards(GqlRolesGuard, GqlClientGuard)plus metadata:authNotRequired,ignoreCompanyQuery,includeBranchQuery(default true),branchIdRequired(default false),roles, and an optionalpermissionrule.GqlRolesGuard(guards/gql-roles.guard.ts) runs the auth guard first, then:branchIdCheck(throws "This action requires a BRANCH…" ifbranchIdRequiredand noactiveBranchId),permissionCheck(delegates toGqlPermissionGuard— see permissions-access.md),updateUserContext,checkCompanyArchiveStatus(blocks non-SuperAdmincallers whose company isARCHIVED), andresolveEmployeeId(best-effort lookup of the caller'semployeesrow, stampingemployeeIdonto the context).GqlClientGuardonly recordsreq.headers.hostascontextSvc.clientand thex-auditheader 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-passwordpage (src/modules/auth/password/reset/page.tsx) is currently a non-wired stub — itshandleSubmitis empty and both fields sharename="email". The functionalresetPasswordmutation 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.handleRequestthrows anApolloErrorwithextensions.codein{ NO_AUTH_TOKEN, TOKEN_EXPIRED, INVALID_TOKEN, UNAUTHENTICATED }(deliberately not a NestJSUnauthorizedException, so the code survives serialization for the client). The admin's ApolloerrorLinkrecognizes these (isAuthError) and retries the operation once; WS subscription errors instead triggeruseSignOutOnAuthError→ client-sidesignOut()→/login. - Refresh token expired/mismatch →
ForbiddenException("Access Denied"); next-auth marks the sessionerror: 'RefreshAccessTokenError'. - Archived company → switch is rejected, and
GqlRolesGuard.checkCompanyArchiveStatusblocks 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 |
SelectCompanyScreen → SelectCompany |
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',securein prod, optionaldomainfromSESSION_COOKIE_DOMAIN(enables shared-cookie subdomains). - Single
CredentialProvider(name:'credentials') whoseauthorizecalls the BEsignInmutation with a fixedclient:'admin', mapping the result throughmapTokens. jwtcallback: ontrigger:'update'(used byswitchCompany/switchBranch) it re-maps fromsession; otherwise from the freshly-authorizeduser. It then checks expiry and lazily callsrefreshAccessToken(the BErefreshTokenmutation) when the access token is pastaccessTokenExpiresIn.sessioncallback: exposessession.token = accessTokenandsession.user = { ...token, sub: userId, xHash }, plussession.errorfor 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' } }).errorLinkretries once on auth-error codes/messages (isAuthError).- WS subscriptions use
connectionParamsthat read the session token (with awaitForWsTokenrace) and reconnect when the token changes (setWsToken/clearWsToken). getGqlClient()is the SSR/getServerSidePropsclient (graphql-request) that takes the Bearer token fromApSsrGlobal— used byfetchUserCompanies,switchCompanyAsync, etc.
7.4 Contexts & methods
AuthContext(modules/auth/context.tsx): exposessignOut()→ BEsignOutmutation →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/sessioncookie route, not next-auth.CompanyContext.switchCompany(companyId, redirect?): BEswitchCompany→session.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 thetokenscollection (extendsAbstractBaseService).JwtService(@nestjs/jwt) — sign/verify;JwtModuleis registered withjwt.accessTokenSecreteas its default secret. ESS verifies refresh tokens withjwt_refresh_token_secretdirectly.ApContextService— the per-requestAsyncLocalStoragestore that the guards populate (see below).CompanyService/BranchService— re-issue tokens on company/branch switch vianewToken.PermissionModule— re-exported byAuthModule; 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:
GqlAuthGuard.handleRequest: on success,contextSvc.setUser({ ...decodedJwtPayload }).setUserderivesbranchId = activeBranchId || branchIdand, if acompanyIdis present, also setsstore.company._idanduser.companyId.GqlRolesGuard.updateUserContext: mergesreq.useragain plus theincludeBranchQuery/ignoreCompanyQueryflags from the decorator, thenresolveEmployeeIdstampsemployeeId.- 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 / thetokens-row check on refresh, not on a per-request DB check. - Config key is misspelled on purpose. It is
jwt.accessTokenSecrete(envjwt_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
tokensrow 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/switchBranchmint fresh tokens (newToken) and the admin mustsession.update(auth)to adopt them. x_hashsuffix. If a password ends withprocess.env.x_hash, the suffix is stripped before bcrypt compare and the session is flaggedxHash:true(propagated through the JWT and into the context'sisXHash). It marks special/elevated sessions; the suffix is not part of the stored password.- OTP TTL is 3 minutes, forgot-password token TTL is
passwordTokenTimeSpanhours; don't conflate them. - No user enumeration on forgot-password — both branches return the same message.
emailis overloaded insignIn/ISignin— OTP login passes the phone number into theemailfield.- Auth-error codes are an explicit contract between
GqlAuthGuardand the admin ApolloerrorLink(NO_AUTH_TOKEN/TOKEN_EXPIRED/INVALID_TOKEN/UNAUTHENTICATED). Throwing a plainUnauthorizedExceptionwould serialize asINTERNAL_SERVER_ERRORand break the client's retry-once recovery — keep theApolloError. - Admin
/reset-passwordis 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-httpOnlyess-tokencookie). Do not assume a single unified token model — there are two.