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

@@ -189,10 +189,11 @@ func TestRequestUser(t *testing.T) {
}
// TestEndRequestSession pins classes.EndRequestSession (D-04, D-05) without
// a database: the session's jti is blacklisted until the token expires, the
// forget cookie carries the configured Domain and Path, a request without a
// token still forgets the cookie, and a blacklist failure never fails the
// call.
// a database: the session's jti is blacklisted until its refresh window
// closes (not only until the access token expires, or /refresh could revive
// it), the forget cookie carries the configured Domain and Path, a request
// without a token still forgets the cookie, and a blacklist failure never
// fails the call.
func TestEndRequestSession(t *testing.T) {
ctx := t.Context()
app := sessionUserApp(t,
@@ -234,14 +235,15 @@ func TestEndRequestSession(t *testing.T) {
if blocked, _ := bl.IsBlacklisted(ctx, jti); !blocked {
t.Fatalf("%s: the jti is not blacklisted", kind)
}
// Until the token's expiry, and no longer.
_ = bl.Sweep(ctx, time.Now())
// Past the token's expiry, until its refresh window closes, and no
// longer.
_ = bl.Sweep(ctx, time.Now().Add(time.Hour+2*time.Minute))
if blocked, _ := bl.IsBlacklisted(ctx, jti); !blocked {
t.Fatalf("%s: the entry left before the token expired", kind)
t.Fatalf("%s: the entry left when the access token expired", kind)
}
_ = bl.Sweep(ctx, time.Now().Add(time.Hour+time.Minute))
_ = bl.Sweep(ctx, time.Now().Add(controllers.RefreshTTL(app)+2*time.Minute))
if blocked, _ := bl.IsBlacklisted(ctx, jti); blocked {
t.Fatalf("%s: the entry outlived the token", kind)
t.Fatalf("%s: the entry outlived the refresh window", kind)
}
}