Files
sm-user-plugin/logout_test.go
Jakub Zych 358ee5d308 fix: logout ends cookie-carried sessions, clears the session cookies and keeps the token revoked for its refresh window
POST /logout read the token from the Authorization header only, while the
jwt guard also accepts the token and auth_token cookies. A session carried
by a cookie (golem15.sociallogin's token_cookie sets an httpOnly one that
the browser cannot turn into a bearer header) was answered 401, nothing was
revoked and the cookie kept authenticating jwt.auth routes until the access
token expired (golem15.user.jwt.ttl, 60 minutes). Logout never cleared a
cookie either, and it and EndRequestSession kept the jwt_blacklist row only
until the access token's exp, so once the sweep removed the row POST
/refresh revived the logged-out token for the rest of its refresh window.

classes.EndSession, which Logout now runs, revokes every session token the
request carries (the bearer header, the token and auth_token cookies) that
verifies, an expired one too while it could still be refreshed, until the
later of exp and iat plus refresh_ttl, plus a minute, as bouncer.Refresh
and the admin logout do. The answer, a 401 included, expires the
auth.cookie token cookie and any other session cookie the request carries,
with that cookie's path, domain, SameSite and Secure. EndRequestSession is
its best-effort form. Response bodies are unchanged; an expired but still
refreshable token now logs out with 200 instead of 401.
2026-10-22 04:51:36 +02:00

298 lines
12 KiB
Go

