---
phase: 02-authentication
plan: 03
type: execute
wave: 2
depends_on: [01]
files_modified:
  - backend/internal/auth/session.go
  - backend/internal/auth/session_test.go
  - backend/internal/auth/cookie.go
  - backend/internal/auth/middleware.go
  - backend/internal/auth/middleware_test.go
autonomous: true
requirements: [AUTH-02, AUTH-03, AUTH-05]
tags: [go, sessions, middleware, sha256, chi]
must_haves:
  truths:
    - "auth.Store.Create issues a 32-byte crypto/rand token and stores SHA-256(token) hex in the sessions row (D-05)"
    - "auth.Store.Lookup hashes the supplied cookie, JOINs users on expires_at > now() (D-07), and returns user+session"
    - "auth.Store.Rotate deletes the old session row and creates a new one — used on every login + signup (D-10)"
    - "auth.Store.Delete hard-deletes the row (D-06)"
    - "auth.Store.MaybeExtend updates expires_at only when remaining < 7 days (D-09)"
    - "SetSessionCookie sets HttpOnly + Secure (env-gated) + SameSite=Lax + Path=/ (D-12); ClearSessionCookie uses MaxAge=-1 (D-06)"
    - "cookie value is the raw base64url token only — no extra signing layer; tampering surfaces via SHA-256 lookup miss (D-13)"
    - "ResolveSession middleware reads the cookie, looks up the session, and attaches Session+User to request context without blocking on miss"
    - "RequireAuth redirects unauth to /login (303 normally, HX-Redirect for HTMX); RedirectIfAuthed bounces authed users to / (D-23)"
  artifacts:
    - path: backend/internal/auth/session.go
      provides: "Store struct + Create/Lookup/Delete/Rotate/MaybeExtend"
      contains: "sha256.Sum256"
    - path: backend/internal/auth/cookie.go
      provides: "SetSessionCookie + ClearSessionCookie"
    - path: backend/internal/auth/middleware.go
      provides: "ResolveSession, RequireAuth, RedirectIfAuthed + context accessor Authed(ctx)"
    - path: backend/internal/auth/session_test.go
      provides: "Real-DB tests: hashed-id storage, rotate-deletes-old, lazy expiry, maybe-extend threshold"
    - path: backend/internal/auth/middleware_test.go
      provides: "ResolveSession ctx plumbing; RequireAuth 303 vs HX-Redirect; RedirectIfAuthed bounce"
  key_links:
    - from: backend/internal/auth/session.go
      to: backend/internal/db/sqlc/sessions.sql.go
      via: "Queries.InsertSession/GetSessionWithUser/DeleteSession/ExtendSession"
      pattern: "q\\.InsertSession|q\\.GetSessionWithUser"
    - from: backend/internal/auth/middleware.go
      to: "request context"
      via: "context.WithValue(sessionCtxKey, authedRequest)"
      pattern: "sessionCtxKey"
---

<objective>
Implement the session lifecycle (token gen, SHA-256 storage, lookup, rotate, delete, maybe-extend), cookie helpers, and the three chi-compatible middlewares (ResolveSession, RequireAuth, RedirectIfAuthed). Each has a deterministic test — real Postgres for store tests, httptest for middleware tests.

Purpose: every authed flow in Plans 04-07 calls into this surface. Getting SHA-256 storage (Pitfall 6), rotation (Pitfall 5), and HTMX-aware redirect (Pattern 5) right here means later plans become straight-line wiring.
Output: internal/auth/{session,cookie,middleware}.go plus tests; package still has no HTTP handlers (those land in Plans 04/05).
</objective>

<execution_context>
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/workflows/execute-plan.md
@/Users/arthur.belleville/Documents/perso/projects/xtablo-source/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/phases/02-authentication/02-CONTEXT.md
@.planning/phases/02-authentication/02-RESEARCH.md
@.planning/phases/02-authentication/02-01-PLAN.md
@backend/internal/auth/types.go
@backend/internal/auth/testdb_test.go
@backend/internal/db/sqlc/sessions.sql.go
@backend/internal/web/middleware.go

<interfaces>
This plan adds to package auth:

