From 63672731f7c5b14e8e8886c024637c7742fc693f Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Thu, 14 May 2026 20:55:22 +0200 Subject: [PATCH] docs(02): capture phase context --- .../phases/02-authentication/02-CONTEXT.md | 169 ++++++++++++++ .../02-authentication/02-DISCUSSION-LOG.md | 212 ++++++++++++++++++ 2 files changed, 381 insertions(+) create mode 100644 .planning/phases/02-authentication/02-CONTEXT.md create mode 100644 .planning/phases/02-authentication/02-DISCUSSION-LOG.md diff --git a/.planning/phases/02-authentication/02-CONTEXT.md b/.planning/phases/02-authentication/02-CONTEXT.md new file mode 100644 index 0000000..d0e1329 --- /dev/null +++ b/.planning/phases/02-authentication/02-CONTEXT.md @@ -0,0 +1,169 @@ +# Phase 2: Authentication - Context + +**Gathered:** 2026-05-14 +**Status:** Ready for planning + + +## 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). + + + + +## 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$$`. 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)` → ``. 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. + + + + +## 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_.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 + + + + +## 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/.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`). + + + + +## 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. + + + + +## 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. + + + +--- + +*Phase: 2-Authentication* +*Context gathered: 2026-05-14* diff --git a/.planning/phases/02-authentication/02-DISCUSSION-LOG.md b/.planning/phases/02-authentication/02-DISCUSSION-LOG.md new file mode 100644 index 0000000..aa6c88a --- /dev/null +++ b/.planning/phases/02-authentication/02-DISCUSSION-LOG.md @@ -0,0 +1,212 @@ +# Phase 2: Authentication - Discussion Log + +> **Audit trail only.** Do not use as input to planning, research, or execution agents. +> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered. + +**Date:** 2026-05-14 +**Phase:** 2-Authentication +**Areas discussed:** Users + sessions schema, Hash algo + session lifetime, CSRF + rate-limit strategy, Auth pages UX (HTMX flavor) + +--- + +## Users + sessions schema + +### Q1 — Email storage strategy for the users table + +| Option | Description | Selected | +|--------|-------------|----------| +| citext + normalize on write | citext column (case-insensitive). Insert/lookup with lowercased value. Single unique index. | ✓ | +| text + lower(email) unique index | Standard text + UNIQUE on lower(email). Requires every lookup to use lower(). | | +| text + app-side normalize to lowercase | Plain text + regular UNIQUE; lowercase in Go before insert/select. | | + +**User's choice:** citext + normalize on write +**Notes:** Migration must `CREATE EXTENSION IF NOT EXISTS citext;` before creating the users table. + +### Q2 — Sessions table: how is the cookie value stored? + +| Option | Description | Selected | +|--------|-------------|----------| +| Opaque token, hashed in DB | 32 bytes crypto/rand; cookie carries raw token; DB stores SHA-256(token). DB leak ≠ session hijack. | ✓ | +| Opaque token, plain in DB | Cookie carries raw token; DB stores it as-is. | | +| HMAC-signed cookie + DB row id | Cookie is signed(session_id); server verifies HMAC then loads row. Two moving parts. | | + +**User's choice:** Opaque token, hashed in DB + +### Q3 — Capture client metadata on the sessions row? + +| Option | Description | Selected | +|--------|-------------|----------| +| user_agent + ip_address + last_seen_at | Enables "active sessions" UI later; one UPDATE per authed request. | | +| Only created_at + expires_at | Minimal columns. Cheapest write path; no audit columns. | ✓ | +| UA + IP at creation only | Audit columns at create, no per-request UPDATE. | | + +**User's choice:** Only created_at + expires_at +**Notes:** Deliberately lean — no audit columns. Active-sessions UI is deferred. + +### Q4 — Expired/logged-out session cleanup? + +| Option | Description | Selected | +|--------|-------------|----------| +| DELETE on logout + lazy expiry check | Hard-delete on logout; middleware filters expires_at < now() in the SELECT; Phase 6 worker adds sweep. | ✓ | +| DELETE on logout, no cleanup job | Expired rows accumulate until later. | | +| Soft-delete (revoked_at) | Keep audit trail; more disk, privacy questions. | | + +**User's choice:** DELETE on logout + lazy expiry check (sweep job → Phase 6) + +--- + +## Hash algo + session lifetime + +### Q1 — Password hashing algorithm (roadmap user-in-loop) + +| Option | Description | Selected | +|--------|-------------|----------| +| argon2id (golang.org/x/crypto/argon2) | OWASP first choice; memory-hard; params t=1, m=64MiB, p=4. | ✓ | +| bcrypt (golang.org/x/crypto/bcrypt) | Battle-tested; single cost knob; not memory-hard. | | + +**User's choice:** argon2id + +### Q2 — Session lifetime policy + +| Option | Description | Selected | +|--------|-------------|----------| +| Sliding 30 days, no remember-me | Extend expires_at when remaining < 7d (write-rate cap). Simplest UX. | ✓ | +| Absolute 7 days, no extension | Hard cutoff; re-login weekly. | | +| Absolute 30d + optional remember-me to 90d | Two-mode with a checkbox. | | + +**User's choice:** Sliding 30 days, no remember-me + +### Q3 — Rotate session ID on login? + +| Option | Description | Selected | +|--------|-------------|----------| +| New session row + new cookie on every login | Session-fixation defense; same createSession() path for login and signup. | ✓ | +| Reuse existing session if already authed | Faster; loses fixation defense. | | + +**User's choice:** Rotate on every login + +### Q4 — Signup-to-login behavior + +| Option | Description | Selected | +|--------|-------------|----------| +| Signup auto-logs-in | POST /signup → insert user → issue session → redirect. One step. No verification email in v1. | ✓ | +| Signup requires explicit login afterward | Two-step flow with success flash. Friction without payoff. | | + +**User's choice:** Signup auto-logs-in + +--- + +## CSRF + rate-limit strategy + +### Q1 — CSRF library/approach + +| Option | Description | Selected | +|--------|-------------|----------| +| gorilla/csrf middleware | Mature, chi-compatible. Per-session token; reads from form field or X-CSRF-Token header. | ✓ | +| justinas/nosurf | Lighter alternative; less widely deployed. | | +| Hand-rolled double-submit cookie | Total control, more code to test, easy to get subtly wrong. | | + +**User's choice:** gorilla/csrf + +### Q2 — How is the CSRF token surfaced in templ pages? + +| Option | Description | Selected | +|--------|-------------|----------| +| Hidden input via templ helper component | `@csrf.Field(ctx)` renders a hidden input; library reads the form value. Zero JS. | ✓ | +| Meta tag + hx-headers config | Render token in ; configure HTMX globally; tiny inline JS. | | + +**User's choice:** Hidden input via templ helper component + +### Q3 — Rate limiter backend + +| Option | Description | Selected | +|--------|-------------|----------| +| In-memory token bucket on email+IP | golang.org/x/time/rate; map + sync.Mutex + janitor; ephemeral across restarts. | ✓ | +| DB-backed counters (login_attempts table) | Survives restart; adds writes to hot path; needs sweep job. | | +| In-memory bucket on IP only | Simpler key; misses targeted credential stuffing from a botnet. | | + +**User's choice:** In-memory token bucket on email+IP + +### Q4 — Rate-limit response behavior + +| Option | Description | Selected | +|--------|-------------|----------| +| 429 + generic "too many attempts" + 60s cooldown | Inline HTMX-swapped error; no captcha. | ✓ | +| Silent slowdown (artificial 2s delay) | Don't tell the attacker; confuses legitimate typo users. | | +| Hard block for fixed window (5 min) | Stricter UX cost. | | + +**User's choice:** 429 + generic message + 60s cooldown + +--- + +## Auth pages UX (HTMX flavor) + +### Q1 — Page structure for /login and /signup + +| Option | Description | Selected | +|--------|-------------|----------| +| Full-page GET, POST returns HTMX fragment on error / HX-Redirect on success | Inline error swap; HX-Redirect: / on success. | ✓ | +| Full-page GET, POST returns full page on error | Plain form submit + re-render. Simpler; loses HTMX practice point. | | +| Single combined /auth page with tabs | One route with tabs; more UI. | | + +**User's choice:** Full-page GET + HTMX fragment on error / HX-Redirect on success +**Notes:** Forms must still work without HTMX (graceful 303 + Location for non-HTMX submits) per the broader project principle. + +### Q2 — Login error message granularity + +| Option | Description | Selected | +|--------|-------------|----------| +| Generic "Invalid email or password" for both wrong-email and wrong-password | Prevents user enumeration. | ✓ | +| Specific ("No account with that email" vs "Wrong password") | Friendlier; enables enumeration. | | + +**User's choice:** Generic message +**Notes:** Signup errors can still reveal "email already taken" — standard tradeoff. + +### Q3 — Post-login redirect destination + +| Option | Description | Selected | +|--------|-------------|----------| +| Always /, ignore return-to | Simplest; nothing meaningful to deep-link yet. | ✓ | +| Honor ?next= with same-origin allow-list | Future-proofs deep-linking; adds allow-list check. | | + +**User's choice:** Always / +**Notes:** Revisit in Phase 3+ when deep-link return-to becomes useful. + +### Q4 — Where does the logout control live? + +| Option | Description | Selected | +|--------|-------------|----------| +| POST /logout form button in base layout when authed | Visible header button; CSRF-protected POST. | ✓ | +| Logout only on a dedicated /account page | Cleaner header but adds a route not needed in Phase 2. | | + +**User's choice:** POST /logout form button in base layout + +--- + +## Claude's Discretion + +- Exact package layout under `internal/auth/` and whether to absorb the Phase 1 `internal/session/` stub. +- Final cookie name and token byte length within sane bounds (24–48 bytes). +- 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` (must reuse `internal/web/ui` primitives). +- Whether to introduce a small flash-message helper for the post-logout message. +- File structure of the in-memory rate limiter (must accept an injectable clock for tests). +- CSRF key env var name (`SESSION_SECRET` reuse vs dedicated `CSRF_KEY`). +- Whether `POST /logout` lives on the protected group (default: require auth to log out). +- Choice of real-Postgres test approach: `testcontainers-go` vs a `TEST_DATABASE_URL` against the compose Postgres. Transaction-rollback vs per-package schema also planner's call. + +## Deferred Ideas + +- Password reset / forgot-password flow — needs email infra; not v1. +- Email verification — same reason; no email infra in v1. +- OAuth / social login — explicitly out of scope (PROJECT.md). +- Magic-link login — that's the client-portal pattern (v2 CLIENT-01). +- MFA / TOTP / passkeys / WebAuthn — beyond v1. +- Account settings page (/account) — future phase. +- Session-list "active devices" UI — would require the UA/IP/last_seen columns we deliberately omitted. +- DB-backed rate limiting (login_attempts table) — only if in-memory proves insufficient (multi-replica deployments — v1 is single-host). +- Captcha on repeated rate-limit hits — out of scope. +- `?next=` deep-link return-to on /login — deferrable to Phase 3+. +- Flash message helper — nice-to-have; planner may inline a minimal version. +- Session sweep / GC job — explicitly punted to Phase 6 (worker phase). +- Production logging / email redaction — Phase 7 operational concern.