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 two-step login. 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.
- HttpOnly cookie delivery. Tokens are sent as
HttpOnly; Secure; SameSite=Strictcookies so JavaScript cannot read them. 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.
Step 2 — Tenant Selection (multi-tenant accounts only)¶
The selectionToken is a short-lived JWT (60-second 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 (15 min TTL)
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:
The tenantId embedded in the token is the single source of truth for tenant context on subsequent requests — no Host header dependency.
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 | 15 minutes |
| Refresh token | 7 days |
| Selection token | 60 seconds |
4. Refresh & Rotation¶
Access tokens expire after 15 minutes. 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.
5. Rate Limiting¶
Rate limiting is applied via @nestjs/throttler:
| Scope | Limit |
|---|---|
| Global (all routes) | 10 requests / 60 seconds |
Login (POST /auth/login) |
5 requests / 60 seconds |
Tenant selection (POST /auth/login/select-tenant) |
5 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. 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, 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 |
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:
POST /auth/login {email, password}
├─ 0 matches → 401 INVALID_CREDENTIALS
├─ 1 tenant + 1 profile → 200 AuthUserDto (cookies set — unchanged path)
├─ 1 tenant + 2 profiles → 200 ProfileSelectionResponseDto (NEW)
└─ 2+ tenants → 200 TenantSelectionResponseDto (unchanged)
POST /auth/login/select-tenant {selectionToken, tenantId}
├─ 1 profile in chosen tenant → 200 AuthUserDto (unchanged)
└─ 2 profiles in chosen tenant → 200 ProfileSelectionResponseDto (NEW)
POST /auth/login/select-profile {selectionToken, activeProfile}
├─ valid → 200 AuthUserDto (cookies set)
└─ invalid → 400 ACTIVE_PROFILE_NOT_AVAILABLE | token errors
Each step returns either a terminal session (AuthUserDto) or a selection prompt carrying a fresh 60-second selection token. The frontend treats the response shape as the next state — if requiresProfileSelection: true is present, it renders a 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 } |
AuthUserDto |
— |
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: PersonProfileKey; // NEW
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_PROFILE_NOT_AVAILABLE |
400 | select-profile or switch-profile with a profile the user doesn't have |
ACTIVE_PROFILE_NOT_AVAILABLE |
401 | 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: PersonProfileKey. 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 15-minute 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 profile picker; POST /auth/login/select-profile with {selectionToken, activeProfile}. |
Switching profiles mid-session uses POST /auth/switch-profile with {activeProfile} and consumes/issues new cookies. GET /auth/me returns availableProfiles: PersonProfileKey[] 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 pure helper |
src/auth/dto/profile-selection-response.dto.ts |
ProfileSelectionResponseDto (mirrors TenantSelectionResponseDto) |
src/auth/dto/select-profile.dto.ts |
{ selectionToken, activeProfile } |
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 |