---
phase: 02-authentication
plan: 06
type: execute
wave: 5
depends_on: [01, 03, 04, 05]
files_modified:
  - backend/internal/web/handlers_auth.go
  - backend/internal/web/handlers_auth_test.go
  - backend/internal/web/router.go
  - backend/internal/web/handlers.go
  - backend/templates/layout.templ
  - backend/templates/index.templ
autonomous: true
requirements: [AUTH-04, AUTH-05]
tags: [go, htmx, logout, protected-routes, layout]
must_haves:
  truths:
    - "POST /logout (on the protected group) deletes the session row and clears the cookie (D-06, D-22)"
    - "Logout request from an unauthed user is blocked by RequireAuth (redirected to /login)"
    - "After logout, the next request has no session in ctx; the cookie is gone"
    - "GET / is now protected — unauth users get 303 to /login (303), HTMX users get HX-Redirect (D-23, AUTH-05)"
    - "The base layout header renders a Log out POST form when the request context has an authed user; nothing when unauthed"
    - "The Index page renders the authed user's email somewhere visible (smoke check that ctx user reaches the template)"
  artifacts:
    - path: backend/internal/web/handlers_auth.go
      provides: "LogoutHandler appended; renderError helpers reused"
    - path: backend/internal/web/router.go
      provides: "Protected group with RequireAuth wrapping GET / and POST /logout"
    - path: backend/templates/layout.templ
      provides: "Layout extended with optional logout button; signature accepts *auth.User"
  key_links:
    - from: backend/internal/web/handlers_auth.go
      to: backend/internal/auth/session.go
      via: "Store.Delete(sess.ID) on logout"
      pattern: "Store\\.Delete|store\\.Delete"
    - from: backend/templates/layout.templ
      to: backend/internal/auth/types.go
      via: "Layout takes *auth.User parameter"
      pattern: "auth\\.User"
---

<objective>
Close the loop: implement POST /logout, protect GET /, and surface the logout button in the base layout. After this plan ships, the only thing left in Phase 2 is CSRF (Plan 07).

Purpose: AUTH-04 + AUTH-05 closure. With logout in place and `/` protected, the canonical flow signup -> redirect -> logout -> login becomes the user's daily life.
Output: protected home, working logout, layout header that adapts to auth state.
</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-03-PLAN.md
@.planning/phases/02-authentication/02-04-PLAN.md
@backend/internal/auth/session.go
@backend/internal/auth/middleware.go
@backend/internal/auth/cookie.go
@backend/internal/web/router.go
@backend/internal/web/handlers.go
@backend/templates/layout.templ
@backend/templates/index.templ