- session.go: type Store struct { q *sqlc.Queries; now func() time.Time }
  - NewStore(q *sqlc.Queries) *Store
  - (s *Store) Create(ctx, userID) (cookieValue string, expiresAt time.Time, err error) — D-05/D-09
  - (s *Store) Lookup(ctx, cookieValue) (*Session, *User, error)
  - (s *Store) Delete(ctx, id) error
  - (s *Store) Rotate(ctx, oldID, userID) (string, time.Time, error) — D-10
  - (s *Store) MaybeExtend(ctx, id, expiresAt) error — D-09 (extends only when remaining < SessionExtendThreshold)

- cookie.go:
  - SetSessionCookie(w http.ResponseWriter, value string, expiresAt time.Time, secure bool)
  - ClearSessionCookie(w http.ResponseWriter, secure bool)

- middleware.go:
  - ResolveSession(store *Store) func(http.Handler) http.Handler — never blocks
  - RequireAuth(next http.Handler) http.Handler — 303 or HX-Redirect to /login
  - RedirectIfAuthed(next http.Handler) http.Handler — 303 or HX-Redirect to /
  - Authed(ctx) (*Session, *User, bool) — context accessor
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Session store + cookie helpers (real-DB TDD)</name>
  <read_first>
    backend/internal/auth/types.go
    backend/internal/auth/testdb_test.go
    backend/internal/db/sqlc/sessions.sql.go
    backend/internal/db/sqlc/users.sql.go
    .planning/phases/02-authentication/02-RESEARCH.md (Pattern 2, Pattern 3, Pitfall 6, Pitfall 5)
    .planning/phases/02-authentication/02-CONTEXT.md (D-05, D-06, D-09, D-10, D-12)
  </read_first>
  <behavior>
    Real-DB tests (skip if TEST_DATABASE_URL unset):
    - TestSession_StoresHashedID: Create returns cookie value v; the DB row id equals hex(sha256(base64url_decode(v))) — NOT the raw token. Pitfall 6 guard.
    - TestSession_LookupRoundtrip: insert a user, Create a session, Lookup(cookieValue) returns same userID + non-zero session.ID.
    - TestSession_LookupRejectsExpired: insert a session manually with expires_at = now()-1h via raw SQL; Lookup returns ErrSessionNotFound (D-07).
    - TestSession_RotateDeletesOld: Create session A; Rotate(A.id, userID) returns a different cookie value; SELECT COUNT(*) WHERE id=A.id == 0 (Pitfall 5 guard); new row exists.
    - TestSession_DeleteRemovesRow: Create, Delete, Lookup returns ErrSessionNotFound.
    - TestSession_MaybeExtend_NoOp: row with expires_at = now()+29d (above threshold) — MaybeExtend MUST NOT change expires_at (within +/-1s).
    - TestSession_MaybeExtend_Extends: row with expires_at = now()+1d — MaybeExtend updates expires_at; assert new > old and within +/-5s of now()+30d.

    Unit tests (no DB):
    - TestCookie_SetAttributes: SetSessionCookie(w, "value", t+30d, true) — HttpOnly=true, Secure=true, SameSite=Lax, Path=/, MaxAge ~ 30*24*3600.
    - TestCookie_ClearUsesMaxAgeMinus1: ClearSessionCookie emits Set-Cookie with Max-Age=-1 (NOT 0). Per RESEARCH Pattern 3.
    - TestCookie_SecureGatedByEnv: secure=false produces a cookie WITHOUT the Secure attribute.
  </behavior>
  <action>
    Create backend/internal/auth/session.go:
    - Store struct with q *sqlc.Queries and now func() time.Time (default time.Now).
    - NewStore(q *sqlc.Queries) *Store returns store with now: time.Now.
    - Create(ctx, userID): raw := make([]byte, 32); rand.Read(raw); cookieValue := base64.RawURLEncoding.EncodeToString(raw); sum := sha256.Sum256(raw); id := hex.EncodeToString(sum[:]); expiresAt := s.now().Add(SessionTTL); call q.InsertSession with InsertSessionParams{ID: id, UserID: userID, ExpiresAt: expiresAt}; return cookieValue, expiresAt.
    - Lookup(ctx, cookieValue): decode base64url; if err or len != 32 return ErrSessionNotFound; sum := sha256.Sum256(raw); id := hex(sum); call q.GetSessionWithUser(ctx, id); map pgx.ErrNoRows -> ErrSessionNotFound; build *Session + *User from row columns.
    - Delete(ctx, id): q.DeleteSession(ctx, id).
    - Rotate(ctx, oldID, userID): if oldID != "" then _ = q.DeleteSession(ctx, oldID) (best-effort, ignore err); return Create(ctx, userID).
    - MaybeExtend(ctx, id, expiresAt): if time.Until(expiresAt) (using s.now as the base — implement as expiresAt.Sub(s.now()) >= SessionExtendThreshold) then return nil; else q.ExtendSession with ExtendSessionParams{ID: id, ExpiresAt: s.now().Add(SessionTTL)}.

    Create backend/internal/auth/cookie.go:
    - SetSessionCookie(w, value, expiresAt, secure): http.SetCookie with Name=SessionCookieName, Value=value, Path="/", Expires=expiresAt, MaxAge=int(time.Until(expiresAt).Seconds()), HttpOnly=true, Secure=secure, SameSite=http.SameSiteLaxMode.
    - ClearSessionCookie(w, secure): http.SetCookie with Name=SessionCookieName, Value="", Path="/", Expires=time.Unix(0,0), MaxAge=-1, HttpOnly=true, Secure=secure, SameSite=http.SameSiteLaxMode.

    Create backend/internal/auth/session_test.go with the seven DB tests and three cookie tests above. DB tests use setupTestDB(t) from Plan 01; for raw INSERT in the expired-row + MaybeExtend setup tests, use the pool directly: pool.Exec(ctx, "INSERT INTO sessions (id, user_id, expires_at) VALUES ($1, $2, $3)", ...). Inject store.now := func() time.Time { return fixedNow } in the MaybeExtend tests to remove flakiness.

    Anti-pattern guards: NEVER use sha256.New().Sum(raw) (Pitfall 6) — use sha256.Sum256. NEVER use MaxAge: 0 to clear a cookie. NEVER skip DeleteSession on Rotate (Pitfall 5).
  </action>
  <verify>
    <automated>cd backend &amp;&amp; TEST_DATABASE_URL="$DATABASE_URL" go test ./internal/auth/ -run "TestSession|TestCookie" -count=1 -v 2>&amp;1 | tee /tmp/p03t1.log | tail -40 &amp;&amp; grep -q "PASS" /tmp/p03t1.log &amp;&amp; ! grep -E "^--- FAIL" /tmp/p03t1.log</automated>
  </verify>
  <acceptance_criteria>
    - Files session.go, cookie.go, session_test.go exist in backend/internal/auth/.
    - grep -c "sha256.Sum256" backend/internal/auth/session.go &gt;= 2 (Create + Lookup).
    - grep -c "sha256.New().Sum" backend/internal/auth/session.go == 0 (Pitfall 6 guard).
    - grep -c "MaxAge: *-1\|MaxAge:-1" backend/internal/auth/cookie.go == 1.
    - grep -c "MaxAge: *0[^0-9]" backend/internal/auth/cookie.go == 0 (anti-pattern guard).
    - grep -c "DeleteSession" backend/internal/auth/session.go &gt;= 2 (one in Delete, one in Rotate — Pitfall 5).
    - grep -c "SameSiteLaxMode" backend/internal/auth/cookie.go == 2 (set + clear).
    - cd backend &amp;&amp; go build ./internal/auth/... exits 0.
    - With TEST_DATABASE_URL set: cd backend &amp;&amp; go test ./internal/auth/ -run "TestSession|TestCookie" -count=1 exits 0.
    - Without TEST_DATABASE_URL set: the seven TestSession_* tests SKIP (do not fail); cookie tests still PASS.
  </acceptance_criteria>
  <done>Session lifecycle works end-to-end against a real Postgres; cookie helpers emit attributes that match D-12 exactly.</done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: ResolveSession + RequireAuth + RedirectIfAuthed middleware (HTMX-aware)</name>
  <read_first>
    backend/internal/auth/session.go (created in Task 1)
    backend/internal/web/middleware.go (Phase 1 RequestIDMiddleware pattern — context.WithValue + typed accessor)
    .planning/phases/02-authentication/02-RESEARCH.md (Pattern 4, Pattern 5; Pitfall 9 — 303 vs 302)
    .planning/phases/02-authentication/02-CONTEXT.md (D-23, D-24)
  </read_first>
  <behavior>
    Tests in backend/internal/auth/middleware_test.go (DB-touching ones use setupTestDB; pure routing ones use a fake Store):
    - TestResolveSession_NoCookie: request with no cookie -> handler runs; Authed(ctx) returns (_, _, false).
    - TestResolveSession_InvalidCookie: cookie with random garbage -> handler runs; Authed(ctx) returns false (Lookup returned ErrSessionNotFound).
    - TestResolveSession_ValidCookie: with a Create'd session cookie -> handler runs; Authed(ctx) returns (*Session, *User, true) with matching userID. Real-DB test.
    - TestRequireAuth_303WhenUnauth: no cookie, GET /protected -> 303 with Location: /login.
    - TestRequireAuth_HXRedirectWhenUnauth: header HX-Request: true -> 200 with HX-Redirect: /login header (NOT a 303). Per Pattern 5.
    - TestRequireAuth_PassesWhenAuth: with valid session in ctx -> next handler runs and returns 200.
    - TestRedirectIfAuthed_BouncesWhenAuth: valid session -> 303 with Location: / OR HX-Redirect: / when HX-Request: true.
    - TestRedirectIfAuthed_PassesWhenUnauth: no session -> next handler runs.
  </behavior>
  <action>
    Create backend/internal/auth/middleware.go:
    - Unexported context key: type sessionCtxKey struct{}; var sessionKey = sessionCtxKey{}.
    - type authed struct { Session *Session; User *User }.
    - Authed(ctx context.Context) (*Session, *User, bool): a, ok := ctx.Value(sessionKey).(*authed); if !ok return nil, nil, false; return a.Session, a.User, true.
    - ResolveSession(store *Store) func(http.Handler) http.Handler: read r.Cookie(SessionCookieName); on err or empty value -> next.ServeHTTP without ctx mutation. Else store.Lookup -> on err -> next without mutation (do NOT clear the cookie here; handler/RequireAuth decides). On success: _ = store.MaybeExtend(...) (best-effort, log err via slog.Default()); ctx2 := context.WithValue(r.Context(), sessionKey, &authed{Session: sess, User: u}); next.ServeHTTP(w, r.WithContext(ctx2)).
    - RequireAuth(next): if _, _, ok := Authed(r.Context()); !ok -> redirect(w, r, "/login"); return. Else next.ServeHTTP(w, r).
    - RedirectIfAuthed(next): if _, _, ok := Authed(r.Context()); ok -> redirect(w, r, "/"); return. Else next.ServeHTTP(w, r).
    - redirect(w, r, target): if r.Header.Get("HX-Request") == "true" then w.Header().Set("HX-Redirect", target); w.WriteHeader(http.StatusOK); return. Else http.Redirect(w, r, target, http.StatusSeeOther). 303 mandated (Pitfall 9).

    Create backend/internal/auth/middleware_test.go with the eight tests above. For the routing-only tests, build a fake Store by injecting a Store with q nil and stub via a function-pointer field is not viable — instead, drive ResolveSession with real DB (use setupTestDB + Create a session). For RequireAuth / RedirectIfAuthed pure tests, build the ctx directly: ctx := context.WithValue(context.Background(), sessionKey, &authed{Session: &Session{ID: "x"}, User: &User{ID: uuid.New()}}). Wire via chi.NewRouter() + r.Use(...) + r.Get("/protected", ...).

    Anti-pattern guards: do NOT use http.StatusFound (302) (Pitfall 9). Do NOT clear the cookie inside ResolveSession on lookup failure (Pattern 4 note). Do NOT make ResolveSession blocking — it always calls next.
  </action>
  <verify>
    <automated>cd backend &amp;&amp; TEST_DATABASE_URL="$DATABASE_URL" go test ./internal/auth/ -run "TestResolveSession|TestRequireAuth|TestRedirectIfAuthed" -count=1 -v 2>&amp;1 | tee /tmp/p03t2.log | tail -40 &amp;&amp; grep -q "PASS" /tmp/p03t2.log &amp;&amp; ! grep -E "^--- FAIL" /tmp/p03t2.log</automated>
  </verify>
  <acceptance_criteria>
    - Files middleware.go and middleware_test.go exist in backend/internal/auth/.
    - grep -c "func ResolveSession" backend/internal/auth/middleware.go == 1.
    - grep -c "func RequireAuth\|func RedirectIfAuthed" backend/internal/auth/middleware.go == 2.
    - grep -c "func Authed" backend/internal/auth/middleware.go == 1.
    - grep -c "HX-Redirect" backend/internal/auth/middleware.go &gt;= 1.
    - grep -c "http.StatusSeeOther\|StatusSeeOther" backend/internal/auth/middleware.go &gt;= 1.
    - grep -c "http.StatusFound\|StatusFound" backend/internal/auth/middleware.go == 0 (Pitfall 9 anti-pattern guard).
    - grep -c "MaybeExtend" backend/internal/auth/middleware.go &gt;= 1 (ResolveSession calls it on hit).
    - cd backend &amp;&amp; go build ./internal/auth/... exits 0.
    - With TEST_DATABASE_URL set: middleware tests pass.
    - Without TEST_DATABASE_URL: DB-touching subtests SKIP; pure ctx tests PASS.
  </acceptance_criteria>
  <done>Middleware chain is ready for plug-in by Plan 04 / 05 / 06; HTMX-vs-303 redirect choice is centralized in one helper.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| Browser <-> server (cookie) | Opaque token in cookie; SHA-256 of token in DB — DB read leak does not yield live cookies (D-05) |
