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 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=Strict cookies 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

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.

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 (60-second 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 (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:

{
  "sub": "<userId>",
  "tenantId": "<tenantId>",
  "roles": ["admin"],
  "isPlatformAdmin": false
}

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:

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.


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-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. 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, 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:

  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_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