fix(auth): logout actually signs out — clear the HttpOnly session cookie
Release / build-and-push (push) Successful in 3m9s

Logout wiped localStorage but never cleared the token cookie (HttpOnly —
only a server response can) and never called the logout API, leaving the
refresh token live for 30 days and every cookie-authenticated SSR page
accessible: the fake-signout bug. Reproduced on prod and dev; unrelated
to RLS (it sits behind authentication) and long-standing.

- /api/auth/logout now ALWAYS clears the session cookie (Set-Cookie
  empty + Max-Age=-1, HttpOnly/Path/SameSite mirroring login) — even a
  malformed body must sign the browser out; refresh-token revocation
  unchanged.
- The header logout button (and the rules-page copy) now POST to the
  API with the stored refresh token BEFORE clearing local state and
  redirecting — best effort: local cleanup never blocks on API failure.
- toast 401 handler fixed: it cleared 'refreshToken' but storage uses
  'refresh_token' — the real key.
- Regression test TestLogoutClearsSession: real login → logout →
  response deletes the cookie; logged-out refresh token can't mint
  access tokens; body-less logout still clears.

Also carries regenerated artifacts from the confirm-modal and
episode-list work (header_templ/podcast_detail_templ/style.css).
This commit is contained in:
John O'Keefe committed 2026-10-29 22:01:32 -04:00
1 parent 253d8d0e8e
commit 7ea84043fa
8 files changed
+653 -228

No files matched your search

+76
View File
@@ -806,3 +806,79 @@ func TestPodcastDownloadAllPolicyFlip(t *testing.T) {
assert.Equal(t, "all", sub.AutoDownload, "PIN: Download All flips newest → all (dynamic + visible)")
})
}
// ---- logout actually signs out (fake-signout bug, 2026-10-30) ----
func TestLogoutClearsSession(t *testing.T) {
setup := setupTestServer(t)
// Real login: capture the session cookie AND the refresh token.
req, _ := http.NewRequest(http.MethodPost, setup.Server.URL+"/api/auth/login",
strings.NewReader(`{"login":"testuser@tests.bookhoard.internal","password":"Test@Pass123!"}`))
req.Header.Set("Content-Type", "application/json")
loginResp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = loginResp.Body.Close() }()
require.Equal(t, http.StatusOK, loginResp.StatusCode)
var cookies []*http.Cookie
for _, c := range loginResp.Cookies() {
if c.Name == "token" && c.Value != "" {
cookies = append(cookies, c)
}
}
require.NotEmpty(t, cookies, "login must set the session cookie")
var loginBody struct {
RefreshToken string `json:"refresh_token"`
}
require.NoError(t, json.NewDecoder(loginResp.Body).Decode(&loginBody))
require.NotEmpty(t, loginBody.RefreshToken, "login must hand back a refresh token")
// Logout WITH the session cookie + refresh token.
logoutReq, _ := http.NewRequest(http.MethodPost, setup.Server.URL+"/api/auth/logout",
strings.NewReader(`{"refresh_token":"`+loginBody.RefreshToken+`"}`))
logoutReq.Header.Set("Content-Type", "application/json")
for _, c := range cookies {
logoutReq.AddCookie(c)
}
logoutResp, err := http.DefaultClient.Do(logoutReq)
require.NoError(t, err)
defer func() { _ = logoutResp.Body.Close() }()
require.Equal(t, http.StatusOK, logoutResp.StatusCode)
// PIN 1: the response deletes the session cookie.
var cleared bool
for _, c := range logoutResp.Cookies() {
if c.Name == "token" && c.Value == "" && c.MaxAge < 0 {
cleared = true
}
}
assert.True(t, cleared, "PIN: logout must Set-Cookie an empty/expired token (HttpOnly — the browser is the only eraser)")
// PIN 2: the refresh token is revoked server-side.
refreshReq, _ := http.NewRequest(http.MethodPost, setup.Server.URL+"/api/auth/refresh",
strings.NewReader(`{"refresh_token":"`+loginBody.RefreshToken+`"}`))
refreshReq.Header.Set("Content-Type", "application/json")
refreshResp, err := http.DefaultClient.Do(refreshReq)
require.NoError(t, err)
defer func() { _ = refreshResp.Body.Close() }()
assert.NotEqual(t, http.StatusOK, refreshResp.StatusCode,
"PIN: a logged-out refresh token must not mint new access tokens")
// Logout WITHOUT a body still clears the cookie (malformed/absent
// payloads must never leave the browser signed in).
logoutReq2, _ := http.NewRequest(http.MethodPost, setup.Server.URL+"/api/auth/logout", nil)
for _, c := range cookies {
logoutReq2.AddCookie(c)
}
logoutResp2, err := http.DefaultClient.Do(logoutReq2)
require.NoError(t, err)
defer func() { _ = logoutResp2.Body.Close() }()
cleared = false
for _, c := range logoutResp2.Cookies() {
if c.Name == "token" && c.Value == "" && c.MaxAge < 0 {
cleared = true
}
}
assert.True(t, cleared, "cookie clear must not depend on the request body")
}