# Phase 2: Authentication - Context

**Gathered:** 2026-05-14
**Status:** Ready for planning

<domain>
## Phase Boundary

A new user can sign up with email + password, log in, and stay logged in across requests via a server-managed session stored in an HTTP-only signed cookie. Protected routes redirect unauthenticated users to `/login`; authed users on `/login` go to `/`. Logout invalidates the session server-side. CSRF protection covers all state-changing requests. Login attempts are rate-limited per email+IP.

Delivers AUTH-01..07. **Not** in scope: password reset / forgot-password flow, email verification, OAuth / social login, magic links, account settings page, multi-factor auth, third-party auth providers (explicitly out of scope per PROJECT.md), session listing UI ("active sessions"), Tablos CRUD (Phase 3).

</domain>

<decisions>
## Implementation Decisions

### Database Schema — users
- **D-01:** `users` table columns: `id uuid PK default gen_random_uuid()`, `email citext NOT NULL UNIQUE`, `password_hash text NOT NULL`, `created_at timestamptz NOT NULL default now()`, `updated_at timestamptz NOT NULL default now()`. Email uses `citext` for native case-insensitive uniqueness; the app additionally normalizes (lowercases + trims) on insert to keep stored values canonical. Enable the `citext` extension in the migration (`CREATE EXTENSION IF NOT EXISTS citext`).
- **D-02:** No `deleted_at` — hard delete only (not exercised in Phase 2; users can't currently delete themselves, but no soft-delete column reserved).
- **D-03:** No `email_verified_at` column — email verification is deferred (no flow in v1).

### Database Schema — sessions
- **D-04:** `sessions` table columns: `id text PK` (the SHA-256 hash of the opaque token, hex-encoded), `user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE`, `created_at timestamptz NOT NULL default now()`, `expires_at timestamptz NOT NULL`. Index on `user_id` (for "delete all sessions for user" sweeps) and on `expires_at` (for the future cleanup job). No `user_agent`, `ip_address`, or `last_seen_at` columns in v1.
- **D-05:** Cookie value is the raw 32-byte (crypto/rand) token, base64url-encoded. DB stores SHA-256(token) hex-encoded. A DB read leak does NOT expose live sessions. Server hashes the incoming cookie on every authed request to look up the row.
- **D-06:** Logout hard-deletes the session row (`DELETE FROM sessions WHERE id = $1`) and expires the cookie (`Max-Age=0`).
- **D-07:** Lazy expiry — auth middleware treats rows where `expires_at < now()` as missing (the lookup query includes `AND expires_at > now()`). Phase 6 worker adds a daily sweep job to GC stale rows; Phase 2 itself ships no background cleanup.

### Password Hashing
- **D-08:** **argon2id** via `golang.org/x/crypto/argon2`. OWASP 2024 baseline parameters: `t=1` (time), `m=64*1024` (64 MiB memory), `p=4` (parallelism), 16-byte random salt per password, 32-byte output. Stored as the standard PHC-formatted string `$argon2id$v=19$m=65536,t=1,p=4$<salt>$<hash>`. The hash code lives in `internal/auth/password.go` (or similar) with a tiny self-test that re-verifies a known password to catch param-drift regressions.

### Session Lifetime
- **D-09:** Sliding 30 days. `expires_at = now() + 30 days` set on session create. On each authenticated request, the middleware extends `expires_at` ONLY when the remaining lifetime drops below 7 days (i.e. update runs once per session per ~23 days). This keeps write volume off the hot path while preserving the sliding behavior. No "remember me" checkbox.
- **D-10:** **Rotate** the session ID on every successful login and on signup auto-login. New random token → new DB row → new cookie. Mitigates session-fixation. The path `POST /login` and `POST /signup` go through the same `createSession(userID)` helper.
- **D-11:** Signup auto-logs-in: `POST /signup` validates → inserts user → creates session → `HX-Redirect: /` (or `Location: /` for non-HTMX submits). No verification email in v1.

### Cookie Properties
- **D-12:** Cookie name: `xtablo_session` (or similar — final name in plan). Attributes: `HttpOnly`, `Secure` (gated by `ENV != "dev"` — dev runs on plain http://localhost), `SameSite=Lax` (default; allows top-level GET navigation from external sites, blocks cross-site POST), `Path=/`. `Max-Age` matches `expires_at` and is reissued whenever the session is extended.
- **D-13:** Cookie value is the raw base64url token only — no extra signing layer. Tampering is detected when SHA-256 lookup fails to find a row. (Signing would add a layer without changing the threat model since the DB lookup is the source of truth.)

### CSRF
- **D-14:** **`github.com/gorilla/csrf`** middleware mounted on the chi stack AFTER the session-resolve middleware but BEFORE any handler that processes POST/PUT/PATCH/DELETE. Configure with `csrf.Secure(ENV != "dev")` and `csrf.SameSite(csrf.SameSiteLaxMode)`. Token is per-session, stored in its own HTTP-only cookie by gorilla/csrf. The library reads the token from the `_csrf` form field OR the `X-CSRF-Token` header (the latter reserved for future HTMX hx-headers usage).
- **D-15:** Token surfacing: a small `internal/web/ui` (or `internal/auth/csrf`) templ helper component renders the hidden input — e.g. `@csrf.Field(ctx)` → `<input type="hidden" name="_csrf" value="...">`. Every templ form includes this component as the first child. No JS, no meta tags.

### Rate Limiting
- **D-16:** Login rate limit: **in-memory token bucket** keyed on `lower(email) + ":" + clientIP`, using `golang.org/x/time/rate`. Config: 5 requests per minute per key (rate = 5/min, burst = 5). Lookup map `map[string]*rate.Limiter` guarded by `sync.Mutex` (or `sync.Map`); a janitor goroutine evicts entries idle > 10 minutes to bound memory. Ephemeral across restarts — acceptable for the >5/min threshold.
- **D-17:** Client IP source: `chimw.RealIP` is already in the Phase 1 middleware stack — read from `r.RemoteAddr` after that middleware has rewritten it. The IP is part of the rate-limit key only — NOT persisted anywhere.
- **D-18:** Rate-limit response: `429 Too Many Requests` with an HTMX-swapped inline error fragment on the login form: "Too many attempts. Try again in a minute." Cooldown is implicit in the token-bucket refill rate (~12s per recovered slot). No captcha. The rate limit applies to `POST /login` only — `POST /signup` is not rate-limited in v1 (revisit if abuse appears).

### Auth Pages UX
- **D-19:** Routes: `GET /login`, `POST /login`, `GET /signup`, `POST /signup`, `POST /logout`. `GET /login` and `GET /signup` render full templ pages using the base layout. Unauthenticated POSTs render an HTMX fragment containing only the form with errors injected; authenticated success returns `HX-Redirect: /` (and `Location: /` + 303 for non-HTMX form submits, so the page works without JS per AUTH-05 / FOUND-philosophy).
- **D-20:** Login error message is intentionally generic: **"Invalid email or password"** — same string for "no such email" and "wrong password" to avoid user enumeration. Validation errors (empty field, malformed email) ARE specific.
- **D-21:** Post-login redirect: **always `/`**. No `?next=` honoring in Phase 2; deep-link return-to plumbing can be added later when there are real routes to deep-link to. Phase 3 may revisit if dashboard deep-links become useful.
- **D-22:** Logout control: POST form with a button rendered in the base layout's header, visible only when the request context carries an authenticated user. CSRF-protected POST (not a GET — prevents prefetchers / image-src attacks from logging users out).

### Protected Routes (chi wiring)
- **D-23:** Chi route groups: a `Group(func(r chi.Router) { r.Use(RequireAuth) ... })` wraps all routes that need a logged-in user. `RequireAuth` middleware checks the session context (set earlier by a `ResolveSession` middleware that runs always) and either lets the request through or returns `303 See Other` with `Location: /login` (HTMX requests get `HX-Redirect: /login`). A separate `RedirectIfAuthed` middleware wraps `GET /login` and `GET /signup` to bounce already-authed users to `/`.
- **D-24:** Middleware order in `NewRouter` for Phase 2: `RequestID → RealIP → SlogLogger → Recoverer → ResolveSession → csrf.Protect(...) → [route groups apply RequireAuth / RedirectIfAuthed as needed]`. `ResolveSession` reads the cookie, looks up the session+user, stuffs both into request context; never blocks — it's `RequireAuth` that enforces.

### Password Policy (server-side validation)
- **D-25:** Minimum constraints (server-side): email looks like an email (Go `net/mail.ParseAddress`), password ≥ 12 characters, password ≤ 128 characters (DoS guard against very long argon2 inputs). No complexity rules (NIST 2023 guidance prefers length). Validation errors render as field-specific inline messages on the form fragment.

### Testing Strategy
- **D-26:** First phase with real DB tests. Use a real Postgres via `testcontainers-go` OR the `compose.yaml` Postgres reachable through `TEST_DATABASE_URL` — planner chooses. Each test runs in a transaction that rolls back, OR a fresh schema per test package; planner picks. No SQL mocking. argon2 password tests use a reduced-cost test parameter set (smaller `m`) to keep unit-test wall time sane while still exercising the same code path.

### Claude's Discretion
- Exact package layout under `internal/auth/` vs splitting between `internal/auth/` + `internal/session/` (the `internal/session/` stub from Phase 1 may be absorbed or kept).
- Final cookie name, session token byte length within sane bounds (24–48 bytes), specific argon2 sub-parameter tuning if benchmarking shows the OWASP baseline is too slow on the target host.
- Specific HTML/Tailwind look of `/login` and `/signup` — minimal, consistent with the Phase 1 design system (`internal/web/ui` Button/Card/Badge). One column, centered, clear labels.
- Whether to introduce a `flash` helper (cookie-backed one-shot messages) for post-logout "you have been logged out" — small QoL, planner decides.
- File/structure for the in-memory rate limiter (standalone `internal/auth/ratelimit.go` vs embedded in the login handler). It must be unit-testable with an injectable clock.
- Exact gorilla/csrf authentication key wiring — generate at startup from env (e.g. `SESSION_SECRET` or a dedicated `CSRF_KEY`); planner makes the call but the key MUST come from env (no compile-time constants).
- Whether `POST /logout` lives on the protected group (require auth to log out) or on a public route that no-ops on missing session (lenient). Default: require auth.

</decisions>

<canonical_refs>
## Canonical References

**Downstream agents MUST read these before planning or implementing.**

### Project & Scope
- `.planning/PROJECT.md` — Core value, constraints, out-of-scope list. Note: "Built-in email/password auth with server-managed sessions (no third-party auth)" is in scope; Clerk/Auth0/Lucia explicitly out.
- `.planning/REQUIREMENTS.md` §Authentication — AUTH-01..07 verbatim.
- `.planning/ROADMAP.md` §"Phase 2: Authentication" — Success criteria + user-in-loop callouts on schema and hash algorithm.

### Prior Phase Context
- `.planning/phases/01-foundation/01-CONTEXT.md` — Phase 1 decisions that constrain Phase 2: chi router + middleware stack (D-07/D-08), goose migrations, sqlc + pgx/v5, templ templates, internal/session/ placeholder package, env-driven config, slog structured logging with request ID.
- `.planning/phases/01-foundation/01-RESEARCH.md` — chi middleware-order findings (RequestID must precede SlogLogger to thread request_id into logs); session resolve middleware will follow the same pattern.

### Codebase Maps (legacy JS app — behavioral reference only)
- `.planning/codebase/INTEGRATIONS.md` — Notes Supabase Auth is the existing JS auth; this phase replaces it entirely.
- `.planning/codebase/CONCERNS.md` — Pain points motivating the rewrite (incl. auth/Supabase coupling).
- Legacy `apps/api/src/routers/authRouter.ts` and `apps/api/src/middlewares/middleware.ts` — Reference ONLY for "what the JS version does"; the new flow does not mirror its JWT/Supabase model.

### Existing Go scaffold
- `backend/internal/session/doc.go` — Empty placeholder stub from Phase 1; Phase 2 fills (or merges into a new `internal/auth/` package).
- `backend/internal/web/router.go` / `middleware.go` — Where new auth middleware (`ResolveSession`, `RequireAuth`, `RedirectIfAuthed`) wires in.
- `backend/migrations/0001_init.sql` — No-op bootstrap from Phase 1. Phase 2 migration is `0002_<name>.sql` (planner picks slug) and includes `CREATE EXTENSION IF NOT EXISTS citext;` plus `users` + `sessions` tables.
- `backend/sqlc.yaml` — sqlc config; queries land in `internal/db/queries/`, generated into `internal/db/sqlc/`.

### External library docs (planner will pull versions during research)
- argon2 (Go std-ext): https://pkg.go.dev/golang.org/x/crypto/argon2 — `argon2.IDKey` signature; PHC string format conventions (OWASP cheatsheet)
- gorilla/csrf: https://github.com/gorilla/csrf — middleware mounting, token field/header names, cookie attributes
- golang.org/x/time/rate: https://pkg.go.dev/golang.org/x/time/rate — token-bucket Limiter API
- pgx/v5 citext: pgx + citext type handling notes
- OWASP Password Storage Cheat Sheet (argon2id params 2024): https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
- OWASP Session Management Cheat Sheet (rotation, lifetime, fixation): https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html

</canonical_refs>

<code_context>
## Existing Code Insights

### Reusable Assets
- `backend/internal/web/middleware.go` (`RequestIDMiddleware`, `SlogLoggerMiddleware`): pattern for context-injecting middleware — `ResolveSession` follows the same shape (`context.WithValue` + typed accessor function).
- `backend/internal/web/ui/` (Button, Card, Badge templ components + `tokens.go` + `variants.go`): the design system established in Phase 1. Auth forms compose these — no new primitives needed for v1 login/signup.
- `backend/templates/layout.templ`: the base layout. Phase 2 extends it with the conditional logout button in the header and a place for global flash/error rendering.
- `backend/internal/db/pool.go` + `pool_test.go`: pgxpool wiring pattern + the existing test approach for DB-touching code.

### Established Patterns
- Middleware order is locked by Phase 1 RESEARCH and CONTEXT D-08: `RequestID → RealIP → SlogLogger → Recoverer`. Phase 2 appends `ResolveSession → csrf.Protect → (route-group RequireAuth)` after Recoverer.
- Templ "handler renders templ.Component to w" pattern (see `IndexHandler`, `DemoTimeHandler`). Auth handlers follow the same shape, with HTMX-vs-full detection via `HX-Request` header for response selection.
- `Pinger` interface pattern (handlers depend on a small interface, not the concrete pool) — the same pattern applies to a `SessionStore` interface for auth, enabling fakes in unit tests.
- sqlc config emits to `internal/db/sqlc/`; queries live in `internal/db/queries/<domain>.sql`. Phase 2 adds `internal/db/queries/auth.sql` (or `users.sql` + `sessions.sql`).

### Integration Points
- `cmd/web/main.go`: wires the pool into `NewRouter`. Phase 2 extends the constructor to accept a `*sql.DB`-or-pgx-pool-backed `auth.Store` (or constructs it inside `NewRouter`). The CSRF secret comes from env (`SESSION_SECRET` or `CSRF_KEY`) read in `main.go` and passed in.
- Existing demo route `/demo/time` does NOT need auth; can stay public. Phase 2's chi groups distinguish public vs protected. The home route `/` becomes protected (per AUTH-05: unauthed → `/login`).

</code_context>

<specifics>
## Specific Ideas

- Argon2id specifically with OWASP 2024 baseline params — user confirmed over bcrypt.
- Sessions store the SHA-256 hash of the opaque token, not the raw token (defense in depth against DB-read leaks). This was an explicit pick over "plain token in DB".
- Sessions are intentionally lean — only `id, user_id, created_at, expires_at`. User explicitly declined storing `user_agent` / `ip_address` / `last_seen_at` columns.
- Rotate session on every login is a hard requirement, even if a valid cookie is already present.
- Signup auto-logs-in (no second step). No email verification anywhere in v1.
- Login error message is intentionally vague to avoid enumeration; signup error messages can be specific (email already taken IS revealed — standard tradeoff).
- Logout MUST be a POST form (CSRF-protected), never a GET link.
- Rate limit applies to login only in v1; signup is not rate-limited.

</specifics>

<deferred>
## Deferred Ideas

- **Password reset / forgot-password flow** — needs email delivery; new capability beyond AUTH-01..07. Future phase.
- **Email verification** — same reason; no email infrastructure in v1.
- **OAuth / social login** — explicitly out of scope (PROJECT.md "no third-party auth").
- **Magic-link login** — that's the client portal pattern (v2 milestone CLIENT-01).
- **MFA / TOTP / passkeys / WebAuthn** — beyond v1 scope.
- **Account settings page** (/account) — change password, view active sessions, etc. Belongs in a later phase once Tablos lands.
- **Session-list "active devices" UI** — would require the UA/IP/last_seen columns we deliberately omitted. Revisit if/when needed.
- **DB-backed rate limiting** with a `login_attempts` table — only if the in-memory approach proves insufficient (e.g. behind multiple replicas — but v1 is single-binary single-host).
- **Captcha on repeated rate-limit hits** — out of scope; revisit if abuse seen in production.
- **`?next=` deep-link return-to on /login** — deferrable until Phase 3+ when protected deep-links exist.
- **Flash message helper** (one-shot post-redirect messages) — nice-to-have; planner may inline a minimal version if it cleans up logout UX.
- **Session sweep / GC job** — explicitly punted to Phase 6 (worker phase), per D-07. Phase 2 ships the lazy expiry check only.
- **Production logging / redaction of email in error logs** — Phase 7 operational concern.

</deferred>

---

*Phase: 2-Authentication*
*Context gathered: 2026-05-14*
