# 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`.

The interactive OpenAPI spec (`/api-docs`) documents the cookie model under a
`cookieAuth` security scheme (the HttpOnly access cookie) and the full auth
surface (`@openapi` JSDoc in `src/routes/auth.ts`); the `UserProfile` response
schema is shared with `/me`, sign-in, and refresh.

## 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.
- The OAuth callback (`/signin/google/callback`) sets session cookies and
  redirects 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.

## Operational maintenance (refresh-token retention)

Refresh-token rows are persistent (the table is **not** paranoid) and rotation
revokes-and-replaces rather than deleting, so expired/revoked rows accumulate.
A maintenance command physically deletes rows that expired before a retention
grace window:

```bash
npm run db:cleanup-tokens          # dev (tsx)
node dist/db/cleanup-refresh-tokens.js   # prod (built; or npm run db:cleanup-tokens:prod)
```

- **Retention window:** `AUTH_REFRESH_TOKEN_RETENTION_MS` (default 7 days). The
  command deletes rows whose `expiresAt` is older than `now - retentionMs`.
- **Why it is safe:** a row past `expiresAt` can no longer be presented (its
  cookie is expired) and is no longer needed for reuse-detection, so deleting it
  — revoked or not — does not affect valid sessions. The grace window keeps
  recently-expired rows for short-term forensics.
- **Scheduling:** run it from cron or the platform scheduler (e.g. daily). It is
  idempotent, logs an observable summary line (`[refresh-token-maintenance] …`),
  and exits non-zero on failure.
- Logic lives in `services/refresh-token-maintenance.ts`
  (`cleanupExpiredRefreshTokens` + the pure `computeRefreshTokenCutoff`), backed
  by `AuthRefreshTokensDBApi.deleteExpiredBefore`.

## Tests

- `services/refresh-token-maintenance.test.ts` — the pure retention cutoff
  (`computeRefreshTokenCutoff`): window subtraction, non-positive/invalid window
  → cutoff = now, and cutoff ≤ now.

## Related

- Profile and permission model: `backend/docs/auth-profile.md`.
- Frontend: `frontend/docs/auth-integration.md`.
