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.
This commit is contained in:
Jakub Zych
2026-10-22 04:51:36 +02:00
parent aa8396916e
commit 358ee5d308
5 changed files with 460 additions and 69 deletions

View File

@@ -126,17 +126,22 @@ func Login(app *backpack.App) http.HandlerFunc {
}
}
// Logout forever-blacklists the presented bearer token.
// Logout ends the request's session through classes.EndSession: every
// session token it carries (the bearer header, or the token and auth_token
// cookies the jwt guard also reads) is blacklisted for the rest of its
// refresh window, so the next request with it is refused and /refresh
// cannot revive it, and the session cookies are expired, on a 401 too.
// It answers 200 {"message":"Logged out"} when the token the guard reads
// verifies (an expired one too while it can still be refreshed) and names a
// user, 401 otherwise, and 500 when the blacklist write fails.
func Logout(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
_, secret, ok := sessionDeps(w, app)
if !ok {
if _, _, ok := sessionDeps(w, app); !ok {
return
}
raw := bearerToken(r)
sub, _, exp, jti, err := bouncer.VerifyClaims(raw, secret)
if err != nil || jti == "" {
writeUnauthorized(w)
sub, err := classes.EndSession(r.Context(), app, w, r)
if err != nil {
writeOpaque500(w)
return
}
id, err := strconv.ParseUint(sub, 10, 64)
@@ -149,12 +154,6 @@ func Logout(app *backpack.App) http.HandlerFunc {
writeUnauthorized(w)
return
}
if bl, ok := app.Lookup[bouncer.BlacklistStore](); ok && bl != nil {
if err := bl.Add(r.Context(), jti, exp, time.Now()); err != nil {
writeOpaque500(w)
return
}
}
writeJSON(w, http.StatusOK, map[string]any{"message": "Logged out"})
}
}