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

158 lines
7.4 KiB
Markdown

# Cookie Auth
## Purpose
Browser authentication uses backend-owned HttpOnly cookies: a short-lived
access cookie carrying a signed JWT, and a long-lived opaque refresh cookie
backed by hashed, rotating refresh-token rows. The product frontend never
reads, stores, or sends auth tokens manually. This document covers the cookie
session transport, refresh rotation, CSRF/origin protection, and sign-out; the
profile and permission model is documented in `backend/docs/auth-profile.md`.
## Slice Files (by layer)
- Route: `src/routes/auth.ts` (mounted at `/api/auth` in `src/index.ts`).
`/signin/local`, `/refresh`, and `/signout` are unauthenticated routes that
read/set/clear cookies; `/me` is JWT-authenticated.
- Controller: `src/api/controllers/auth.controller.ts` — sets cookies via
`cookies.setSessionCookies`, reads the refresh cookie via
`cookies.extractRefreshCookie`, clears via `cookies.clearSessionCookies`.
- Service (BLL): `src/services/auth.ts` (`createSession`, `refreshSession`,
`revokeSession`) with `SessionOptions` in `src/services/auth.types.ts`.
- Cookie helpers: `src/auth/cookies.ts`.
- Passport JWT strategy: `src/auth/auth.ts` (reads the access cookie via
`cookies.extractAccessCookie`).
- Origin middleware: `src/middlewares/csrf-origin.ts`.
- Repositories (DAL): `src/db/api/auth_refresh_tokens.ts`
(`AuthRefreshTokensDBApi`), `src/db/api/users.ts` (`UsersDBApi.findBy`).
- Model: `src/db/models/auth_refresh_tokens.ts`.
- Shared used: `shared/config` (cookie/CORS/token config), `shared/jwt`
(`jwtSign`), `shared/constants/auth.ts` (cookie names, TTLs, token bytes,
hash algorithm, unsafe methods), `shared/errors/forbidden.ts`.
## API
Base path `/api/auth`.
- `POST /api/auth/signin/local` -> `200` the current user profile. Validates
credentials (`AuthService.signin`), creates a session, sets the access and
refresh HttpOnly cookies.
- `POST /api/auth/refresh` -> `200` the current user profile. Reads the refresh
cookie, rotates it (`AuthService.refreshSession`), sets fresh cookies. Returns
`ForbiddenError` when the refresh cookie is missing/invalid/expired/revoked.
- `POST /api/auth/signout` -> `204 No Content`. Revokes the active refresh token
(`AuthService.revokeSession`) and clears both cookies. Missing/already-revoked
tokens are a no-op (still clears cookies and returns `204`).
- `GET /api/auth/me` (JWT-authenticated) -> `200` the current user profile,
authenticated from the access cookie.
- OAuth callbacks (`/signin/google/callback`, `/signin/microsoft/callback`) set
session cookies and redirect to `config.uiUrl` without token query parameters.
## Access Rules
- Protected API routes authenticate through the access cookie. The Passport JWT
strategy (`src/auth/auth.ts`) extracts the token with
`cookies.extractAccessCookie`, verifies it with `config.secret_key`, loads the
user by email, and rejects disabled users.
- The JWT payload is `{ user: { id, email } }`, signed by `jwtSign`.
- `/refresh` and `/signout` are not Passport-guarded; they authenticate solely
from the refresh cookie value.
## CSRF / Origin Protection
- `src/middlewares/csrf-origin.ts` (`csrfOrigin`) is applied to all `/api`
routes via `app.use('/api', csrfOrigin)` in `src/index.ts`.
- Safe methods pass through; only unsafe methods are checked
(`UNSAFE_HTTP_METHODS` = `POST`, `PUT`, `PATCH`, `DELETE`).
- The source origin is taken from the `Origin` header, falling back to the
`Referer` header (parsed to its origin).
- The request is allowed when `config.auth.allowAllOrigins` is true and a source
origin is present, or when the source origin is in
`config.auth.allowedOrigins`. Otherwise it is rejected with `ForbiddenError`.
- `allowAllOrigins` is enabled only in the `dev_stage` (production-like,
non-production) environment when `ALLOWED_ORIGINS` is not set; strict
production requires an explicit allow-list.
## Refresh Tokens
Access/refresh rotation (`src/services/auth.ts` + `auth_refresh_tokens`):
- `createSession` mints a signed access JWT and an opaque refresh token
(`crypto.randomBytes(config.auth.refreshTokenBytes).toString('base64url')`),
stores the SHA-256 hash (`config.auth.refreshTokenHashAlgorithm`) in
`auth_refresh_tokens` with `userId`, `organizationId`, `familyId`,
`previousTokenId`, `userAgent`, `ipAddress`, and `expiresAt`
(`now + config.auth.refreshTokenMaxAgeMs`). Only the hash is persisted.
- `refreshSession` runs in a transaction: looks up the token by hash, then
rejects with `ForbiddenError` and:
- revokes the whole family (`revokeFamily`) when the token is already revoked
(reuse detection);
- revokes the single token when it is expired;
- revokes the family when the user is missing or disabled.
On success it creates a new session in the same `familyId` (setting
`previousTokenId`) and marks the old token revoked with `replacedByTokenId`
pointing at the new row (rotation).
- `revokeSession` looks up the token by hash and revokes it
(`replacedByTokenId` null); missing or already-revoked tokens are a no-op.
## Cookie Behavior
`src/auth/cookies.ts`:
- Access cookie name: `config.auth.accessCookieName`
(env `AUTH_ACCESS_COOKIE_NAME`, default `AUTH_COOKIE_NAME` =
`school_chain_session`). Max-age `config.auth.accessTokenMaxAgeMs`
(env `AUTH_COOKIE_MAX_AGE_MS`, default `JWT_EXPIRES_IN_MS` = 15 minutes).
- Refresh cookie name: `config.auth.refreshCookieName`
(env `AUTH_REFRESH_COOKIE_NAME`, default `AUTH_REFRESH_COOKIE_NAME` =
`school_chain_refresh`). Max-age `config.auth.refreshTokenMaxAgeMs`
(env `AUTH_REFRESH_TOKEN_MAX_AGE_MS`, default `REFRESH_TOKEN_EXPIRES_IN_MS` =
14 days).
- Both cookies are set with `httpOnly: true`, `path` = `config.auth.cookiePath`
(`/`), `sameSite` = `config.auth.cookieSameSite`, `secure` =
`config.auth.cookieSecure`, and `domain` = `config.auth.cookieDomain` when
configured.
- `clearSessionCookies` clears both cookies using the same path/domain.
- Cookies are read by manually parsing the `Cookie` header
(`extractCookie` / `extractAccessCookie` / `extractRefreshCookie`).
## Configuration
`src/shared/config/index.ts` (`config.auth`), from constants in
`src/shared/constants/auth.ts` and environment values:
- `ALLOWED_ORIGINS` -> `allowedOrigins`
- `AUTH_ACCESS_COOKIE_NAME` -> `accessCookieName`
- `AUTH_REFRESH_COOKIE_NAME` -> `refreshCookieName`
- `AUTH_COOKIE_SAME_SITE` -> `cookieSameSite` (default `lax`)
- `AUTH_COOKIE_SECURE` -> `cookieSecure` (default: true in production-like envs)
- `AUTH_COOKIE_MAX_AGE_MS` -> `accessTokenMaxAgeMs`
- `AUTH_REFRESH_TOKEN_MAX_AGE_MS` -> `refreshTokenMaxAgeMs`
- `AUTH_COOKIE_DOMAIN` -> `cookieDomain`
Validation in config: `AUTH_COOKIE_SECURE` must be true when
`AUTH_COOKIE_SAME_SITE` is `none` and in any production-like environment, and
`ALLOWED_ORIGINS` must be set in production.
Secrets remain in environment variables only, especially `SECRET_KEY` and
OAuth/email credentials.
## Security Rules
- Auth tokens must not be returned in response bodies.
- Auth tokens must not be placed in redirect URLs.
- Protected browser API routes authenticate through the HttpOnly access cookie.
- Only the refresh-token hash is stored in the database.
- Unsafe methods are protected by Origin/Referer validation against the
configured allow-list.
- Refresh-token reuse triggers family revocation.
## Tests
None yet (no auth unit/e2e test under `backend/src`).
## Related
- Profile and permission model: `backend/docs/auth-profile.md`.
- Frontend: `frontend/docs/auth-integration.md`.