<interfaces>
- LogoutHandler(deps AuthDeps) http.HandlerFunc — POST. Reads sess from ctx (guaranteed present because mounted under RequireAuth), Store.Delete(sess.ID), ClearSessionCookie, redirect to /login.
- Layout signature changes:
  ```
  templ Layout(title string, user *auth.User) { ... }
  ```
  All call sites updated: Index, SignupPage, LoginPage. Templates referencing user inside their own bodies receive it via Layout-children pattern OR via explicit param threading (planner picks; auth pages pass nil since they're rendered with RedirectIfAuthed wrapping the GET).
- IndexHandler(deps AuthDeps) http.HandlerFunc — renders templates.Index(user) where user is Authed(ctx).user. Existing IndexHandler() signature changes.
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: LogoutHandler + protected route group + IndexHandler/Layout signature changes</name>
  <read_first>
    backend/internal/auth/session.go
    backend/internal/auth/cookie.go
    backend/internal/auth/middleware.go
    backend/internal/web/router.go
    backend/internal/web/handlers.go
    backend/templates/layout.templ
    backend/templates/index.templ
    .planning/phases/02-authentication/02-RESEARCH.md (Pattern 6 — route groups; Logout snippet; Pitfall 9 — MaxAge -1 already enforced in Plan 03)
    .planning/phases/02-authentication/02-CONTEXT.md (D-06, D-22, D-23)
  </read_first>
  <behavior>
    Tests in backend/internal/web/handlers_auth_test.go (extend Plans 04+05 test file):
    - TestLogout_Success: pre-seed user + session; POST /logout with cookie -> 303 Location: /login (or HX-Redirect when HTMX). Set-Cookie header expires the cookie (Max-Age=-1 or 0, Expires in the past). SELECT COUNT(*) FROM sessions WHERE id=hex(sha256(decode(cookie))) == 0 (D-06 hard delete).
    - TestLogout_UnauthRedirectsToLogin: POST /logout with NO cookie -> 303 Location: /login from RequireAuth, NOT a 500 (the handler never runs). Body is not the logout success path.
    - TestLogout_HXRedirect: POST /logout with HX-Request: true -> 200 + HX-Redirect: /login.
    - TestProtected_HomeUnauthRedirects: GET / with no cookie -> 303 Location: /login.
    - TestProtected_HomeUnauthHXRedirect: GET / + HX-Request: true -> 200 + HX-Redirect: /login.
    - TestProtected_HomeAuthRendersUserEmail: pre-seed user "alice@example.com" + session; GET / with cookie -> 200 with body containing "alice@example.com" (proves ctx user reached the template).
    - TestLayout_LogoutFormVisibleWhenAuthed: render Layout("X", &auth.User{Email:"a@b.c"}) to buffer; assert body contains form action="/logout" and method="POST".
    - TestLayout_LogoutFormHiddenWhenUnauthed: render Layout("X", nil); assert body does NOT contain action="/logout".
    - TestLogout_AfterLogoutSubsequentRequestUnauth: POST /logout, then immediately GET / using the SAME cookie value (simulating an attacker still holding it) — must redirect to /login (session row gone, Lookup returns ErrSessionNotFound).
  </behavior>
  <action>
    1. Extend Layout signature in backend/templates/layout.templ:
       - `templ Layout(title string, user *auth.User) { ... }` (import backend/internal/auth in the templ file).
       - Inside the header div: `if user != nil { <form method="POST" action="/logout" class="inline"><!-- CSRF in Plan 07 --><button type="submit" class="text-sm text-slate-700 hover:underline">Log out</button></form> }`.
       - Optionally render the user.Email next to it (small text). Recommendation: yes — it's the cheapest "you reached the template" smoke test.
    2. Update backend/templates/index.templ: `templ Index(user *auth.User) { @Layout("Xtablo", user) { ... } }`. Replace the existing demo block content's outer wrapper so the user.Email shows on the page (e.g., a small "Signed in as {user.Email}" line above the demo card).
    3. Update Signup and Login templates: SignupPage and LoginPage pass `nil` for user when wrapping their own @Layout("Sign up", nil) / @Layout("Sign in", nil).
    4. Update backend/internal/web/handlers.go: change IndexHandler signature to `IndexHandler() http.HandlerFunc` (no deps) but inside the handler read user from auth.Authed(r.Context()) and call templates.Index(user). It is fine to keep IndexHandler() as a no-arg constructor since auth.Authed pulls from ctx.
    5. Add LogoutHandler(deps AuthDeps) http.HandlerFunc to backend/internal/web/handlers_auth.go:
       - sess, _, ok := auth.Authed(r.Context()); if !ok { http.Redirect(w, r, "/login", http.StatusSeeOther); return } // defense in depth, though RequireAuth already gates.
       - if err := deps.Store.Delete(r.Context(), sess.ID); err != nil { slog.Error("logout delete", "err", err) /* continue and clear cookie anyway */ }.
       - auth.ClearSessionCookie(w, deps.Secure).
       - if HX-Request: w.Header().Set("HX-Redirect", "/login"); w.WriteHeader(http.StatusOK); return. Else http.Redirect(w, r, "/login", http.StatusSeeOther).
    6. Update backend/internal/web/router.go: introduce a protected group AFTER the existing public groups (and AFTER ResolveSession):
       ```
       r.Group(func(r chi.Router) {
           r.Use(auth.RequireAuth)
           r.Get("/", IndexHandler())
           r.Post("/logout", LogoutHandler(deps))
       })
       ```
       REMOVE the old top-level r.Get("/", IndexHandler()) registration. /healthz, /demo/time, /static/* remain public.
    7. Update backend/internal/web/handlers_test.go (Phase 1 + Plan 04 tests):
       - TestIndex_RendersHxGet expected behavior changes: an UNAUTH GET / now returns 303 to /login. Update the test (or add TestIndex_AuthRendersHxGet) to seed a user + session via setupTestDB and assert the HTMX demo still renders.
       - TestRequestID_HeaderSet should use a route that's still public; switch to /healthz if it isn't already.
       - TestDemoTime_Fragment is unaffected (route still public).
       Document any test-rewrite decisions in the plan SUMMARY.

    Anti-pattern guards:
    - Logout MUST be POST not GET (D-22). The Layout snippet uses <form method="POST">.
    - Cookie clear uses Max-Age=-1 (already enforced by ClearSessionCookie from Plan 03 — Pitfall: 0 means session cookie).
    - Do NOT short-circuit Store.Delete if ctx user is missing — RequireAuth already gates, but the handler still has the defense-in-depth redirect.
  </action>
  <verify>
    <automated>cd backend &amp;&amp; templ generate &amp;&amp; TEST_DATABASE_URL="$DATABASE_URL" go test ./internal/web/ ./internal/auth/ ./templates/ -count=1 2>&amp;1 | tee /tmp/p06.log | tail -60 &amp;&amp; ! grep -E "^FAIL|^--- FAIL" /tmp/p06.log</automated>
  </verify>
  <acceptance_criteria>
    - handlers_auth.go: grep -c "func LogoutHandler" == 1; grep -c "Store.Delete\|store.Delete\|deps.Store.Delete" &gt;= 1 in the Logout block.
    - handlers_auth.go: grep -c "ClearSessionCookie" &gt;= 1.
    - router.go: grep -c "auth.RequireAuth" &gt;= 1; the protected group contains both `/` and `/logout`.
    - router.go: the OLD top-level `r.Get("/", IndexHandler())` is removed; grep -c 'r.Get("/", ' backend/internal/web/router.go == 0 (only the in-group registration remains).
    - layout.templ: grep -c 'method="POST"' &gt;= 1; grep -c 'action="/logout"' &gt;= 1; grep -c 'if user != nil' &gt;= 1.
    - layout.templ: grep -c 'method="GET".*logout\|hx-get="/logout"' == 0 (D-22 — logout must NOT be a GET).
    - index.templ: grep -c "user.Email\|user\\.Email" &gt;= 1.
    - cd backend &amp;&amp; templ generate exits 0.
    - cd backend &amp;&amp; go build ./... exits 0.
    - cd backend &amp;&amp; TEST_DATABASE_URL=$DATABASE_URL go test ./internal/web/ -run "TestLogout|TestProtected|TestLayout" -count=1 exits 0 with all nine TestLogout_*/TestProtected_*/TestLayout_* tests PASS.
    - Existing Plan 04 + Plan 05 tests still pass (TestSignup_*, TestLogin_*).
    - TestLogout_AfterLogoutSubsequentRequestUnauth passes — proves the session is server-side invalidated, not just cookie-cleared client-side.
  </acceptance_criteria>
  <done>AUTH-04 and AUTH-05 closed. End-to-end loop works in tests: signup -> / -> logout -> /login -> login -> /.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| Cookie -> ResolveSession -> ctx | Server-side authority; cookie alone is not enough — DB lookup must succeed |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-2-07 | Spoofing | Stale session after logout | mitigate | Store.Delete hard-deletes the row; even if attacker captured the cookie pre-logout, post-logout requests miss in Lookup. Verified by TestLogout_AfterLogoutSubsequentRequestUnauth. |
| T-2-10 | Elevation of privilege | Broken access control on / | mitigate | / is now inside the RequireAuth-wrapped group; verified by TestProtected_HomeUnauthRedirects. |
| T-2-22 | Tampering | CSRF on logout (GET-based) | mitigate | Logout is POST form (D-22). Full CSRF token check lands in Plan 07; SameSite=Lax provides interim protection against cross-origin POST. Verified by acceptance grep gate forbidding GET logout. |
| T-2-23 | Information disclosure | User email rendered in HTML | accept | The page is gated behind auth; the user is viewing their own email. templ auto-escapes the value. No mitigation beyond auto-escape. |
</threat_model>

<verification>
- All Plan 06 tests pass.
- Manual browser walkthrough: sign up -> see /, see "Log out" button + email in header; click Log out -> bounce to /login; try direct GET / -> bounce to /login again.
- No regressions across Plans 02-05 tests.
</verification>

<success_criteria>
- AUTH-04 closed: user can log out; session is server-side invalidated.
- AUTH-05 closed: / is protected; /login redirects authed users to /.
- Logout button visible only when authed (verified by template smoke test).
</success_criteria>

<output>
Create .planning/phases/02-authentication/02-06-SUMMARY.md recording: any Phase 1 test rewrites required by /-becomes-protected, manual UAT notes, full middleware-order snapshot from router.go (so Plan 07 has a single-source-of-truth diff base for inserting csrf.Protect).
</output>
