Local user authentication: register, login, logout, sessions (#11) #38

Merged
james merged 1 commit from 11-local-user-auth into main 2026-06-14 00:05:51 +00:00
Owner

Closes #11.

Substantial PR — the bits all have to land together for the acceptance criteria to even be testable end-to-end (register depends on sessions depends on the middleware lookup, etc.). Full reasoning for the load-bearing decisions lives in ADR-0004 (server-side sessions over JWT) and in code comments.

Surface added

Endpoint Behaviour
POST /api/auth/register 201 + user DTO + session cookie. First registered user is admin. 409 on dup email.
POST /api/auth/login 200 + user DTO + session cookie. 401 invalid_credentials for both wrong password AND unknown email (no enumeration). 429 once rate limit trips.
POST /api/auth/logout 200, revokes session row + clears cookie. Idempotent.
GET /api/auth/me 200 + user DTO if authenticated; 401 otherwise. Does its own session check as defence in depth.

Key decisions

  • Password hashing: argon2id via @node-rs/argon2 (m=19MiB, t=2, p=1 — OWASP profile). napi-rs prebuilts, so no node-gyp pain in the container.
  • Sessions: 256-bit CSPRNG cookie value (hex), 30-day TTL, stored in a sessions table. Cookie is opaque; the row is the source of truth. ADR-0004 explains why DB-backed over JWT for Carol's deployment shape.
  • Cookie attrs: HttpOnly, SameSite=Lax, Secure in prod, Path=/.
  • Registration policy: REGISTRATION_POLICY env var (default open). Only open is wired in this PR; invite and admin-approval return 503 policy_not_implemented so a misconfigured deployment fails closed, not open.
  • First user is admin (is_admin = 1). TOCTOU window between count() and create() accepted at this scale; documented inline.
  • Rate limit: 5 failed logins per (email, ip) per 15 min → 429. Keyed by (email, ip) so an attacker swapping IPs can't lock out the real user from their own address.
  • Identity model: users + local_identities (1:1). A future oauth_identities table (#12) will sit alongside; both FK back to users so one account owns both identity types.
  • Middleware: getSession() (lib/auth/session.ts) now does a real DB lookup, replacing #10's cookie-presence stub. Public allowlist unchanged.

Tests (80 total — 65 SQLite + 15 Postgres, all passing)

  • tests/auth/password.test.ts — argon2 round-trip, salt freshness, malformed-hash safety. No DB.
  • tests/auth/services.test.tsdual-engine via describePerEngine: register, login success / wrong-password / unknown-email, rate-limit kicks in, rate-limit is (email, ip)-keyed, session validity / expiry / revocation, isolation between two users, registration policy 503.
  • tests/auth/api.test.ts — single-engine SQLite HTTP-layer tests: status codes, JSON shape, Set-Cookie attributes, the "no plaintext password ever in a response" guard.
  • tests/middleware.test.ts — updated to mock @/lib/auth/session so it remains a unit test instead of needing a DB.

End-to-end against the production node .next/standalone/server.js matches the test matrix (register → me → logout → me-returns-401, plus rate-limit and password-rejection paths).

Test plan

  • npm run lint / npm run typecheck / npm run build — all green
  • npm test (SQLite only) — 60 pass, 20 Postgres skip
  • npm test with Postgres 16 via podman — 80/80 pass (parity confirmed)
  • Live-server smoke flow exercising register → me → logout → second-user → wrong-password
  • CI matrix (will run on PR open)
Closes #11. Substantial PR — the bits all have to land together for the acceptance criteria to even be testable end-to-end (register depends on sessions depends on the middleware lookup, etc.). Full reasoning for the load-bearing decisions lives in **[ADR-0004](docs/adr/0004-server-side-sessions.md)** (server-side sessions over JWT) and in code comments. ## Surface added | Endpoint | Behaviour | | ----------------------- | -------------------------------------------------------------------------- | | `POST /api/auth/register` | 201 + user DTO + session cookie. First registered user is admin. 409 on dup email. | | `POST /api/auth/login` | 200 + user DTO + session cookie. 401 invalid_credentials for both wrong password AND unknown email (no enumeration). 429 once rate limit trips. | | `POST /api/auth/logout` | 200, revokes session row + clears cookie. Idempotent. | | `GET /api/auth/me` | 200 + user DTO if authenticated; 401 otherwise. Does its own session check as defence in depth. | ## Key decisions - **Password hashing**: argon2id via `@node-rs/argon2` (m=19MiB, t=2, p=1 — OWASP profile). napi-rs prebuilts, so no `node-gyp` pain in the container. - **Sessions**: 256-bit CSPRNG cookie value (hex), 30-day TTL, stored in a `sessions` table. Cookie is opaque; the row is the source of truth. ADR-0004 explains why DB-backed over JWT for Carol's deployment shape. - **Cookie attrs**: `HttpOnly`, `SameSite=Lax`, `Secure` in prod, `Path=/`. - **Registration policy**: `REGISTRATION_POLICY` env var (default `open`). Only `open` is wired in this PR; `invite` and `admin-approval` return **503** `policy_not_implemented` so a misconfigured deployment fails closed, not open. - **First user is admin** (`is_admin = 1`). TOCTOU window between `count()` and `create()` accepted at this scale; documented inline. - **Rate limit**: 5 failed logins per (`email`, `ip`) per 15 min → 429. Keyed by `(email, ip)` so an attacker swapping IPs can't lock out the real user from their own address. - **Identity model**: `users` + `local_identities` (1:1). A future `oauth_identities` table (#12) will sit alongside; both FK back to `users` so one account owns both identity types. - **Middleware**: `getSession()` (`lib/auth/session.ts`) now does a real DB lookup, replacing #10's cookie-presence stub. Public allowlist unchanged. ## Tests (80 total — 65 SQLite + 15 Postgres, all passing) - `tests/auth/password.test.ts` — argon2 round-trip, salt freshness, malformed-hash safety. No DB. - `tests/auth/services.test.ts` — **dual-engine** via `describePerEngine`: register, login success / wrong-password / unknown-email, rate-limit kicks in, rate-limit is `(email, ip)`-keyed, session validity / expiry / revocation, isolation between two users, registration policy 503. - `tests/auth/api.test.ts` — single-engine SQLite HTTP-layer tests: status codes, JSON shape, `Set-Cookie` attributes, the **"no plaintext password ever in a response"** guard. - `tests/middleware.test.ts` — updated to mock `@/lib/auth/session` so it remains a unit test instead of needing a DB. End-to-end against the production `node .next/standalone/server.js` matches the test matrix (register → me → logout → me-returns-401, plus rate-limit and password-rejection paths). ## Test plan - [x] `npm run lint` / `npm run typecheck` / `npm run build` — all green - [x] `npm test` (SQLite only) — 60 pass, 20 Postgres skip - [x] `npm test` with Postgres 16 via podman — **80/80 pass** (parity confirmed) - [x] Live-server smoke flow exercising register → me → logout → second-user → wrong-password - [ ] CI matrix (will run on PR open)
Local user authentication: register, login, logout, sessions (#11)
All checks were successful
PR / Typecheck (pull_request) Successful in 31s
PR / Lint (pull_request) Successful in 33s
PR / Test (postgres) (pull_request) Successful in 37s
PR / Test (sqlite) (pull_request) Successful in 37s
PR / Build (pull_request) Successful in 52s
09db4d93cd
Email + password identity backed by argon2id, server-side opaque
sessions stored in a sessions table, and per-(email, ip) rate
limiting on the login path. Every PR-tracked acceptance criterion
landed and is covered by both unit and integration tests on both DB
engines.

Stack and design choices, summarised — full reasoning lives in
ADR-0004 (server-side sessions over JWT) and in code comments:

- Password hashing: argon2id via @node-rs/argon2 (m=19 MiB, t=2,
  p=1, OWASP "second recommended" profile). @node-rs/argon2 ships
  napi-rs prebuilts so it works on Debian-slim without the
  node-gyp pain we saw with better-sqlite3 on alpine.
- Sessions: 256-bit (32-byte) CSPRNG cookie value, DB-backed,
  30-day TTL, cleaned up on expired-read. ADR-0004 explains why
  DB sessions over JWT for Carol's deployment shape.
- Cookie: HttpOnly, SameSite=Lax, Secure in production,
  Path=/. Set with the session's expiry.
- Registration policy: REGISTRATION_POLICY env var, default
  "open". Only `open` is wired in this PR; `invite` and
  `admin-approval` return 503 policy_not_implemented so a
  misconfigured deployment fails closed instead of silently
  falling back to open.
- First registered user is the instance admin (is_admin=1). TOCTOU
  window between count() and create() is acceptable for a
  self-hosted single-instance app; an explicit serialization
  isn't worth the cost until the admin role grants meaningful
  power. Documented inline.
- Rate limit: 5 failed attempts per (email, ip) per 15-minute
  sliding window → 429. Keyed by (email, ip) so an attacker
  swapping IPs doesn't lock out the real user (the real user from
  their own IP still gets fresh attempts).
- Identity model: users + local_identities (1:1). local_identities
  carries the email + password hash. A future oauth_identities
  table (#12) will sit alongside; both FK back to users so one
  account can own both identity types.
- Middleware: getSession() (lib/auth/session.ts) now does a real
  DB lookup, replacing the cookie-presence stub from #10. Public
  routes — /api/health and the /api/auth/* prefix for register/
  login/logout/me — still pass through without a session check.

API surface added:
- POST /api/auth/register → 201 + user + session cookie
- POST /api/auth/login    → 200 + user + session cookie (or 401 / 429)
- POST /api/auth/logout   → 200 + cleared cookie (idempotent)
- GET  /api/auth/me       → 200 + user (or 401)

Tests (80 total, 65 SQLite + 15 Postgres):
- tests/auth/password.test.ts — unit, no DB
- tests/auth/services.test.ts — dual-engine via describePerEngine,
  covers register, login success/failure, rate-limit (incl.
  per-IP key), session validity / expiry / revocation, isolation
  between two users, and that hashes are never echoed
- tests/auth/api.test.ts — single-engine SQLite, HTTP-layer
  wiring: status codes, JSON shape, Set-Cookie attributes, the
  "no plaintext password ever in a response" guard
- tests/middleware.test.ts — now mocks @/lib/auth/session so it
  remains a unit test instead of pulling in a DB

End-to-end verification against the production standalone server
agrees with the test results (register-login-me-logout flow,
plus rate-limit and password-rejection paths).

Closes #11

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
james merged commit 4fd536e9ff into main 2026-06-14 00:05:51 +00:00
Sign in to join this conversation.
No description provided.