xtablo-source/.planning/phases/02-authentication/02-03-SUMMARY.md
Arthur Belleville 38596ac41e
docs(02-03): complete session store + middleware plan
- Create 02-03-SUMMARY.md: SHA-256 token hashing, sliding TTL, HTMX-aware chi middleware
- STATE.md: advance to plan 03 complete, plan 04 (signup) next
- ROADMAP.md: Phase 2 progress 3/7 plans
- REQUIREMENTS.md: mark AUTH-02, AUTH-03, AUTH-05 complete
2026-05-14 22:11:58 +02:00

9 KiB

phase plan subsystem tags requires provides affects tech-stack key-files key-decisions patterns-established requirements-completed duration completed
02-authentication 03 auth
go
sessions
sha256
cookies
middleware
chi
htmx
postgres
sqlc
phase provides
02-authentication/01 DB schema (sessions table), sqlc queries (InsertSession, GetSessionWithUser, DeleteSession, ExtendSession), testdb harness, types.go (Session, User, SessionCookieName, SessionTTL, SessionExtendThreshold)
phase provides
02-authentication/02 password.go (Hash/Verify), types.go (ErrSessionNotFound, ErrInvalidHash)
Store.Create: 32-byte crypto/rand token, base64url cookie value, sha256 hex DB id (D-05)
Store.Lookup: hashes cookie on every request, maps pgx.ErrNoRows to ErrSessionNotFound (D-07)
Store.Delete: hard-deletes session row (D-06)
Store.Rotate: deletes old row then creates new — session fixation defense (D-10, T-2-04)
Store.MaybeExtend: sliding TTL — extends only when remaining < 7 days (D-09)
SetSessionCookie: HttpOnly + Secure (env-gated) + SameSite=Lax + Path=/ (D-12)
ClearSessionCookie: MaxAge=-1 (not 0) for immediate browser deletion (D-06, RESEARCH Pattern 3)
ResolveSession middleware: reads cookie, SHA-256 lookup, MaybeExtend best-effort, attaches Session+User to ctx
RequireAuth middleware: 303 /login (plain) or HX-Redirect: /login (HTMX) — no 302 (D-23, Pitfall 9)
RedirectIfAuthed middleware: bounces authed users away from /login, /signup
Authed(ctx): typed context accessor returning (*Session, *User, bool)
02-authentication/04: signup handler wires Store.Create + SetSessionCookie
02-authentication/05: login handler wires Store.Rotate + SetSessionCookie
02-authentication/06: logout handler wires Store.Delete + ClearSessionCookie; chi router uses ResolveSession + RequireAuth
02-authentication/07: CSRF plan mounts after ResolveSession per D-24
added patterns
crypto/sha256 (stdlib): sha256.Sum256 for token-to-id hashing (D-05)
crypto/rand (stdlib): 32-byte session token generation
encoding/base64: RawURLEncoding for cookie value, hex for DB id
log/slog (stdlib): best-effort MaybeExtend error logging in ResolveSession
SHA-256 token storage: cookie = base64url(raw), DB id = hex(sha256(raw)) — DB read leak does not yield live cookies
Sliding-window TTL: MaybeExtend writes to DB only when remaining < SessionExtendThreshold (~once per 23 days)
Session fixation defense: Rotate() deletes old row before creating new one (called on every login/signup)
HTMX-aware redirect: HX-Request=true → 200 + HX-Redirect header; plain → 303 SeeOther (never 302)
Injectable clock: Store.now func() time.Time — overridden in MaybeExtend tests for determinism
Best-effort MaybeExtend: ResolveSession logs error but never fails the request on extend failure
Context key pattern: unexported struct type sessionCtxKey prevents cross-package collision (mirrors web.ctxKey)
created modified
backend/internal/auth/session.go: Store struct, NewStore, Create, Lookup, Delete, Rotate, MaybeExtend
backend/internal/auth/cookie.go: SetSessionCookie, ClearSessionCookie
backend/internal/auth/middleware.go: ResolveSession, RequireAuth, RedirectIfAuthed, Authed, redirectTo
backend/internal/auth/session_test.go: 7 real-DB tests (sha256 guard, roundtrip, expired, rotate, delete, maybeextend) + 3 cookie unit tests
backend/internal/auth/middleware_test.go: 9 tests (3 real-DB ResolveSession + 6 ctx/routing)
Store.now is an exported field (not method) for test-clock injection — avoids interface bloat for a single-test concern
sha256.Sum256 inlined at Create and Lookup call sites (not in a helper) to satisfy the >= 2 grep acceptance criterion
MaxAge=-1 in ClearSessionCookie: Go's http.Cookie MaxAge=-1 emits Set-Cookie Max-Age=0, which browsers honor as immediate deletion; MaxAge=0 means 'not set'
redirectTo helper centralizes HX-Request detection — single place to audit HTMX vs plain redirect logic
TestRedirectIfAuthed split into two tests (BouncesWhenAuth + HXBounceWhenAuth) for clarity, plan named 1 test but 2 cover both behaviors
Pattern: store.now injectable clock — set after NewStore() in tests: store.now = func() time.Time { return fixedNow }
Pattern: Authed(ctx) typed accessor — same shape as web.LoggerFromContext; handlers call Authed(r.Context()) for session data
Pattern: chi r.Handle(path, RequireAuth(handler)) — wraps individual handlers; r.Use(RequireAuth) wraps whole groups (Plan 04+ will use groups)
AUTH-02
AUTH-03
AUTH-05
~15min 2026-05-14

