Skip to content

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=None cookies so JavaScript cannot read them. SameSite=None is deliberate — the FE and BE live on different domains in every environment, and None requires Secure (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

POST /api/v1/auth/login
{ "email": "admin@demo-school.dev", "password": "changeme123" }

AuthService.validateCredentials() runs cross-tenant:

  1. Find all active User records matching the email across all active tenants.
  2. Verify the supplied password against each match using argon2.verify() — all checks run in parallel to keep latency flat regardless of tenant count.
  3. 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)

POST /api/v1/auth/login/select-tenant
{ "selectionToken": "<jwt>", "tenantId": "<uuid>" }

The selectionToken is a short-lived JWT (10-minute TTL) signed by the server, containing:

{ "sub": "tenant-selection", "matchedUserIds": ["<uuid>", ...] }

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:

POST /api/v1/auth/refresh
{ "refreshToken": "<token>" }

Server-side refresh logic:

  1. Hash lookup — SHA-256 hash of the supplied token is looked up in the database.
  2. 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.
  3. Tenant + user validation — confirm the tenant is still ACTIVE or TRIAL, and the user is still isActive.
  4. IP change logging — if the request IP differs from the IP that last used this token, a warning is logged (not blocked, but auditable).
  5. 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-derived tenantId / roles[] / isPlatformAdmin and the access-token expiry. Owned by AuthModule. Used by the navbar / session-validity probe.
  • GET /auth/profile — full. Same user payload plus the caller's Teacher / Staff / Student / Referent rows joined via userId. Year-snapshotted tables (Teacher/Staff/Student) resolve to the tenant's active academic year via AcademicYearsService.getActiveYear; Referent is global. Each profiles.* field is null when no row exists. The response also carries a top-level academicYearId — the active academic year the year-snapshotted profiles resolved against, or null when no year is active. Self-service: no FieldFilterInterceptor — the user always reads their own data in full. Owned by ProfileModule (src/profile/), not AuthModule, to break a AuthModule → {Teachers,Staff,Referents}Module → InvitationsModule → AuthModule import cycle. The route still sits at /auth/profile because 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:

  1. Look up the stored RefreshToken row.
  2. If row.activeProfile is NULL (legacy row predating this change) → 401 ACTIVE_PROFILE_NOT_AVAILABLE — forces re-login.
  3. If row.activeProfile is no longer in the user's fresh profile list (profile deleted mid-session) → 401 ACTIVE_PROFILE_NOT_AVAILABLE.
  4. Otherwise, carry activeProfile forward into the new access token's narrowed roles and the new RefreshToken row.

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 are LocalizedMessageDtos resolved by resolveViewLabel: role keys use the same catalog as GET /roles; the four profile keys use the sibling PROFILE_VIEW_LABELS catalog (kept out of ROLE_LABELS, whose key set is drift-spec-pinned to the management presets). Held profiles first (in PERSON_PROFILE_KEYS order), remaining role keys alphabetical.
  • activeView: string | nullnull = the combined default session. Referent/student sessions report their profile key (they are inherently single-view). Present on every auth response; availableViews only 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]; activeProfile flips to the caller's employee profile when one exists.
  • teacher / staff → same rule (the profile-coupled key doubles as the role): an explicit teacher pick drops dept-head and every sibling grant.
  • referent / student → the existing profile-switch semantics (sentinel narrowing).
  • null → back to the combined default (narrowRolesForActiveProfile of 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
  1. 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.
  2. 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 410 PASSWORD_RESET_INVALID_OR_USED.
  3. 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