40227-vm/backend/docs/auth-profile.md
2026-06-10 18:27:19 +02:00

9.0 KiB

Auth Profile

Purpose

The auth subsystem owns sign-in, signup, the current-user profile contract (GET /api/auth/me), password reset / email verification, OAuth (Google / Microsoft) sign-in, and the permission enforcement model. This document covers the profile and permission concerns; the HttpOnly cookie session transport (access/refresh tokens, rotation, CSRF/origin, sign-out) is documented in backend/docs/cookie-auth.md.

The profile response must not expose passwords, verification tokens, reset tokens, or raw Sequelize model objects.

Slice Files (by layer)

  • Route: src/routes/auth.ts (thin wiring; mounted at /api/auth in src/index.ts). /me, /password-update, /send-email-address-verification-email, and /profile are guarded by passport.authenticate('jwt', { session: false }).
  • Controller: src/api/controllers/auth.controller.ts (custom — not the CRUD factory).
  • Service (BLL): src/services/auth.ts (class Auth, default export AuthService) with DTO shapes in src/services/auth.types.ts.
  • Passport strategies: src/auth/auth.ts (JWT, Google, Microsoft).
  • Cookie helpers: src/auth/cookies.ts (used for the session transport; see cookie-auth.md).
  • Permission middleware: src/middlewares/check-permissions.ts (checkPermissions, checkCrudPermissions).
  • Origin middleware: src/middlewares/csrf-origin.ts (see cookie-auth.md).
  • Repositories (DAL): src/db/api/users.ts (UsersDBApi), src/db/api/auth_refresh_tokens.ts (AuthRefreshTokensDBApi), src/db/api/roles.ts (RolesDBApi, used by the permission middleware).
  • Models: src/db/models/users.ts, src/db/models/auth_refresh_tokens.ts, plus the roles, permissions, organizations, staff, and campuses models joined for the profile.
  • Shared used: shared/config (auth/cookie/OAuth config), shared/jwt (jwtSign), shared/constants/auth.ts, shared/constants/roles.ts (role/product-role mapping), shared/errors/* (ForbiddenError, ValidationError), services/email/* (verification / reset / invitation emails).

API

Base path /api/auth (mounted in src/index.ts).

Profile / account endpoints:

  • GET /api/auth/me (JWT-authenticated) -> 200 the current user profile payload (see Data Contract). Returns ForbiddenError when there is no current user.
  • POST /api/auth/signup -> 200 the current user profile. Body: { email, password, organizationId }. Creates a session and sets cookies.
  • PUT /api/auth/profile (JWT-authenticated) -> 200 true. Body: { profile } (passed to UsersDBApi.update).
  • PUT /api/auth/password-update (JWT-authenticated) -> 200 the updated user. Body: { currentPassword, newPassword }.
  • PUT /api/auth/password-reset -> 200 the updated user. Body: { token, password }.
  • PUT /api/auth/verify-email -> 200 result of marking the email verified. Body: { token }.
  • POST /api/auth/send-password-reset-email -> 200 true. Body: { email }. The reset link host is derived from the request referer.
  • POST /api/auth/send-email-address-verification-email (JWT-authenticated) -> 200 true. Sends a verification email to the current user's address.
  • GET /api/auth/email-configured -> 200 boolean (EmailSender.isConfigured).

Sign-in (covered in detail in cookie-auth.md):

  • POST /api/auth/signin/local -> 200 the current user profile.

OAuth endpoints:

  • GET /api/auth/signin/google -> redirects to Google (passport.authenticate('google', { scope: ['profile', 'email'], state })).
  • GET /api/auth/signin/google/callback (Passport google, failureRedirect: '/login') -> sets session cookies and redirects to config.uiUrl (no token query parameters).
  • GET /api/auth/signin/microsoft -> redirects to Microsoft (passport.authenticate('microsoft', { scope: ['https://graph.microsoft.com/user.read openid'], state })).
  • GET /api/auth/signin/microsoft/callback (Passport microsoft, failureRedirect: '/login') -> sets session cookies and redirects to config.uiUrl.

OAuth users are resolved by db.users.findOrCreate({ where: { email, provider } }) in src/auth/auth.ts; the Microsoft email comes from profile._json.mail or profile._json.userPrincipalName.

Access Rules

  • JWT routes are protected by passport.authenticate('jwt', { session: false }), which extracts the access token from the HttpOnly access cookie (cookies.extractAccessCookie) and loads the user by email. A disabled user is rejected by the strategy (done(new Error(...))).
  • Permission enforcement (src/middlewares/check-permissions.ts):
    • checkPermissions(permission) allows the request if any of:
      1. self-access bypass — currentUser.id equals req.params.id or req.body.id;
      2. the user's custom_permissions include permission;
      3. the effective role's permissions include permission. The effective role is the user's app_role, or the cached seeded Public role (SPECIAL_ROLE_NAMES.PUBLIC) when there is no assigned role. The Public role is fetched once at module load and cached.
    • A denied request is passed new ValidationError('auth.forbidden').
    • checkCrudPermissions(name) derives the permission as ${METHOD}_${ENTITY} from the HTTP method and entity name, where the method maps POST->CREATE, GET->READ, PUT->UPDATE, PATCH->UPDATE, DELETE->DELETE and ENTITY is name.toUpperCase(). It then delegates to checkPermissions. This middleware is applied per generic-CRUD router via router.use(permissions.checkCrudPermissions(permission)) in src/api/http/crud-router.ts; the auth routes themselves do not use it.

Tenant Scope

  • The profile is loaded for the authenticated user only (UsersDBApi.findProfileById(currentUser.id)), so it reflects that user's own organization, role, staff profile, and campus.
  • signup accepts an organizationId and assigns it to the created user.
  • Tenant filtering for other entities is enforced elsewhere (CRUD repositories scope by currentUser.organizationId); the auth profile endpoints do not add tenant filtering beyond loading the current user.

Data Contract

AuthService.currentUserProfile returns (built from findProfileById):

  • id, email, firstName, lastName
  • organizationId
  • organizationsOrganizationDto { id, name } or null
  • app_roleRoleDto { id, name, globalAccess } or null
  • productRole — a PRODUCT_ROLE_VALUES value (teacher | para | office | director | superintendent)
  • staffProfileStaffProfileDto { id, employee_number, job_title, staff_type, status, organizationId, campusId, userId } or null (first row of staff_user)
  • campusCampusDto { id, name, code } or null
  • campusId — the campus DTO id, else the staff profile campusId, else null
  • permissions — de-duplicated string names from the role's permissions plus the user's custom_permissions

Note: the profile payload does not include a phoneNumber field (findProfileById does not select it and currentUserProfile does not return it).

productRole resolution order (getProductRole): generated backend role name via GENERATED_ROLE_TO_PRODUCT_ROLE, then staff type via STAFF_TYPE_TO_PRODUCT_ROLE, else PRODUCT_ROLE_VALUES.TEACHER. Mappings live in src/shared/constants/roles.ts.

Signup / signin behavior (src/services/auth.ts):

  • signin throws ValidationError for auth.userNotFound, auth.userDisabled, auth.wrongPassword, or auth.userNotVerified. When email is not configured (EmailSender.isConfigured is false), emailVerified is treated as true.
  • signup rehashes the password with bcrypt (config.bcrypt.saltRounds). An existing disabled user raises auth.userDisabled; an existing enabled user has its password updated; a new user is created via UsersDBApi.createFromAuth (first name defaults to the email local-part, default role from config.roles.user). A verification email is sent when email is configured.
  • passwordUpdate requires a logged-in user, verifies the current password, rejects reuse of the same password (auth.passwordUpdate.samePassword), and stores the new bcrypt hash.
  • passwordReset / verifyEmail look up the user by a non-expired passwordResetToken / emailVerificationToken and raise the matching ValidationError on an invalid token. Tokens are random hex of EMAIL_ACTION_TOKEN_BYTES with TTL EMAIL_ACTION_TOKEN_TTL_MS.

Behavior / Notes

  • Profile loads use a single trimmed eager query (UsersDBApi.findProfileById) selecting only the columns and relations the DTO reads.
  • Auth tokens are never returned in response bodies or redirect URLs; OAuth callbacks redirect to config.uiUrl with cookies only.
  • updateProfile runs inside a Sequelize transaction.

Tests

None yet (no auth unit/e2e test under backend/src).

  • Cookie session transport: backend/docs/cookie-auth.md.
  • Frontend: frontend/docs/auth-integration.md.
  • Role / product-role constants: src/shared/constants/roles.ts.