xtablo-source/.planning/phases/02-authentication/02-CONTEXT.md
2026-05-14 20:55:22 +02:00

18 KiB
Raw Blame History

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$<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.
  • 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 (2448 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_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)

</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>

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