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 — the JWT identifies the tenant and active session roles, while guards compile scope/action grants from the permission model.
Key design choices:
- Password-first, up-to-3-step login (credentials → optional tenant selection → optional profile selection). The user supplies email + password; the system verifies one global LoginIdentity password, then finds its eligible school-local Users. 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=Nonesupports cross-site clients and requiresSecure. Browser clients must send credentials; production CORS and origin checks restrict trusted callers. 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:
- Canonicalize the email with trim/lowercase and verify one LoginIdentity hash; an absent identity performs a dummy verification.
- Discover its active Users in ACTIVE/TRIAL tenants, excluding requiresInvitationAcceptance and Users without a person binding or platform access.
- Carry the verified identity ID and credential version through each school/view selection and session issuance.
| Matches | Response |
|---|---|
| 0 | 401 — "Invalid credentials". An absent credential performs dummy password verification to reduce timing differences; eligibility failures use the same public error. |
| 1 | Issue a session for one view, otherwise return the view chooser. |
| 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) — 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:
{ "sub": "tenant-selection", "matchedUserIds": ["<uuid>", ...], "loginIdentityId": "<uuid>", "credentialVersion": 0 }
The server verifies the token, confirms the requested tenantId is in the pre-validated list, rechecks current credential version and User eligibility under the chosen tenant GUC, then issues a session or offers the view chooser.
Token Issuance (both paths)¶
AuthService.login():
1. Sign a JWT access token (positive whole-second JWT_EXPIRATION, at most 1h)
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>",
"loginIdentityId": "<identityId>",
"credentialVersion": 0,
"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 |
Cookie path /api/v1/auth, shared by refresh and session-switch routes |
Authorization: Bearer header |
— | Supported for non-browser / API clients |
Request Pipeline¶
A protected tenant resource uses the following pipeline. Auth/public routes can have explicit transaction boundaries; see multitenancy.
Request
-> JwtAuthGuard / JwtStrategy Verify the JWT and establish request identity
-> ScopeGuard Check declared scope access
-> ActionGuard Check operation capability
-> FieldWriteGuard Reject fields outside writable scopes
-> RolesGuard Enforce explicit or policy-derived admission
-> TenantTransactionInterceptor Set the tenant GUC in the handler transaction
-> Controller -> Service Apply row policies and domain rules
-> FieldFilterInterceptor Project authorized response scopes
-> Commit -> PostCommitCoordinator Drain deferred effects after commit
Platform admins (isPlatformAdmin: true) bypass ScopeGuard, ActionGuard, FieldWriteGuard, and FieldFilterInterceptor.
Token Lifetimes¶
| Token | TTL |
|---|---|
| Access token | Configured JWT_EXPIRATION: positive whole seconds, at most 1 hour |
| Refresh token | REFRESH_TOKEN_EXPIRATION_DAYS, default 7 days |
| Selection token | 10 minutes |
4. Refresh & Rotation¶
Access tokens expire according to JWT_EXPIRATION. 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: 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. The binding is kept (not detached), so reactivation needs no re-invitation. Invitation reset: POST /invitations/:id/reset retains the person binding. It sets isActive=false and requiresInvitationAcceptance=true on the still-bound User, revokes that school User's refresh sessions and deletes the invitation row; global password recovery remains independent. Un-archive/readmission restore access only while that flag is false; a fresh invitation acceptance clears it by UPDATING the same User (the proved identity, isActive=true, and its canonical email; an existing identity keeps its current password). Accept never INSERTs a second User for a bound person; an unbound person whose email another local User holds is refused at send (account checks). Deleting a person row ends, keeps or switches off its User according to the person's remaining rows (membership lifecycle). An email change on a bound person runs the same reset (admin PATCH and setup-import update alike; case-only edits excluded), so the login email is only ever changed by accepting from the new mailbox. Nobody can change their own login email: /me rejects contacts.institutionalEmail, and the admin route rejects the edit on the caller's own row, both with 403 LOGIN_EMAIL_SELF_CHANGE_FORBIDDEN.
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 depends on the configured proxy topology. Keep trust proxy aligned with the deployment.
6. Authentication boundaries¶
Keep token issuance, identity proof, password hashing, rotation, rate limits, and session narrowing in the authentication module. Consumers use its public contracts rather than constructing credential or role-selection flows. Stateless access expiry and database-backed refresh eligibility are distinct controls.
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, one profile per session. Sameuserpayload plusprofileType(the session'sactiveProfile;nullfor platform sessions),profile(Teacher/Staff/Student for the tenant's active academic year; Referent globally, including when no year is active), andcanWrite(scope → field/input → boolean; null exactly whenprofileis null). A missing matching row produces null. Role views such asadminalready anchor on the employee profile; no request parameter is needed.canWriteprojects the self-editable whitelist unioned with session WRITE scopes, masking login email, identification codes and file references. Teacheremployment.departmentIdsfollows employment WRITE; referentcustomFieldskeys are absent andhealthis empty. See ch04 § Self-service.EnsurePermissionsGuardloads permissions; there is no scope decorator or field filtering on this read. The response also carries top-levelacademicYearId.ProfileModuleowns the route to avoid theAuthModule → person modules → InvitationsModule → AuthModulecycle. Used by the profile page for loading; saves usePATCH /teachers/me,PATCH /staff/me, orPATCH /referents/:idwith the own id.- The own snapshot includes the
private_datagroup regardless of directory grants. Native fields there are writable for teacher/staff through the self-service allowlist, writable for referents through their WRITE grant, and read-only for students. EverycanWrite.private_datablock omitscustomFieldsbecause the input DTOs do not accept it.
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: identity check, one snapshot for the session's activeProfile, canWrite via src/profile/self-write-map.ts |
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¶
One canonical login email owns one credential. Each tenant User.profileType establishes one immutable local person type: Teacher, Staff, Referent or Student. Any pair is allowed across schools; mixed types on one membership are forbidden, including retained history. NULL represents an unclassified membership and grants no person access. First legitimate onboarding/provisioning declares or atomically claims the type in the same tenant transaction as attachment; previews never claim it. Established types survive archive/unlink/reset and identity/email rebind. Platform is a synthetic session view, not another person type. Deprecated LoginIdentity.profileType is neither read nor claimed by runtime credential logic.
LoginIdentity is auth-private; tenant Users retain their IDs, roles and histories. Composite User(identityId,email) FK and database binding triggers enforce the graph even for imports and direct SQL. Administrative role views remain school-local.
issueTokenPair validates the selected active User's established type, actual available person profile, current identity/version and local grants. School switching recomputes destination views and roles: Teacher A can enter Staff B without carrying A's profile or permissions. /auth/profile.profileType continues to describe the active session; the internal storage field is not added to public User CRUD DTOs. Teacher and parent/Referent access within the same school requires separate institutional/personal accounts and logout/login.
Login state machine¶
The login flow has up to three steps. Its final chooser offers views from the flat namespace in section 10: person profiles and held role keys, deduplicated, plus a platform view when available. Accounts with at least two views choose one before session issuance.
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 (compatibility field; render the labeled 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 }. For accounts with no person row: platform admins go straight to the platform session; anyone else still cannot log in (role views need a person profile to anchor on).
A school switch chooser uses a distinct school-switch-selection subject with the source refresh row ID/User/tenant; final choice consumes that source once. Ordinary selection 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.
Session payload¶
Use src/auth/interfaces/jwt-payload.interface.ts as the claim contract. activeProfile identifies the person profile; activeRole optionally narrows permissions to one held role. The credential proof contains loginIdentityId and credentialVersion.
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¶
RefreshToken.activeProfile stores the profile selected for the session. On refresh:
- Look up the stored
RefreshTokenrow. - If
row.activeProfileisNULL→ 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 always populate activeProfile, loginIdentityId and credentialVersion. Old or stale credential snapshots cannot refresh or issue sessions.
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.
Session context¶
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 positive lifetime of at most one hour. Deleting a profile or revoking refresh tokens does not invalidate an already-signed access JWT. Permission compilation and record policies still apply to each protected operation, so JWT acceptance does not guarantee continued access to the same scopes or rows. Refresh checks current profile eligibility. A person deletion that ends a membership deletes its refresh sessions, and one that switches it off revokes them; a membership kept for another open row keeps its sessions (membership lifecycle).
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.
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 selects a person class (teacher/staff, referent, or student). Within an employee session, activeRole can narrow the held roles to one view. Permission compilation and record policies use that effective view: a teacher who also holds admin can select Teacher and receive the teacher's authority and defaults.
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).
All four person-profile keys have preset roles. Employee onboarding binds the matching Teacher or Staff role. Tenant bootstrap administrators also receive an anchored admin assignment; provisioning requires the employee profile choice. Other schools' profile types do not constrain that choice. See backoffice onboarding.
Internally the session state is the pair (activeProfile, activeRole). Auth DTOs expose activeProfile and the presentation field activeView; clients do not supply the internal pair. The issuance rule everywhere is resolveSessionRoles: activeRole ? [activeRole] : narrowRolesForActiveProfile(allRoles, activeProfile). An actual profile switch resets activeRole to null; the idempotent same-profile branch preserves its existing view. 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¶
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). 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)¶
getAccessContextSlice keeps only assignments whose role key is in the active session, not just temporally active (an in-memory filter over the request's assignment load) — otherwise a dropped department_head would keep widening DEPARTMENT-parameterized policies (and RolesGuard's dimension-overlap admission) from outside the session. The same filter applies when compiling parameters for any narrowed session.
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¶
Recovery is global to the login identity. It neither activates a school membership nor clears invitation restrictions.
| Endpoint (prefix /api/v1) | Contract |
|---|---|
| POST /auth/password-reset/request | Generic empty 204. Queue at most one request per canonical identity within the persisted cooldown; unknown addresses disclose nothing. |
| GET /auth/password-reset/verify?token=... | Read-only empty 204 for a live token; otherwise generic 410 PASSWORD_RESET_INVALID_OR_USED. |
| POST /auth/password-reset/commit | Consume once, update one hash and increment its credential version, cancel sibling reset links, return empty 204 and clear cookies. Normal login follows. |
LoginPasswordResetRequest is an auth-private global outbox with email/version snapshots. Request, sender claims and commit lock the identity before reset rows. Raw tokens are minted only at claim, stored as SHA-256 hashes with expiry, and emailed outside transactions. Lease/version checks fence claim recovery and completion. Concurrent commits have one winner. Queued work remains recoverable after a process crash.
Mail uses password-reset-en / password-reset-it templates with the published variables recipientName, schoolName, resetUrl and expiresInMinutes. recipientName carries the canonical account email; the legacy schoolName key carries PASSWORD_RESET_PLATFORM_NAME (SIS by default). No school is selected for branding or language; configuration uses PASSWORD_RESET_PLATFORM_NAME and PASSWORD_RESET_DEFAULT_LANGUAGE. The configured transport requires published provider templates with these variable names.
Credential-version checks invalidate old refresh and selection proofs across schools. Existing access JWTs keep their signed expiry: JwtStrategy.validate does not query the identity version on every request. Do not describe password recovery or refresh revocation as immediate access-token revocation.
Canonical files: src/auth/identity/, src/auth/password-reset/, src/config/jwt-lifetime.ts.
12. Invitation credential purpose and school switching¶
GET /invitations/verify returns 200 { credentialPurpose: SET_PASSWORD | USE_EXISTING_PASSWORD } after live token, school eligibility and profile compatibility checks. POST /invitations/accept requires that discriminator and password. SET_PASSWORD creates an absent identity using the new-password policy; USE_EXISTING_PASSWORD proves the existing credential without modifying it. Signed-in users also provide the current password. A creation race returns 409 INVITATION_CREDENTIAL_MODE_CHANGED; reverify and change the form. Wrong existing password returns 401 INVALID_CREDENTIALS. Profile incompatibility returns generic 409 LOGIN_IDENTITY_PROFILE_CONFLICT without other-school/type details. Invalid/expired/consumed links share 410 INVITATION_INVALID_OR_USED.
Acceptance creates a typed tenant User or checks/claims the existing bound User and binds the verified identity in its tenant transaction. Reacceptance preserves local User/person IDs, names, established type and platform flags. LOGIN_IDENTITY_PROFILE_CONFLICT is a conflict in the inviting school only; types in other schools are irrelevant. Separate same-email people still produce local account collisions even with the same type. Global reset requests are not cancelled by local onboarding/archive/reset operations. Sendability and delivery recheck tenant-local compatibility without reserving a type during preflight.
GET /auth/tenants returns the current identity's eligible schools. POST /auth/switch-tenant requires access credentials plus the source refresh cookie (or explicit refreshToken body alternative). Choosing the current school is idempotent after current proof/eligibility checks. A real switch rechecks the destination and consumes the source refresh before creating a fresh target family. If a view chooser is necessary the source remains usable until the final choice; source rotation or expiry makes that chooser stale.
| Confirmed committed-operation error | Client action |
|---|---|
| 409 INVITATION_ACCEPTED_LOGIN_REQUIRED | Acceptance committed and invite consumed; cookies cleared. Use normal login, never retry that invite. |
| 409 AUTH_TENANT_SWITCH_LOGIN_REQUIRED | Source consumed but target issuance failed; cookies cleared. Use normal login, never retry the old source. |
Timeouts and lost responses have unknown outcomes and must not be presented as either confirmed case. Render localized errors from messages.en_US / messages.it_IT. The controller signatures, DTOs and Swagger declarations are the request/response contract.
Account checks on person writes¶
InvitationsService owns the read-only account decision for both persisted recipients and proposed creates. Generic Teacher/Staff/Student creates check the final normalized login email inside their transaction, before insertion. Direct new Referents, inline Student Referents and import inserts use the same rule; ordinary Referent email corrections check before resetting access. Teacher/Staff/Student PATCH applies the same local account protections. All lookups use the caller's tenant transaction.
The decision has two halves in src/invitations/invitations.queries.ts. The account half (classifyEmailHolder) reads the person's stored or carried userId and the local holder of the canonical email, the User at (tenantId, email). Rules apply top-down:
| State | Result |
|---|---|
The stored or carried userId does not resolve to a User in this tenant |
409 INVITATION_ACCOUNT_CONFLICT, reason: BROKEN_BINDING |
| No local holder, or the holder is the person's own bound User | Passes to the person-row half |
| The holder is bound to at least one person row, active or not, of any type | 409 LOGIN_EMAIL_IN_USE { field?, email, holderName, holderType } |
| The holder is bound to no person row (normally a platform admin) | 409 LOGIN_EMAIL_IN_USE, reason: ACCOUNT, { field?, email, holderName } |
A membership's login can lag its person's current email: an email change keeps the old login until the new address is accepted. The person-row half (classifyPersonEmailHolders over findPersonEmailHolders) therefore also reads other person rows, of every type and year, whose current email is the canonical address. The person's own rows never count: the row being written, the same person's rows in other years (same type and personUuid), and rows bound to the person's own membership. Another row holds the address when it:
| That row | Result |
|---|---|
Is bound to a membership (userId set), whatever its status or year |
409 LOGIN_EMAIL_IN_USE { field?, email, holderName, holderType } |
Has an open invitation (QUEUED or SENT) |
Same |
| Has the written row's type and academic year, a pair the per-year email unique index also forbids | Same |
| Matches none of these | Allowed |
Referents are school-flat and have no year: a Referent with the same email is reused or refused by the referent domain (REFERENT_EMAIL_IN_USE), so between Referents only the first two rows apply. Creates pass the year they write, and import inserts also pass the carried personUuid.
For an account holder, holderName is the holder's newest bound row's name, falling back to the User's name and then the email, and holderType is the User's profileType, else the bound row's type. For a person-row holder, they are that row's name (falling back to its email) and type. Both are disclosed to any caller who can write the person; the conflict stays inside the school. field is the body path of the login email on form writes (contacts.schoolEmail for Students, contacts.institutionalEmail for Teachers/Staff, contacts.email for Referents); imports, inline Student Referents and resend carry none. Single resend (POST /invitations/:id/resend) applies both halves.
The check performs no global LoginIdentity lookup, type claim, account attachment or invitation eligibility decision; another school's account cannot veto the write. The invitee side stays generic: GET /invitations/verify and POST /invitations/accept answer INVITATION_ACCOUNT_CONFLICT or LOGIN_IDENTITY_PROFILE_CONFLICT without naming anyone, and batch send reports ACCOUNT_CONFLICT per recipient.
Referent create-as-link, profile replacement and eligible explicit merges retain their existing associations. Optional missing login emails and canonical no-op corrections do not trigger a new check. Import inserts may pass only the User binding supplied by the existing prior-year identity resolver plus email agreement. Setup updates lock and reread the recipient; the acting admin's own email remains suppressed. Only missing import Referents are checked in a deduplicated batch.
Saves reserve nothing: two people of different types with neither an account nor an open invitation may still share an email. Invitation-time ownership checks remain authoritative when credentials are sent or accepted; batch send (POST /invitations/send) applies only the account half. A rejected write leaves person/account/invitation state intact; an import conflict rejects the whole transaction.
Membership lifecycle¶
A school User (membership) exists only while at least one person row (Student, Teacher, Staff or Referent, in any academic year and any status) is bound to it through userId. Platform admins are the exception. Bindings come only from invitation acceptance or a trusted prior-year import carry; there is no endpoint that deletes a User directly.
Every person-row delete releases its membership through InvitationsService, inside the delete transaction and before the row goes. Single DELETE and the setup-import sweep (both through deleteRecordWithinTx) and ReferentsService.remove run it per row under the per-person lock; the setup DEPARTMENTS/GRADES student deletes run it set-wise (releaseStudentsOnBulkDelete). The row's invitations of any status and the role assignments anchored on it are deleted in every case. The membership's outcome depends on its other bound rows (decideReleaseOutcome):
| Other bound rows | Membership |
|---|---|
| None | Ended: the User is hard-deleted, freeing its email |
At least one open (Student ENROLLED/PRE_ENROLLED, Teacher/Staff ACTIVE/ON_LEAVE) |
Kept as is, with its sessions and other invitations |
Only closed rows (Student LEFT/GRADUATED, Teacher/Staff ARCHIVED) |
Switched off, as an invitation reset does: isActive=false, requiresInvitationAcceptance=true, sessions revoked, its other pending invitations dropped; it keeps holding its email |
| Platform admin, whatever the rows | Sessions revoked; never ended or switched off |
Ending a membership cascades what it owns: sessions, password-reset requests, push registrations, notifications and their scheduled claims, saved reports and report runs, private mailing groups and role assignments. Attribution columns (audit actor, file uploader, next-step completion, curriculum-selection editor, role assigner) become NULL; any name snapshot stored beside them is kept. Communication.senderUserId and CurriculumSelectionWindow.openedByUserId carry no foreign key: they keep the ended id, and senderDisplayName remains the sender's display authority. The schema's onDelete actions are authoritative.
On the per-row paths, two gates run after the lock and before any write, both 409:
- SELF_DELETE_FORBIDDEN: the row is bound to the caller's own membership. The setup-import sweep instead silently skips the acting admin's row.
- LAST_ADMIN_REQUIRED (Teacher/Staff rows only): after removing the admin assignments anchored on the row, plus all of the membership's own when it ends or is switched off, no active admin assignment held by an active User would remain.
A referent merge deletes its source row the same way, but only an unbound source: the precondition is rechecked on the locked row, and a source that accepted an invitation meanwhile is refused with 409 REFERENT_EMAIL_IN_USE, so its release never touches an account. The backoffice tenant reset keeps its own wipe.