| Request scope | ResolveSession is the only writer of the session ctx key; downstream handlers consume via Authed(ctx) |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-2-04 | Spoofing | Session fixation | mitigate | Store.Rotate deletes the old session row before creating the new one (D-10). Verified by TestSession_RotateDeletesOld. |
| T-2-05 | Information disclosure | Cookie theft via JS / non-TLS | mitigate | HttpOnly + Secure (env-gated) + SameSite=Lax (D-12) in SetSessionCookie. Verified by TestCookie_SetAttributes. |
| T-2-06 | Information disclosure | Token leak via DB read | mitigate | DB id is hex(sha256(token)); raw token never persists. Verified by TestSession_StoresHashedID. |
| T-2-10 | Tampering / Elevation | Broken access control | mitigate | RequireAuth gates protected routes; redirects unauth to /login. Verified by TestRequireAuth_303WhenUnauth and TestRequireAuth_PassesWhenAuth. |
| T-2-16 | Information disclosure | Timing on session lookup | accept | Single SHA-256 + indexed PK SELECT; DB-latency dominates. No additional mitigation in v1. |
| T-2-17 | DoS | Garbage cookies forcing DB hits | accept | One indexed SELECT per request; rate limit on /login (Plan 05) covers the attacker entrypoint that creates volume. |
</threat_model>

<verification>
- cd backend &amp;&amp; TEST_DATABASE_URL=$DATABASE_URL go test ./internal/auth/... exits 0 (combined with Plan 02 password tests).
- cd backend &amp;&amp; go vet ./internal/auth/... exits 0.
- No use of http.StatusFound (302), no use of bytes.Equal for hash compare, no use of sha256.New().Sum, no use of MaxAge:0 — all asserted by grep gates.
</verification>

<success_criteria>
- A real Postgres round-trip Create -> Lookup -> Delete works.
- Rotate replaces the old session row (fixation defense).
- MaybeExtend updates expires_at exactly once per ~23 days (sliding window without write storm).
- Middleware integrates cleanly with chi r.Use and chi route groups in Plan 04+.
</success_criteria>

<output>
Create .planning/phases/02-authentication/02-03-SUMMARY.md recording: chosen cookie name (SessionCookieName value), how MaybeExtend handles a clock injected for tests, observed wall time for the test suite, any deviations from RESEARCH Pattern 2/4/5.
</output>
