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

7.4 KiB

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.

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).

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