Authentication¶
1. Overview¶
SIS uses custom, self-hosted authentication built on Passport.js + JWT. There is no third-party auth provider (Auth0, Cognito, etc.). This gives the team full control over token structure and lifetime, which is a hard requirement for the Entity-Scope permission model — roles and scopes live inside the JWT payload and are interpreted by guards on every request.
Key design choices:
- Password-first, up-to-3-step login (credentials → optional tenant selection → optional profile selection). The user supplies email + password; the system finds all matching accounts across all active tenants and verifies the password against each. The tenant is resolved by credentials, not by a subdomain or header. See chapter 02 for tenant resolution details, and §9 for the full state machine.
- HttpOnly cookie delivery. Tokens are sent as
HttpOnly; Secure; SameSite=Nonecookies so JavaScript cannot read them.SameSite=Noneis deliberate — the FE and BE live on different domains in every environment, andNonerequiresSecure(which is also set). Bearer header auth is also supported for non-browser clients. - Self-hosted refresh token rotation. Refresh tokens are stored (hashed) in the database, rotated on every use, and support replay detection and family revocation.
2. Login Flow¶
Step 1 — Credential Validation¶
AuthService.validateCredentials() runs cross-tenant:
- Find all active
Userrecords matching the email across all active tenants. - Verify the supplied password against each match using
argon2.verify()— all checks run in parallel to keep latency flat regardless of tenant count. - Collect valid matches (userId + tenantId pairs where password verification passes).
| Matches | Response |
|---|---|
| 0 | 401 — "Invalid credentials". A dummy argon2.verify() is always run even when no users are found, keeping response time consistent and preventing timing-based email enumeration. |
| 1 | Auto-login: issue tokens, set cookies, return { user }. |
| 2+ | 200 with { requiresTenantSelection: true, tenants: [...], selectionToken }. The client must prompt the user to pick a tenant. |
The tenant list is only returned after password verification, so an attacker cannot enumerate which tenants an email belongs to without knowing the correct password.
Each entry's display name is Tenant.name, which SchoolService keeps in sync with School.operationalName on every school write (see the school/ module in REFERENCE.md / spec 2026-06-26-school-tenant-name-sync-design.md) — so the picker reflects the configured school name, not the provisioning default.
Step 2 — Tenant Selection (multi-tenant accounts only)¶
The selectionToken is a short-lived JWT (10-minute TTL) signed by the server, containing:
The server verifies the token, confirms the requested tenantId is in the pre-validated list, then proceeds to token issuance.
Token Issuance (both paths)¶
AuthService.login():
1. Sign a JWT access token (1h TTL, driven by JWT_EXPIRATION)
2. Generate a random refresh token (64 hex chars)
3. Hash the refresh token (SHA-256), store hash in DB
4. Set HttpOnly cookies: access_token + refresh_token
5. Return { user: { id, email, firstName, lastName } }
3. JWT & Token Lifecycle¶
Payload¶
Every JWT access token carries:
{
"sub": "<userId>",
"tenantId": "<tenantId>",
"roles": ["admin"],
"activeProfile": "teacher",
"activeRole": null,
"isPlatformAdmin": false
}
The tenantId embedded in the token is the single source of truth for tenant context on subsequent requests — no Host header dependency. activeProfile is the narrowed active-session profile (PersonProfileKey | 'platform'); the roles array is narrowed to that profile's role set — see §9. activeRole is the session's active view: when non-null, roles is exactly [activeRole] — see §10.
Token Delivery¶
| Channel | Cookie name | Notes |
|---|---|---|
| HttpOnly cookie | access_token |
Primary channel for browser clients |
| HttpOnly cookie | refresh_token |
Stored separately; sent only to /auth/refresh |
Authorization: Bearer header |
— | Supported for non-browser / API clients |
Request Pipeline¶
After login, every authenticated request flows through:
Request
→ JwtAuthGuard Extract token from cookie or Bearer header
→ JwtStrategy.validate() Decode and verify JWT, attach { userId, tenantId, roles } to request.user
→ ScopeGuard Check @RequireScopes() entity-level access
→ ActionGuard Check @RequireAction() operation-level access (opt-in)
→ FieldWriteGuard Reject writes to fields outside the user's writable scopes
→ Controller → Service Business logic; tenantId filtering in every query
→ FieldFilterInterceptor Strip unauthorized scope groups from the response
Platform admins (isPlatformAdmin: true) bypass ScopeGuard, ActionGuard, FieldWriteGuard, and FieldFilterInterceptor.
Token Lifetimes¶
| Token | TTL |
|---|---|
| Access token | 1 hour |
| Refresh token | 7 days |
| Selection token | 10 minutes |
4. Refresh & Rotation¶
Access tokens expire after 1 hour. The client uses the refresh token to obtain a new pair:
Server-side refresh logic:
- Hash lookup — SHA-256 hash of the supplied token is looked up in the database.
- Replay detection — if the token hash is found but already marked revoked, the system treats this as a replay attack and revokes the entire refresh token family for that user, forcing re-authentication.
- Tenant + user validation — confirm the tenant is still
ACTIVEorTRIAL, and the user is stillisActive. - IP change logging — if the request IP differs from the IP that last used this token, a warning is logged (not blocked, but auditable).
- Rotation — the old refresh token is revoked, a new access token + refresh token are issued in the same family.
This "use once and rotate" model limits the blast radius if a refresh token is stolen: any attempt to reuse a consumed token immediately locks down the entire token family.
isActive is also the lever for archiving a person: setting a Teacher/Staff to PeopleStatus.ARCHIVED deactivates the linked User (isActive=false) and revokes their refresh tokens in the same transaction, so login (which filters isActive: true) and refresh both stop. Un-archiving flips isActive back. Students (2026-09-03): a PATCH /students/:id that moves status into LEFT or GRADUATED (one set) runs the same revocation from StudentsService.afterUpdate — immediately at the status write, independent of the placement boundary — and readmission (LEFT/GRADUATED → ENROLLED/PRE_ENROLLED) restores the still-bound user; a deleted pending invitation is not resurrected. Invitability itself is also fenced: a non-ENROLLED student, or a referent with no ENROLLED linked student, answers INVITATION_ACCESS_DISABLED and is absent from GET /invitations (spec docs/superpowers/specs/2026-09-03-student-participation-status-fences-design.md). The binding is kept (not detached), so reactivation needs no re-invitation. See docs/superpowers/specs/2026-06-22-people-status-archive-and-contract-validation-design.md.
5. Rate Limiting¶
Rate limiting is applied via @nestjs/throttler:
| Scope | Limit |
|---|---|
| Global (all routes) | 120 requests / 60 seconds |
Login (POST /auth/login) |
5 requests / 60 seconds |
Tenant selection (POST /auth/login/select-tenant) |
5 requests / 60 seconds |
Profile selection (POST /auth/login/select-profile) |
5 requests / 60 seconds |
Profile switch (POST /auth/switch-profile) |
10 requests / 60 seconds |
View switch (POST /auth/switch-view) |
10 requests / 60 seconds |
Refresh (POST /auth/refresh) |
10 requests / 60 seconds |
The backend reads the real client IP from req.ip via Express trust proxy (configured in main.ts), so rate limiting works correctly behind Railway's load balancer and Cloudflare's proxy.
6. Trade-offs¶
We own the security implementation. Using a managed auth provider would outsource password hashing, token rotation, brute-force protection, and CSRF defence. By staying self-hosted we carry that responsibility — mitigated by using battle-tested libraries (argon2, passport-jwt, helmet, @nestjs/throttler) and following established patterns (SHA-256 token hashing, family-based revocation, timing-safe dummy verifies).
Why it's worth it. The Entity-Scope permission model requires tenant ID and role data inside the JWT payload, refreshed on every rotation. No off-the-shelf provider gives us clean control over the payload structure, token family semantics, and per-refresh tenant/user re-validation without significant workarounds. Full ownership is the pragmatic choice here.
7. Profile endpoints — /me vs /profile¶
Two reads expose the authenticated identity, with different surface areas:
GET /auth/me— lean. Returns the User row's identity columns (id, email, firstName, lastName, avatarUrl) plus JWT-derivedtenantId / roles[] / isPlatformAdminand the access-token expiry. Owned byAuthModule. Used by the navbar / session-validity probe.GET /auth/profile— full. Sameuserpayload plus the caller's Teacher / Staff / Student / Referent rows joined viauserId. Year-snapshotted tables (Teacher/Staff/Student) resolve to the tenant's active academic year viaAcademicYearsService.getActiveYear; Referent is global. Eachprofiles.*field isnullwhen no row exists. The response also carries a top-levelacademicYearId— the active academic year the year-snapshotted profiles resolved against, ornullwhen no year is active. Self-service: noFieldFilterInterceptor— the user always reads their own data in full. Owned byProfileModule(src/profile/), notAuthModule, to break aAuthModule → {Teachers,Staff,Referents}Module → InvitationsModule → AuthModuleimport cycle. The route still sits at/auth/profilebecause Nest allows multiple controllers to share a route prefix. Used by the dashboard / profile page.
Both endpoints sit behind JwtAuthGuard and inherit the global throttler.
8. Key Files¶
| File | Purpose |
|---|---|
src/auth/auth.controller.ts |
HTTP endpoints: login, select-tenant, select-profile, switch-profile, refresh, logout, /me |
src/auth/auth.service.ts |
Cross-tenant credential validation, token generation, refresh logic |
src/profile/profile.controller.ts |
GET /auth/profile — sibling route, separate module |
src/profile/profile.service.ts |
Composes /auth/profile payload from the four role services + AcademicYearsService |
src/auth/strategies/jwt.strategy.ts |
Passport strategy — validates JWT from cookie or Bearer header |
src/auth/guards/jwt-auth.guard.ts |
Guard that requires a valid JWT on protected routes |
src/auth/dto/select-tenant.dto.ts |
DTO for the tenant selection step |
src/auth/interfaces/authenticated-user.interface.ts |
Shape of request.user after JWT validation |
src/auth/interfaces/jwt-payload.interface.ts |
Shape of the JWT payload |
src/auth/auth-config.helper.ts |
Parses REFRESH_TOKEN_EXPIRATION_DAYS env var with a default fallback |
src/auth/utils/to-user-profile.ts |
Shared mapper between /auth/me and /auth/profile response shapes |
9. Active Profile — 3-step login state machine¶
Why it exists¶
A user can be linked to more than one person-entity row in the same tenant (Teacher + Referent, or Staff + Referent — Teacher + Staff is prohibited). Without a per-session active profile, the JWT would carry both role keys and downstream helpers that branch on role membership (e.g. studentsForAccessContext) would silently pick whichever branch evaluates first, leaking cross-profile record access. The frontend would also have no signal for which dashboard to render.
activeProfile makes the session context explicit: one profile is chosen at login (or switched mid-session), the JWT's roles array is narrowed to that profile's role set, and record-level helpers see a single coherent context.
Login state machine¶
Login extends the existing 2-step tenant-selection into a 3-step flow. Since the login-time view selection iteration (2026-08-04) the third step is a view chooser, not a profile chooser: the selectable unit is a key from the flat views namespace of §10 (person profiles ∪ held role keys, deduped) plus a platform entry for platform admins. The chooser fires for any account with two or more views — a single-profile staff+admin now picks at login where they used to land in the combined session.
POST /auth/login {email, password}
├─ 0 matches → 401 INVALID_CREDENTIALS
├─ 1 tenant + 1 view → 200 AuthUserDto (cookies set — combined default)
├─ 1 tenant + 2+ views → 200 ProfileSelectionResponseDto (view chooser)
└─ 2+ tenants → 200 TenantSelectionResponseDto
POST /auth/login/select-tenant {selectionToken, tenantId}
├─ 1 view in chosen tenant → 200 AuthUserDto
└─ 2+ views in chosen tenant → 200 ProfileSelectionResponseDto
POST /auth/login/select-profile {selectionToken, activeProfile: <view key>}
├─ valid → 200 AuthUserDto (cookies set — session narrowed to the pick)
└─ invalid → 400 ACTIVE_VIEW_NOT_AVAILABLE | token errors
Each step returns either a terminal session (AuthUserDto) or a selection prompt carrying a fresh 10-minute selection token (SELECTION_TOKEN_TTL). The frontend treats the response shape as the next state — if requiresProfileSelection: true is present, it renders a chooser. If a selection step is rejected with AUTH_TOKEN_INVALID (the token is invalid or expired), the backend clears the auth cookies on that response — the login flow can't continue, so the client is put back into a clean logged-out state and must restart from the password step. Other selection failures (AUTH_TENANT_INVALID, ACTIVE_VIEW_NOT_AVAILABLE) leave the still-valid token in place so the user can retry the chooser.
| Endpoint | Body | Terminal response | Selection response |
|---|---|---|---|
POST /auth/login |
{ email, password } |
AuthUserDto |
TenantSelectionResponseDto or ProfileSelectionResponseDto |
POST /auth/login/select-tenant |
{ selectionToken, tenantId } |
AuthUserDto |
ProfileSelectionResponseDto |
POST /auth/login/select-profile |
{ selectionToken, activeProfile: <view key> } |
AuthUserDto |
— |
ProfileSelectionResponseDto carries views: [{ key, label }] — the complete, ordered, labeled chooser (same label strings as GET /roles / availableViews, plus the platform entry when applicable) — alongside the legacy profiles array (kept for FE transition; render views). The selection token embeds the offered view keys (availableViews claim) and the consume step re-validates the pick against a fresh recomputation of the whole set, so a role revoked or a person row deleted during chooser dwell is rejected with ACTIVE_VIEW_NOT_AVAILABLE.
A pick has exactly the switch-view semantics of §10: employee-profile picks narrow to the coupled role (a teacher+department_head picking Teacher lands in roles: ['teacher']), role-key picks anchor activeProfile on the held employee profile (falling back to the first held profile), referent/student keep the sentinel narrowing, platform starts the passthrough platform session. There is no combined entry at login — the union is reachable afterward via POST /auth/switch-view { target: null }. Accounts with no person row are unchanged: platform admins go straight to the platform session; anyone else still cannot log in (role views need a person profile to anchor on).
The selection token for select-profile uses subject constant 'profile-selection'; the one for select-tenant uses 'tenant-selection'. Each verifier rejects the other's subject, preventing token mix-up.
JWT payload changes¶
type JwtPayload = {
sub: string; // userId
tenantId: string;
roles: string[]; // narrowed to the active session — see §9 Narrowing rule
activeProfile: ActiveProfileKey; // NEW — `PersonProfileKey | 'platform'`
isPlatformAdmin: boolean;
};
request.user (via JwtStrategy) gains activeProfile. AuthenticatedUser and AuthenticatedRequest extend accordingly.
Profile switching¶
POST /auth/switch-profile {activeProfile} (JwtAuthGuard required)
├─ requestedProfile == currentActiveProfile → 200 AuthUserDto (idempotent, no rotation)
├─ valid switch → 200 AuthUserDto (tokens rotated, cookies updated)
└─ invalid → 400 ACTIVE_PROFILE_NOT_AVAILABLE
Validates that the user has a person-entity row of the requested type in the current tenant, revokes the current refresh token, and issues new access + refresh tokens with the updated activeProfile and correspondingly narrowed roles.
Refresh token — activeProfile column¶
The RefreshToken table gains an activeProfile column (nullable — see migration note). On refresh:
- Look up the stored
RefreshTokenrow. - If
row.activeProfileisNULL(legacy row predating this change) → 401ACTIVE_PROFILE_NOT_AVAILABLE— forces re-login. - If
row.activeProfileis no longer in the user's fresh profile list (profile deleted mid-session) → 401ACTIVE_PROFILE_NOT_AVAILABLE. - Otherwise, carry
activeProfileforward into the new access token's narrowedrolesand the newRefreshTokenrow.
New tokens issued by any endpoint (login, select-profile, switch-profile, and refresh itself) always populate activeProfile. After a grace window the column can be tightened to NOT NULL in a follow-up migration.
Error handling¶
| Code | HTTP | Trigger |
|---|---|---|
ACTIVE_VIEW_NOT_AVAILABLE |
400 | select-profile with a view not offered or no longer available (also switch-view, §10) |
ACTIVE_PROFILE_NOT_AVAILABLE |
400 | switch-profile with a profile the user doesn't have |
ACTIVE_PROFILE_NOT_AVAILABLE |
401 | Login of a user with no profile at all; refresh on a legacy token (activeProfile = NULL) or profile deleted mid-session |
The response body is generic; debug context (requested, available) is logged server-side only.
Request lifecycle changes¶
After this spec, every authenticated request carries request.user.activeProfile: ActiveProfileKey (i.e. PersonProfileKey | 'platform' — the synthetic 'platform' value covers vendor/superadmin sessions). Controllers and services that need to branch by profile should consult this field directly. The roles[] array on request.user is the narrowed active session set — see "Active-session narrowing" in docs/04-rbac.md.
Soft-enforcement window¶
Access tokens are stateless JWTs with a 1-hour lifetime. If a user's person-entity row (Teacher/Staff/Referent) is hard-deleted while they are logged in as that profile, their existing access token continues to grant the corresponding RBAC scopes until expiry. Refresh-time validation (refresh()) catches the deletion and forces re-login. To shorten the window, the deletion flows in TeachersService.remove, StaffService.remove, and ReferentsService.remove revoke all of the user's refresh tokens — so the user gets bumped on the next refresh attempt rather than after a full token lifetime.
Frontend integration¶
| Response shape | Frontend handles by... |
|---|---|
AuthUserDto (200, with Set-Cookie) |
Authenticated; navigate to dashboard. |
TenantSelectionResponseDto (200, no cookies) |
Show tenant picker; POST /auth/login/select-tenant with {selectionToken, tenantId}. |
ProfileSelectionResponseDto (200, no cookies) |
Show the view picker (render views[] — labeled); POST /auth/login/select-profile with {selectionToken, activeProfile: <view key>}. |
Switching profiles mid-session uses POST /auth/switch-profile with {activeProfile} and consumes/issues new cookies. GET /auth/me returns availableProfiles: ActiveProfileKey[] so the frontend can render the switch UI without an extra round-trip.
Rollout note¶
The migration that adds refresh_tokens.active_profile is nullable. Existing in-flight refresh tokens issued before this deploy will have active_profile = NULL. The refresh() flow rejects those rows with ACTIVE_PROFILE_NOT_AVAILABLE (401), forcing affected users to log in again. After all in-flight tokens have rotated (default 7 days based on JWT_REFRESH_EXPIRATION_DAYS), a follow-up migration may tighten the column to NOT NULL.
Key files¶
| File | Role |
|---|---|
src/common/constants/person-profiles.ts |
PERSON_PROFILE_KEYS, PersonProfileKey, EMPLOYEE_PROFILES, PERSON_PROFILE_TO_ENTITY |
src/common/utils/narrow-roles.ts |
narrowRolesForActiveProfile + resolveSessionRoles pure helpers |
src/auth/dto/profile-selection-response.dto.ts |
ProfileSelectionResponseDto (labeled views chooser + legacy profiles) |
src/auth/dto/select-profile.dto.ts |
{ selectionToken, activeProfile: <view key> } |
src/auth/dto/switch-profile.dto.ts |
{ activeProfile } |
src/auth/auth.service.ts |
resolveProfileSelection, completeProfileSelection, switchActiveProfile |
src/auth/auth.controller.ts |
POST /auth/login/select-profile, POST /auth/switch-profile |
10. Active view — role-level narrowing¶
Why it exists¶
activeProfile separates person-classes (teacher/staff vs referent vs student), but within an employee session the JWT still carried the union of every RBAC role the user holds: a teacher-who-is-admin was always admin-flavored everywhere (permission compilation, record-policy pass-throughs, attendance's grant-shaped school-wide check). Product wants a "view selector": the same person can step down into one hat — take attendance as a teacher, with the teacher's fences and defaults — and step back up.
The views namespace¶
A view is a single key from the flat namespace of the caller's person profiles ∪ held role keys, deduped (profile-coupled keys appear once). GET /auth/me returns:
availableViews: [{ key, label }]— labels areLocalizedMessageDtos resolved byresolveViewLabel: role keys use the same catalog asGET /roles; the four profile keys use the siblingPROFILE_VIEW_LABELScatalog (kept out ofROLE_LABELS, whose key set is drift-spec-pinned to the management presets). Held profiles first (inPERSON_PROFILE_KEYSorder), remaining role keys alphabetical.activeView: string | null—null= the combined default session. Referent/student sessions report their profile key (they are inherently single-view). Present on every auth response;availableViewsonly on/auth/me. Platform sessions get an empty list.
Switching¶
POST /auth/switch-view { target: string | null } (JwtAuthGuard, 10 req/60s)
├─ target == current view → 200 permissions map (idempotent, no rotation)
├─ valid switch → 200 permissions map (tokens rotated, cookies updated)
└─ invalid → 400 ACTIVE_VIEW_NOT_AVAILABLE
The response body is byte-shape-identical to GET /permissions — the recompiled map for the new session — so the FE needs no second round-trip. Target semantics:
- Role key (
admin,department_head, custom …) → session roles become exactly[target];activeProfileflips to the caller's employee profile when one exists. teacher/staff→ same rule (the profile-coupled key doubles as the role): an explicitteacherpick drops dept-head and every sibling grant.referent/student→ the existing profile-switch semantics (sentinel narrowing).null→ back to the combined default (narrowRolesForActiveProfileof the full set).
Since 2026-08-21 all four person-profile keys are backed by frozen preset roles.
In particular, staff is no longer a role-less view sentinel: STAFF invitation
acceptance binds the tenant staff role, existing activated users are backfilled
by Tier-1 seeding, and tenant bootstrap admins receive it during provisioning.
The Staff view therefore compiles its own four-scope READ baseline instead of an
empty permission map.
Internally the session state is the pair (activeProfile, activeRole); the pair never reaches the FE. The issuance rule everywhere is resolveSessionRoles: activeRole ? [activeRole] : narrowRolesForActiveProfile(allRoles, activeProfile). switch-profile always resets activeRole to null. Rotation reuses the switch-profile family semantics (rotateSessionTokens: replay detection, fresh family, optimistic-lock revoke).
The target→pair resolution is the shared resolveViewSelection helper — the login chooser (§9) consumes the exact same rule, so login-time and mid-session picks cannot drift. The only difference is the role-key anchor fallback: mid-session it is the current profile; at login it is the first held person profile.
Login-time selection¶
Sessions no longer always start at the union: the §9 login chooser lets a multi-view account start directly in a view (activeRole populated at first issuance). The combined default remains the state of single-view users, of target: null restores, and of revoked-view refresh fallbacks. availableViews on /auth/me still excludes platform (platform switching stays on switch-profile); only the login chooser lists it, labeled from PROFILE_VIEW_LABELS.
Refresh¶
RefreshToken.activeRole (nullable; NULL = combined default, so legacy rows need no migration gate). On refresh the stored value is carried forward only while the role is still granted; a revoked view silently degrades the session to the combined default — no 401, losing a view must not kill the session. Profile deletion keeps its existing 401.
Faithfulness note (parametric roles)¶
fetchParametricAssignments filters by the active session role keys, not just temporally — otherwise a dropped department_head would keep widening DEPARTMENT-parameterized policies (and RolesGuard's dimension-overlap admission) from outside the session. This also tightened a latent widening for e.g. dept_head+referent referent-sessions.
Caveat for consumers¶
In a role view the JWT roles does not contain the profile key: a teacher-admin in admin view has no 'teacher' in the array. Code must not assume "employee session ⇒ profile key present"; branch on activeProfile (or identity) for profile-shaped concerns. Self-service /teachers/me + /staff/me are identity-gated and unaffected.
Key files¶
| File | Role |
|---|---|
src/common/utils/narrow-roles.ts |
resolveSessionRoles issuance rule |
src/common/i18n/role-labels.catalog.ts |
PROFILE_VIEW_LABELS (incl. platform) + resolveViewLabel (role keys resolve as on GET /roles) |
src/auth/utils/session-views.ts |
composeSessionViews (flat list: dedup + order + labels), composeLoginViews (+ platform entry), resolveViewSelection (pick → session pair) |
src/auth/dto/session-view.dto.ts, src/auth/dto/switch-view.dto.ts |
SessionViewDto, SwitchViewDto |
src/auth/auth.service.ts |
switchActiveView, rotateSessionTokens, resolveAvailableViews |
src/auth/auth.controller.ts |
POST /auth/switch-view |
src/permissions/permissions.service.ts |
getActiveRoleEntries, session-key-filtered getAccessContextSlice |
11. Password recovery¶
Password recovery is a public, three-endpoint flow under
/auth/password-reset:
request → QUEUED → SENDING → SENT
├─ provider/live gate failure → FAILED
├─ successful commit → USED
└─ sibling successfully commits → CANCELLED
POST /request { email }always returns 204. Active accounts in ACTIVE or TRIAL tenants are queued independently, including one email per tenant when the address is shared. A persisted per-user 60-second cooldown bounds repeat mail without changing the response.GET /verify?token=...is read-only and returns 204 while the token is live. Invalid, expired, used, cancelled, deactivated, email-changed, and suspended-tenant states all return the same 410PASSWORD_RESET_INVALID_OR_USED.POST /commit { token, password }atomically consumes the token, writes the Argon2id password, revokes all refresh tokens, and cancels sibling reset links. It returns 204, clears auth cookies, and does not log the user in.
Reset credentials are 32 random bytes encoded base64url. The durable sender
mints them only when it claims queued work, stores only SHA-256 plus a
30-minute expiry, and sends outside the database transaction. Claims and
completion updates are CAS/lease-fenced; a scheduled sweep owns crash recovery.
The public email request and token pre-read are sanctioned AdminPrismaService
uses because no tenant is known yet; all authoritative reads and every write
continue under withTenantGuc.
Already-issued access JWTs remain valid until their existing expiry (at most one hour). Refresh sessions are revoked immediately.
| File | Role |
|---|---|
src/auth/password-reset/password-reset.controller.ts |
Public request, verify, and commit contract |
src/auth/password-reset/password-reset.service.ts |
Anti-enumerating fan-out and atomic reset command |
src/auth/password-reset/password-reset-sender.service.ts |
Durable credential-email sweeper |
src/auth/password-reset/password-reset.queries.ts |
Public-flow data access and one-time commit CAS |
src/auth/password-reset/password-reset-sender.queries.ts |
Claim/completion lease fencing |
prisma/schema.prisma (PasswordResetRequest) |
Tenant-bearing credential/outbox state |