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

@@ -17,7 +17,7 @@ It owns the `users` table extensions, `user_throttle`, `jwt_blacklist`, `user_ap
## Features
- **Registration and activation.** `POST /register`, `POST /activate`, `POST /activate-by-code`. Registration can be switched off and throttled; `activate_mode` is `auto`, `user` (a mailed code) or `admin`, with codes from `IssueActivationCode` and `VerifyActivationCode`. A `/register` body sets only the `User.RegisterFillable` columns: `name`, `surname`, `email`, `password` (stored as its hash), `preferred_locale` and `marketing_consent`. It cannot set `organisation_id` or `organisation_role` (membership is the administrator's or the application's), `is_onboarded`, `must_change_password`, or the activation, reset, address proof, handle and permission columns; other keys are ignored (they still reach `RegisterEvent.Payload`). `created_ip_address` and `last_ip_address` are server-set from the client address only.
- **JWT sessions.** `POST /login`, `GET /fetch`, `POST /refresh`, `POST /logout`. Boot registers the `jwt` guard with the framework's `bouncer.Registry` and exposes it as the `jwt.auth` middleware for other plugins. Logged-out tokens go to the Postgres `jwt_blacklist`, which a background sweep prunes. `POST /refresh` loads the token's user and refuses (401, `Could not refresh token`, `User not found`) a deactivated or banned user and a token issued before the user's `tokens_valid_after`; a suspension alone does not stop a refresh.
- **JWT sessions.** `POST /login`, `GET /fetch`, `POST /refresh`, `POST /logout`. Boot registers the `jwt` guard with the framework's `bouncer.Registry` and exposes it as the `jwt.auth` middleware for other plugins. `POST /logout` ends the session however the token travels: the bearer header, or only the `token` or `auth_token` cookie the guard also reads. Every session token the request carries goes to the Postgres `jwt_blacklist` until its refresh window closes (the later of `exp` and `iat` plus `refresh_ttl`, plus a minute), so the guard refuses it on the next request and `POST /refresh` cannot revive it after the access token expired; a token whose access lifetime passed is revoked too while it could still be refreshed. The answer, a 401 included, expires the `auth.cookie.*` token cookie (the one `golem15.sociallogin`'s `token_cookie` sets), and any other session cookie the request carries, with that cookie's path, domain, SameSite and Secure. A background sweep prunes the blacklist. `POST /refresh` loads the token's user and refuses (401, `Could not refresh token`, `User not found`) a deactivated or banned user and a token issued before the user's `tokens_valid_after`; a suspension alone does not stop a refresh.
- **Password reset and change.** `POST /forgot-password` answers the same body for known and unknown addresses; `POST /reset-password` consumes a code from `IssueResetCode`. `POST /change-password` requires the current password.
- **Must-change-password lock.** The `user:require-password-change` console command flags an account. The flag rides on the authenticated principal (`bouncer.Principal.MustChangePassword`) for guards and other plugins to act on, and a successful `POST /change-password` clears it.
- **Login throttling.** Failed logins are counted per user and IP in `user_throttle` (`CheckAndRecordLogin`, `RejectIfThrottled`) and suspend the account for a configured time.
@@ -186,7 +186,8 @@ Packages:
- `RevokeUserAccess(ctx, db, userIDs)` ends every session JWT issued before the current second (it stamps `users.tokens_valid_after`, deactivated rows included) and revokes every personal API token of the users. `DeactivateUsers` and `BanUsers` call it; an application may call it for its own offboarding and after writing a ban directly to `user_throttle`.
- `TouchLastSeen(ctx, db, userID)`.
- `OAuthProvider` (`Name`, `Label`) and `OAuthProviderSource` (`EnabledOAuthProviders(ctx)`, `KnownOAuthProviders()`), the seam a social-login plugin publishes with an explicit type argument, `app.Publish[classes.OAuthProviderSource](src)`, so the lookup finds it. `GET /oauth-providers` lists its enabled providers in order; the unlink routes accept its known names.
- `RequestUser(ctx, app, r)` and `EndRequestSession(ctx, app, w, r)`, for handlers that serve signed-in and anonymous browsers on one route (an OAuth provider's authorize page). `RequestUser` reads and checks the session JWT exactly as the `jwt` guard does (bearer header, then the `token` and `auth_token` cookies, the blacklist, `tokens_valid_after`) and returns the user, or `nil` for an anonymous, invalid, expired or blacklisted request and for a soft-deleted user; only an infrastructure failure is an error. `EndRequestSession` blacklists the request's JWT until it expires (as `POST /logout` does) and writes the forget cookie of `bouncer.LoadCookieConfig`, best effort: a blacklist failure is logged, never returned.
- `EndSession(ctx, app, w, r)` is what `POST /logout` runs: it blacklists every session JWT the request carries (bearer header, `token` and `auth_token` cookies) that verifies, an expired one too while its refresh window is open, until that window closes, writes the forget cookies described under JWT sessions, and returns the subject of the token the guard would read (`""` when there is none or it does not verify); only a failing blacklist write is an error.
- `RequestUser(ctx, app, r)` and `EndRequestSession(ctx, app, w, r)`, for handlers that serve signed-in and anonymous browsers on one route (an OAuth provider's authorize page). `RequestUser` reads and checks the session JWT exactly as the `jwt` guard does (bearer header, then the `token` and `auth_token` cookies, the blacklist, `tokens_valid_after`) and returns the user, or `nil` for an anonymous, invalid, expired or blacklisted request and for a soft-deleted user; only an infrastructure failure is an error. `EndRequestSession` is the best-effort `EndSession`: it revokes and forgets the same way (the forget cookie of `bouncer.LoadCookieConfig`), but a blacklist failure is logged, never returned.
- `AvatarURL(ctx, app, user, size)`, the display picture rule of the user payload at any size: the avatar thumbnail (size by size, mode auto) when the user has an avatar and a file bucket is published, the file path when the thumbnail fails, otherwise the gravatar URL. The URL may be protocol-relative or relative. The payload's `avatar_url` comes from it at 128.
- `HandleGenerator`, `AssignHandle(ctx, db, user)`, `CreatedEvent` / `CreatedEventName`, `DeletingEvent` / `DeletingEventName`, `DeactivatingEvent` / `DeactivatingEventName`, `AuthorizeUser` and `RegisterUserAuthorizer`.
- `middleware`: `APITokenGuard`, `Scope`, `Permission` and `BearerToken`.
@@ -255,6 +256,8 @@ go test ./... # full run, starts Postgres with testcontainers (needs D
`TestOffboarding` runs the admin deactivate and ban bulk actions and checks that the user's older session JWT and personal API token stop working, that a password login restores a deactivated account with a working token while the old credentials stay dead, and that unban revives nothing; `TestRefreshSubjectChecks` covers the `/refresh` subject checks; `TestRevokeUserAccess` (in `classes`) covers the revocation writes.
`TestLogoutEndsTheSession` signs in through `POST /login` and logs out with the bearer header, with only the `token` or `auth_token` cookie, and with both a bearer and a different cookie token, then checks that the token is refused through every carrier on a `jwt.auth` route, at `/fetch` and at `/refresh`, that the forget cookies match the token cookie's attributes (also with a configured domain and SameSite), that the blacklist row lasts for the refresh window so `/refresh` stays refused after the sweep that follows the access token's expiry, that an expired but refreshable token is revoked, and which tokens a logout refuses with 401.
`TestAdminOrganisationMembershipClearsRole` checks that the members manager's unlink and link and a Users form save that changes the organisation clear `organisation_role`, that a save keeping the organisation keeps it, and that deleting an organisation clears both columns.
The admin tests (`TestAdmin*`) boot the plugin with the admin API mounted on a database of their own and sign in backend administrators with a chosen permission set; the helpers are in `admin_harness_test.go`.

View File

@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"net/http"
"slices"
"strconv"
"strings"
"time"
@@ -77,23 +78,77 @@ func RequestUser(ctx context.Context, app *backpack.App, r *http.Request) (*mode
return &user, nil
}
// EndRequestSession signs the request's user out the way Logout does and
// forgets the token cookie. When r carries a valid session JWT its jti is
// added to the published bouncer.BlacklistStore until the token expires;
// the response always gets the forget cookie of
// bouncer.LoadCookieConfig(app.Config), so its Domain and Path match the
// cookie the login wrote. It is best effort, as in the PHP provider: a
// blacklist failure is logged and never returned, and the function writes
// no status or body.
func EndRequestSession(ctx context.Context, app *backpack.App, w http.ResponseWriter, r *http.Request) error {
if r != nil {
if raw := sessionToken(r); raw != "" {
revokeSessionToken(ctx, app, raw)
// EndSession signs the request out; POST /logout runs it. Every session JWT
// r carries (the Authorization bearer header and the token and auth_token
// cookies the "jwt" guard reads) that verifies is added to the published
// bouncer.BlacklistStore, so the guard, RequestUser and POST /refresh refuse
// it from then on. A token verifies while it is unexpired, and a token with
// the user audience also after its access lifetime while its refresh window
// is open (it could still be refreshed). The row is kept until the later of
// the token's exp and its iat plus the refresh window
// (golem15.user.jwt.refresh_ttl), plus one minute, as bouncer.Refresh keeps
// a rotated jti: a row that ended at exp would let /refresh revive the
// logged-out token for the rest of its window.
//
// When w is not nil the response gets the forget cookie of
// bouncer.LoadCookieConfig(app.Config), with the name, path, domain,
// SameSite and Secure of the token cookie a login writes, and one with the
// same attributes for each other session cookie r carries. It writes no
// status or body.
//
// It returns the subject of the token the guard would read (the bearer
// header first, then the cookies in order), or "" when r carries none or it
// does not verify. Only a failing blacklist write is an error; the cookies
// are forgotten and the other tokens revoked in any case. Without a
// published blacklist nothing is revoked.
func EndSession(ctx context.Context, app *backpack.App, w http.ResponseWriter, r *http.Request) (string, error) {
if w != nil {
forgetSessionCookies(app, w, r)
}
if r == nil || app == nil {
return "", nil
}
secret, err := JWTSecret(app)
if err != nil {
return "", nil
}
bl, _ := app.Lookup[bouncer.BlacklistStore]()
window := refreshWindow(app)
subject := ""
var firstErr error
for i, raw := range sessionTokens(r) {
sub, iat, exp, jti, ok := verifySessionToken(raw, secret, window)
if !ok {
continue
}
if i == 0 {
subject = sub
}
if bl == nil {
continue
}
if err := bl.Add(ctx, jti, revokedUntil(iat, exp, window), time.Now()); err != nil && firstErr == nil {
firstErr = err
}
}
if w == nil {
return nil
return subject, firstErr
}
// EndRequestSession is the best-effort EndSession, as in the PHP provider:
// it revokes the session JWTs r carries and forgets the session cookies the
// same way, logs a blacklist failure instead of returning it, and always
// returns nil. It writes no status or body.
func EndRequestSession(ctx context.Context, app *backpack.App, w http.ResponseWriter, r *http.Request) error {
if _, err := EndSession(ctx, app, w, r); err != nil {
pluginLog.Warn("golem15.user: end session: blacklist token", "err", err)
}
return nil
}
// forgetSessionCookies expires the token cookie of
// bouncer.LoadCookieConfig(app.Config) and, with the same path, domain,
// SameSite and Secure, each other session cookie r carries.
func forgetSessionCookies(app *backpack.App, w http.ResponseWriter, r *http.Request) {
cfg := bouncer.CookieConfig{Name: bouncer.DefaultCookieName, Path: "/", SameSite: http.SameSiteLaxMode}
if app != nil && app.Config != nil {
loaded, err := bouncer.LoadCookieConfig(app.Config)
@@ -104,48 +159,83 @@ func EndRequestSession(ctx context.Context, app *backpack.App, w http.ResponseWr
}
}
http.SetCookie(w, cfg.ForgetTokenCookie())
return nil
if r == nil {
return
}
for _, name := range sessionCookieNames {
if name == cfg.Name {
continue
}
if _, err := r.Cookie(name); err != nil {
continue
}
other := cfg
other.Name = name
http.SetCookie(w, other.ForgetTokenCookie())
}
}
// revokeSessionToken blacklists raw's jti until its expiry (best effort).
func revokeSessionToken(ctx context.Context, app *backpack.App, raw string) {
if app == nil {
return
}
secret, err := JWTSecret(app)
// verifySessionToken accepts raw when it is a token the "jwt" guard accepts
// or POST /refresh still rotates: a user-audience token whose refresh window
// is open (its exp may have passed), or an unexpired token, which also
// covers one without an audience (issued by the PHP backend). A token
// without a jti cannot be revoked and is refused.
func verifySessionToken(raw, secret string, window time.Duration) (sub string, iat, exp time.Time, jti string, ok bool) {
sub, iat, exp, jti, err := bouncer.VerifyRefreshableClaimsAudience(raw, secret, bouncer.AudienceUser, window)
if err != nil {
return
sub, iat, exp, jti, err = bouncer.VerifyClaims(raw, secret)
}
_, _, exp, jti, err := bouncer.VerifyClaims(raw, secret)
if err != nil || jti == "" {
return
return sub, iat, exp, jti, err == nil && jti != ""
}
// revokedUntil is the storage expiry of a revoked session jti: the later of
// exp and iat plus the refresh window, plus one minute (PHP jwt-auth's
// Blacklist::getMinutesUntilExpired, which bouncer.Refresh follows).
func revokedUntil(iat, exp time.Time, window time.Duration) time.Time {
until := iat.Add(window)
if exp.After(until) {
until = exp
}
bl, ok := app.Lookup[bouncer.BlacklistStore]()
if !ok || bl == nil {
return
}
if err := bl.Add(ctx, jti, exp, time.Now()); err != nil {
pluginLog.Warn("golem15.user: end session: blacklist token", "err", err)
return until.Add(time.Minute)
}
// refreshWindow is golem15.user.jwt.refresh_ttl minutes, default 20160: the
// window POST /refresh accepts (controllers.RefreshTTL reads the same key).
func refreshWindow(app *backpack.App) time.Duration {
mins := 20160
if app != nil && app.Config != nil && app.Config.Int("golem15.user.jwt.refresh_ttl") > 0 {
mins = app.Config.Int("golem15.user.jwt.refresh_ttl")
}
return time.Duration(mins) * time.Minute
}
// sessionToken extracts the session JWT as the "jwt" guard does: the
// Authorization bearer header, then each session cookie in order.
func sessionToken(r *http.Request) string {
h := strings.TrimSpace(r.Header.Get("Authorization"))
if token, ok := strings.CutPrefix(h, "Bearer "); ok {
if token = strings.TrimSpace(token); token != "" {
return token
}
}
for _, name := range sessionCookieNames {
c, err := r.Cookie(name)
if err != nil {
continue
}
if v := strings.TrimSpace(c.Value); v != "" {
return v
}
if tokens := sessionTokens(r); len(tokens) > 0 {
return tokens[0]
}
return ""
}
// sessionTokens returns every distinct session JWT r carries, in the order
// the "jwt" guard tries them: the Authorization bearer header, then each
// session cookie. Empty values are skipped.
func sessionTokens(r *http.Request) []string {
var out []string
add := func(v string) {
if v = strings.TrimSpace(v); v != "" && !slices.Contains(out, v) {
out = append(out, v)
}
}
h := strings.TrimSpace(r.Header.Get("Authorization"))
if token, ok := strings.CutPrefix(h, "Bearer "); ok {
add(token)
}
for _, name := range sessionCookieNames {
if c, err := r.Cookie(name); err == nil {
add(c.Value)
}
}
return out
}

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"})
}
}

297
logout_test.go Normal file
View File

@@ -0,0 +1,297 @@
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
}

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)
}
}