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

184 lines
9.0 KiB
Markdown

# 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`
- `organizations``OrganizationDto` `{ id, name }` or `null`
- `app_role``RoleDto` `{ id, name, globalAccess }` or `null`
- `productRole` — a `PRODUCT_ROLE_VALUES` value
(`teacher` | `para` | `office` | `director` | `superintendent`)
- `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).
`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`).
## Related
- Cookie session transport: `backend/docs/cookie-auth.md`.
- Frontend: `frontend/docs/auth-integration.md`.
- Role / product-role constants: `src/shared/constants/roles.ts`.