package user
import (
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"git.golem15.com/golem15/sm-user-plugin/controllers"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/compass"
"github.com/golang-jwt/jwt/v5"
)
const (
loggedOutBody = `{"message":"Logged out"}`
unauthorizedRaw = `{"error":true,"message":"Unauthorized"}`
blacklistedBody = `{"error":"Could not refresh token","msg":"The token has been blacklisted"}`
userTokensPath = "/_user/api/v1/tokens"
userLogoutPath = "/_user/api/v1/logout"
userRefreshPath = "/_user/api/v1/refresh"
)
// TestLogoutEndsTheSession: POST /logout ends the session however the token
// travels (the bearer header, or only the token or auth_token cookie the
// jwt guard also reads), so the next request with the same token or cookie
// is 401 on the jwt.auth routes, at /fetch and at /refresh. It clears the
// session cookies with the attributes the token cookie is written with, and
// the revocation lasts for the token's refresh window: a logged-out token is
// not refreshable after its access lifetime, and an expired but still
// refreshable token is revoked by a logout too.
func TestLogoutEndsTheSession(t *testing.T) {
e := newAdminEnv(t)
t.Run("bearer", func(t *testing.T) {
token := e.sessionLogin(t, "logout-bearer")
if code := e.guarded(t, token, ""); code != http.StatusOK {
t.Fatalf("jwt.auth before logout = %d", code)
}
rec := e.call(t, http.MethodPost, userLogoutPath, token, nil)
assertLoggedOut(t, rec)
e.assertSessionEnded(t, token)
})
for _, name := range []string{"token", "auth_token"} {
t.Run("cookie "+name, func(t *testing.T) {
token := e.sessionLogin(t, "logout-"+name)
if code := e.guarded(t, token, name); code != http.StatusOK {
t.Fatalf("jwt.auth with the %s cookie before logout = %d", name, code)
}
rec := e.send(t, http.MethodPost, userLogoutPath, "", nil, "application/json", map[string]string{"Cookie": name + "=" + token})
assertLoggedOut(t, rec)
e.assertSessionEnded(t, token)
want := e.cookieConfig(t)
assertForgets(t, rec, want, want.Name)
if name != want.Name {
assertForgets(t, rec, want, name)
}
})
}
t.Run("bearer logout forgets the token cookie too", func(t *testing.T) {
token := e.sessionLogin(t, "logout-bearer-cookie")
rec := e.call(t, http.MethodPost, userLogoutPath, token, nil)
assertLoggedOut(t, rec)
want := e.cookieConfig(t)
assertForgets(t, rec, want, want.Name)
for _, c := range rec.Result().Cookies() {
if c.Name == "auth_token" {
t.Fatalf("a request without the auth_token cookie got a forget cookie for it: %s", c)
}
}
})
t.Run("refused token still forgets the cookie", func(t *testing.T) {
rec := e.send(t, http.MethodPost, userLogoutPath, "", nil, "application/json", map[string]string{"Cookie": "token=not-a-token"})
if rec.Code != http.StatusUnauthorized || rec.Body.String() != unauthorizedRaw {
t.Fatalf("logout with a bad cookie = %d %s", rec.Code, rec.Body.String())
}
assertForgets(t, rec, e.cookieConfig(t), "token")
missing := e.call(t, http.MethodPost, userLogoutPath, "", nil)
if missing.Code != http.StatusUnauthorized || missing.Body.String() != unauthorizedRaw {
t.Fatalf("logout without a token = %d %s", missing.Code, missing.Body.String())
}
})
t.Run("revocation outlives the access token", func(t *testing.T) {
token := e.sessionLogin(t, "logout-window")
claims := parseToken(t, token)
jti, _ := claims["jti"].(string)
iat, _ := claims.GetIssuedAt()
exp, _ := claims.GetExpirationTime()
assertLoggedOut(t, e.call(t, http.MethodPost, userLogoutPath, token, nil))
var expiresAt time.Time
if err := e.gdb.Raw(`SELECT expires_at FROM jwt_blacklist WHERE jti = ?`, jti).Scan(&expiresAt).Error; err != nil {
t.Fatal(err)
}
// The later of exp and iat plus the refresh window, plus a minute.
want := iat.Add(controllers.RefreshTTL(e.app)).Add(time.Minute)
if d := expiresAt.Sub(want); d < -time.Second || d > time.Second {
t.Fatalf("blacklist row expires at %v, want %v (refresh window plus a minute)", expiresAt, want)
}
// The sweep that runs once the access token has expired: the same
// predicate as bouncer.PostgresBlacklist.Sweep, limited to this jti
// so other tests' rows stay.
if err := e.gdb.Exec(`DELETE FROM jwt_blacklist WHERE jti = ? AND expires_at < ?`, jti, exp.Add(2*time.Minute)).Error; err != nil {
t.Fatal(err)
}
rec := e.refresh(t, token)
if rec.Code != http.StatusUnauthorized || rec.Body.String() != blacklistedBody {
t.Fatalf("refresh of a logged-out token after its access lifetime = %d %s", rec.Code, rec.Body.String())
}
})
t.Run("expired but refreshable token", func(t *testing.T) {
user := e.seedUser(t, "logout-expired", nil)
iat := time.Now().Add(-2 * time.Hour)
token := userAudienceToken(t, strconv.FormatUint(uint64(user.ID), 10), "logout-expired-"+adminStamp(), iat, iat.Add(time.Hour))
rec := e.call(t, http.MethodPost, userLogoutPath, token, nil)
assertLoggedOut(t, rec)
if refreshed := e.refresh(t, token); refreshed.Code != http.StatusUnauthorized || refreshed.Body.String() != blacklistedBody {
t.Fatalf("refresh after logging out an expired token = %d %s", refreshed.Code, refreshed.Body.String())
}
})
t.Run("every token the request carries", func(t *testing.T) {
bearerToken := e.sessionLogin(t, "logout-both-bearer")
cookieToken := e.sessionLogin(t, "logout-both-cookie")
rec := e.send(t, http.MethodPost, userLogoutPath, bearerToken, nil, "application/json", map[string]string{"Cookie": "token=" + cookieToken})
assertLoggedOut(t, rec)
e.assertSessionEnded(t, bearerToken)
e.assertSessionEnded(t, cookieToken)
})
t.Run("refused tokens", func(t *testing.T) {
user := e.seedUser(t, "logout-refused", nil)
sub := strconv.FormatUint(uint64(user.ID), 10)
closedAt := time.Now().Add(-controllers.RefreshTTL(e.app) - time.Hour)
for name, token := range map[string]string{
// The refresh window closed: nothing left to revoke.
"closed window": userAudienceToken(t, sub, "logout-closed-"+adminStamp(), closedAt, closedAt.Add(time.Hour)),
// No audience (a PHP token) is accepted only while unexpired.
"expired without audience": craftToken(t, sub, "logout-legacy-old-"+adminStamp(), time.Now().Add(-2*time.Hour), time.Now().Add(-time.Hour)),
// No jti: it cannot be blacklisted.
"no jti": userAudienceToken(t, sub, "", time.Now(), time.Now().Add(time.Hour)),
// A user that does not exist.
"unknown user": userAudienceToken(t, "999999999", "logout-ghost-"+adminStamp(), time.Now(), time.Now().Add(time.Hour)),
} {
rec := e.call(t, http.MethodPost, userLogoutPath, token, nil)
if rec.Code != http.StatusUnauthorized || rec.Body.String() != unauthorizedRaw {
t.Fatalf("%s: logout = %d %s", name, rec.Code, rec.Body.String())
}
assertForgets(t, rec, e.cookieConfig(t), "token")
}
legacy := craftToken(t, sub, "logout-legacy-"+adminStamp(), time.Now().Add(-time.Minute), time.Now().Add(time.Hour))
if code := e.guarded(t, legacy, ""); code != http.StatusOK {
t.Fatalf("jwt.auth with an unexpired token without audience = %d", code)
}
assertLoggedOut(t, e.call(t, http.MethodPost, userLogoutPath, legacy, nil))
if code := e.guarded(t, legacy, ""); code != http.StatusUnauthorized {
t.Fatalf("jwt.auth after logging out a token without audience = %d", code)
}
})
t.Run("configured cookie attributes and refresh window", func(t *testing.T) {
custom := newAdminEnv(t, func(c *compass.Config) {
for key, value := range map[string]any{
"auth.cookie.domain": "example.com",
"auth.cookie.same_site": "strict",
"auth.cookie.path": "/",
"golem15.user.jwt.refresh_ttl": 120,
} {
if err := c.Set(key, value); err != nil {
t.Fatal(err)
}
}
})
token := custom.sessionLogin(t, "logout-custom")
rec := custom.send(t, http.MethodPost, userLogoutPath, "", nil, "application/json", map[string]string{"Cookie": "token=" + token})
assertLoggedOut(t, rec)
want := custom.cookieConfig(t)
if want.Domain != "example.com" || want.SameSite != http.SameSiteStrictMode {
t.Fatalf("config not applied: %+v", want)
}
assertForgets(t, rec, want, "token")
custom.assertSessionEnded(t, token)
// Logout keeps the row for the window /refresh uses.
claims := parseToken(t, token)
jti, _ := claims["jti"].(string)
iat, _ := claims.GetIssuedAt()
var expiresAt time.Time
if err := custom.gdb.Raw(`SELECT expires_at FROM jwt_blacklist WHERE jti = ?`, jti).Scan(&expiresAt).Error; err != nil {
t.Fatal(err)
}
if window := controllers.RefreshTTL(custom.app); window != 120*time.Minute {
t.Fatalf("refresh window = %v", window)
}
if d := expiresAt.Sub(iat.Add(121 * time.Minute)); d < -time.Second || d > time.Second {
t.Fatalf("blacklist row expires at %v, want iat plus 121 minutes", expiresAt)
}
})
}
// sessionLogin seeds a frontend user, signs in through POST /login and
// returns the session token.
func (e *adminEnv) sessionLogin(t *testing.T, label string) string {
t.Helper()
user := e.seedUser(t, label, nil)
code, token := e.userLogin(t, user.Email, "correct-horse")
if code != http.StatusOK || token == "" {
t.Fatalf("login %s = %d", label, code)
}
return token
}
// guarded calls GET /tokens, a jwt.auth route, with token as the bearer
// header (cookie "") or only in the named cookie, and returns the status.
func (e *adminEnv) guarded(t *testing.T, token, cookie string) int {
t.Helper()
if cookie == "" {
return e.call(t, http.MethodGet, userTokensPath, token, nil).Code
}
return e.send(t, http.MethodGet, userTokensPath, "", nil, "application/json", map[string]string{"Cookie": cookie + "=" + token}).Code
}
// assertSessionEnded: token no longer authenticates, through any carrier,
// and cannot be refreshed.
func (e *adminEnv) assertSessionEnded(t *testing.T, token string) {
t.Helper()
for _, carrier := range []string{"", "token", "auth_token"} {
if code := e.guarded(t, token, carrier); code != http.StatusUnauthorized {
t.Fatalf("jwt.auth after logout (cookie %q) = %d", carrier, code)
}
}
if code := e.fetchCode(t, token); code != http.StatusUnauthorized {
t.Fatalf("fetch after logout = %d", code)
}
if rec := e.refresh(t, token); rec.Code != http.StatusUnauthorized || rec.Body.String() != blacklistedBody {
t.Fatalf("refresh after logout = %d %s", rec.Code, rec.Body.String())
}
}
func (e *adminEnv) cookieConfig(t *testing.T) bouncer.CookieConfig {
t.Helper()
cfg, err := bouncer.LoadCookieConfig(e.app.Config)
if err != nil {
t.Fatal(err)
}
return cfg
}
func assertLoggedOut(t *testing.T, rec *httptest.ResponseRecorder) {
t.Helper()
if rec.Code != http.StatusOK || rec.Body.String() != loggedOutBody {
t.Fatalf("logout = %d %s", rec.Code, rec.Body.String())
}
}
// assertForgets: the response deletes the cookie name with the path,
// domain, SameSite, Secure and HttpOnly of the token cookie cfg writes.
func assertForgets(t *testing.T, rec *httptest.ResponseRecorder, cfg bouncer.CookieConfig, name string) {
t.Helper()
set := cfg.TokenCookie("x", time.Hour)
for _, c := range rec.Result().Cookies() {
if c.Name != name {
continue
}
if c.Value != "" || c.MaxAge >= 0 || c.Path != set.Path || c.Domain != set.Domain ||
c.SameSite != set.SameSite || c.Secure != set.Secure || c.HttpOnly != set.HttpOnly {
t.Fatalf("forget cookie %s does not match the token cookie %s", c, set)
}
return
}
t.Fatalf("no forget cookie for %s in %v", name, rec.Result().Header.Values("Set-Cookie"))
}
// userAudienceToken signs a session token the way bouncer.Mint does (user
// audience, prv), with a chosen iat and exp.
func userAudienceToken(t *testing.T, sub, jti string, iat, exp time.Time) string {
t.Helper()
claims := jwt.MapClaims{
"sub": sub,
"aud": []string{bouncer.AudienceUser},
"iss": "http://example.com/_user/api/v1/login",
"iat": iat.Unix(),
"nbf": iat.Unix(),
"exp": exp.Unix(),
"jti": jti,
"prv": prvHash,
}
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(sessionSecret))
if err != nil {
t.Fatal(err)
}
return signed
}