# 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) 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 via `passport-jwt`; Google via the maintained, typed **`passport-google-oauth20`**). OAuth is wired for future use and is not surfaced in the current UI. - 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 definitions, scopes, names), `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). OAuth users are resolved by `db.users.findOrCreate({ where: { email, provider } })` in `src/auth/auth.ts`; the Google email comes from the typed `profile.emails[0].value`. ## 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. global-access bypass — the user's `app_role.globalAccess` is `true` (the system-scope roles `super_admin` / `system_admin`), which pass any permission; 3. the user's `custom_permissions` include `permission`; 4. the effective role's permissions include `permission`. The effective role is the user's `app_role`, or the cached seeded `guest` role (`ROLE_NAMES.GUEST`) when there is no assigned role. The `guest` 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`, `name_prefix` (honorific title — `mr`/`ms`/`mrs`/`mx`/`dr`/`prof`, or `null`), `firstName`, `lastName` - `organizationId` - `organizations` — `OrganizationDto` `{ id, name }` or `null` - `app_role` — `RoleDto` `{ id, name, scope, globalAccess }` or `null`. `name` is one of the 11 first-class role names and `scope` is its scope (`system` | `organization` | `campus` | `external` | `guest`); the frontend derives the UI role from `app_role.name`. There is no separate `productRole`. - `staffProfile` — `StaffProfileDto` `{ id, employee_number, job_title, staff_type, status, organizationId, campusId, userId }` or `null` (first row of `staff_user`) - `campus` — `CampusDto` `{ 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). Role model: roles are first-class persisted rows (`src/shared/constants/roles.ts` `ROLE_DEFINITIONS` / `ROLE_NAMES`, seeded by `db/seeders/20200430130760-user-roles.ts`). Each role has a `scope` and, for the two system roles, `globalAccess: true`. The preset permission matrix grants `owner` / `superintendent` / `director` every permission, `office_manager` / `teacher` / `support_staff` read-only entity permissions, and `student` / `guardian` / `guest` none; `super_admin` / `system_admin` need no rows (they bypass via `globalAccess`). Per-user `custom_permissions` extend a user's grants. 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`). ## Related - Cookie session transport: `backend/docs/cookie-auth.md`. - Frontend: `frontend/docs/auth-integration.md`. - Role constants (definitions, scopes, names): `src/shared/constants/roles.ts`.