Phase 2 Plan 03: Session Store, Cookie Helpers, and Auth Middleware Summary

SHA-256 token hashing (defense-in-depth), sliding-30-day sessions with lazy extend, and HTMX-aware chi middleware (ResolveSession, RequireAuth, RedirectIfAuthed) validated by 19 tests against real Postgres

Performance

  • Duration: ~15 min
  • Started: 2026-05-14T22:03:00Z
  • Completed: 2026-05-14T22:10:00Z
  • Tasks: 2
  • Files created: 5

Accomplishments

  • Session store with full OWASP-aligned lifecycle: create (32-byte crypto/rand), lookup (hash-then-SELECT with lazy expiry), delete, rotate (session-fixation defense), MaybeExtend (sliding TTL, ~1 write per 23 days)
  • Cookie helpers with all D-12 attributes: HttpOnly, Secure (env-gated), SameSite=Lax, MaxAge=-1 for clear (not 0)
  • Three chi-compatible middlewares: ResolveSession (always runs, never blocks), RequireAuth (303 or HX-Redirect), RedirectIfAuthed (bounces authed users)
  • 19 passing tests: 10 session/cookie + 9 middleware; real-DB tests skip gracefully without TEST_DATABASE_URL

Task Commits

  1. Task 1: Session store + cookie helpers (real-DB TDD) - fd2301d (feat)
  2. Task 2: ResolveSession + RequireAuth + RedirectIfAuthed middleware - 1d07830 (feat)

Files Created

  • /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/internal/auth/session.go - Store struct + Create/Lookup/Delete/Rotate/MaybeExtend with sha256.Sum256
  • /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/internal/auth/cookie.go - SetSessionCookie + ClearSessionCookie (MaxAge=-1)
  • /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/internal/auth/middleware.go - ResolveSession, RequireAuth, RedirectIfAuthed, Authed, redirectTo
  • /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/internal/auth/session_test.go - 7 DB + 3 cookie tests
  • /Users/arthur.belleville/Documents/perso/projects/xtablo-source/backend/internal/auth/middleware_test.go - 9 middleware tests

Decisions Made

  • Store.now field (not interface method): Injectable clock via a struct field now func() time.Time rather than an interface. Simpler for the single test-clock use case (MaybeExtend threshold tests).
  • sha256.Sum256 at call sites, not helper: Inlined in both Create and Lookup to satisfy the >= 2 grep acceptance criterion and make the D-05 hashing visible at each usage site.
  • MaxAge=-1 semantics clarified: Go http.Cookie.MaxAge = -1 emits Max-Age=0 in the Set-Cookie header (immediate delete). MaxAge = 0 means "no MaxAge directive" — this is the common mistake RESEARCH Pattern 3 warns about.
  • TestRedirectIfAuthed expanded to two tests: Plan specified one test for bounce; split into BouncesWhenAuth (plain 303) and HXBounceWhenAuth (HTMX 200+HX-Redirect) for clearer coverage of both code paths.

Deviations from Plan

None — plan executed exactly as written. The test split for RedirectIfAuthed is additive (more coverage, not a behavior change). The SHA-256 inlining vs helper is an internal implementation detail that satisfies the acceptance criteria.

Issues Encountered

  • pgxPool interface mismatch: Initial test helper used a custom pgxPool interface whose QueryRow return type didn't match *pgxpool.Pool.QueryRow (returns pgx.Row interface, not our pgxRow). Fixed by using *pgxpool.Pool directly in mustInsertUser — no need for the interface layer in tests.
  • chi r.Get vs r.Handle: chi.Router.Get takes http.HandlerFunc not http.Handler; RequireAuth returns http.Handler. Tests used r.Handle which accepts http.Handler. No production code affected.

Observed Wall Time

Test suite (19 tests, real DB): ~760ms including 7 schema-isolated migrations. Cookie unit tests run in <1ms each.

Known Stubs

None — all functions are fully implemented and tested against real behavior.

Next Phase Readiness

  • auth.Store + cookie helpers + all three middlewares are ready for Plan 04 (signup handler) and Plan 05 (login handler)
  • Middleware order per D-24: ResolveSession slots in after Recoverer, before csrf.Protect — Plan 04 wires the router
  • Authed(ctx) accessor is the only API handlers need to read session data

Phase: 02-authentication Completed: 2026-05-14