fix(auth): logout actually signs out — clear the HttpOnly session cookie
Release / build-and-push (push) Successful in 3m9s
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:
1 parent
253d8d0e8e
commit
7ea84043fa
8 files changed
+653
-228
No files matched your search
@@ -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")
|
||||
}
|
||||
Reference in new issue
Block a user