fix: a /refresh carried by the auth.cookie session cookie rewrites that cookie, so an HttpOnly session survives the rotation

8e4b863 answered a cookie-carried refresh in the body only and blacklisted the old jti, so an HttpOnly cookie (golem15.sociallogin token_cookie) kept a dead token. Now the cookie named auth.cookie.name gets the rotated token and the body is {}; bearer and auth_token refreshes are unchanged, and refusals write no cookie.
This commit is contained in:
Jakub Zych
2026-10-22 23:39:18 +02:00
parent a484d0e9a2
commit 846087db45
5 changed files with 367 additions and 27 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. With `require_activation` on (the default), `POST /login` refuses an account that is not activated with the same 401 invalid-login answer as a wrong password and counts the attempt for the login throttle. Such an account is activated by the mailed code (`POST /activate-by-code` answers with a token), by the invitation link or by an administrator. `POST /activate` serves the sessions of not-activated accounts, which exist when `require_activation` is off. `Login` checks in this order: a ban or an active suspension (`RejectIfThrottled`), the password, the activation, then the deactivation restore.
- **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. The guard reads the session cookies `classes.SessionCookieNames()` lists and resolves the published `bouncer.BlacklistStore` on every request. Boot installs the plugin's GORM hooks (`classes.RegisterHooks`), publishes the Postgres `jwt_blacklist` store (`bouncer.NewPostgresBlacklist`) and starts its background sweep, which prunes the blacklist, through `lagoon.OnDatabase`. Under `summer serve`, which publishes the database after Boot, they take effect when `lagoon.Publish` runs, before the router is built; when the database is already published they take effect at once. While no store is published, no token counts as blacklisted. `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. `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. `GET /fetch`, `POST /refresh`, `POST /update` and `POST /change-password`, like the plugin's other signed-in routes, read the session token as the `jwt` guard does: the bearer header first, then the `token` and `auth_token` cookies, so a cookie-only session works on them and a bearer header wins when both are sent. A cookie-carried `POST /refresh` answers the rotated token in the body and writes no cookie; the old token is blacklisted after `golem15.user.jwt.blacklist_grace`, so the caller that wrote the cookie stores the new token in it. A cookie-carried session relies on the cookie's SameSite attribute against cross-site requests (the `auth.cookie` default is `lax`), as on the `jwt.auth` routes.
- **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. The guard reads the session cookies `classes.SessionCookieNames()` lists and resolves the published `bouncer.BlacklistStore` on every request. Boot installs the plugin's GORM hooks (`classes.RegisterHooks`), publishes the Postgres `jwt_blacklist` store (`bouncer.NewPostgresBlacklist`) and starts its background sweep, which prunes the blacklist, through `lagoon.OnDatabase`. Under `summer serve`, which publishes the database after Boot, they take effect when `lagoon.Publish` runs, before the router is built; when the database is already published they take effect at once. While no store is published, no token counts as blacklisted. `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. `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. `GET /fetch`, `POST /refresh`, `POST /update` and `POST /change-password`, like the plugin's other signed-in routes, read the session token as the `jwt` guard does: the bearer header first, then the `token` and `auth_token` cookies, so a cookie-only session works on them and a bearer header wins when both are sent. A `POST /refresh` whose token came from the `auth.cookie.*` cookie (`auth.cookie.name`, default `token`) with no bearer header gets that cookie back with the rotated token: the cookie has the `bouncer.LoadCookieConfig` attributes, HttpOnly and Max-Age `refresh_ttl`, and the answer is `{}`, so the token never reaches JavaScript. This holds whoever wrote the cookie (`golem15.sociallogin`'s `token_cookie` or the application); a frontend that writes the `token` cookie itself and refreshes through it gets it back HttpOnly, without a body token, so such a frontend sends the bearer header to keep rotating in the body. A refresh carried by the bearer header (a cookie sent with it included) or by the other session cookie answers `{"token": …}` and writes no cookie; the old token is blacklisted after `golem15.user.jwt.blacklist_grace`, so that caller stores the new token itself. A refused refresh writes and forgets no cookie. When `bouncer.LoadCookieConfig` refuses the `auth.cookie` block, a cookie-carried refresh answers the opaque 500 before anything is rotated; a bearer refresh never reads that block. Keep `auth.cookie.name` at `token` or `auth_token`, the cookies the guard reads. A cookie-carried session relies on the cookie's SameSite attribute against cross-site requests (the `auth.cookie` default is `lax`), as on the `jwt.auth` routes.
- **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 client address in `user_throttle` (`CheckAndRecordLogin`, `RejectIfThrottled`) and suspend the account for a configured time. The client address is `surf.ClientIP` with the framework's `http.trusted_proxies`, as for the `/register` IP limit and the `user-api` rate limit: behind a listed reverse proxy it is the `X-Forwarded-For` client, otherwise the connection address, and an `X-Forwarded-For` header from any other peer is ignored. A deployment behind a proxy lists the proxy's address in `http.trusted_proxies`; otherwise every visitor shares the proxy's counter.
@@ -189,7 +189,7 @@ Packages:
- `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.
- `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.
- `SessionToken(r)`, the session JWT the `jwt` guard reads from a request (bearer header, then the `token` and `auth_token` cookies; `""` when there is none), and `SessionCookieNames()`, a copy of that cookie list to pass to `bouncer.NewJWTGuard`, so a handler's own guard reads the same token.
- `SessionToken(r)`, the session JWT the `jwt` guard reads from a request (bearer header, then the `token` and `auth_token` cookies; `""` when there is none); `SessionTokenCookie(r)`, the name of the session cookie that supplied that token (`""` when the bearer header did or there is none), which `POST /refresh` uses to rewrite the `auth.cookie` cookie; and `SessionCookieNames()`, a copy of that cookie list to pass to `bouncer.NewJWTGuard`, so a handler's own guard reads the same token.
- `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`.
@@ -263,12 +263,14 @@ go test ./... # full run, starts Postgres with testcontainers (needs D
`TestBootBeforeDatabase` boots in the serve order (`party.Activate` with no database, then `lagoon.Publish`, through `newServeOrderEnv` in `admin_harness_test.go`). It checks that the blacklist store and the GORM hooks are installed on Publish and not before, and that a token ended by `POST /logout` is refused on a `jwt.auth` route through every carrier, at `/fetch` and at `/refresh`, with its `jwt_blacklist` row. `TestAppBlacklist` (in `-short`) covers the guard's per-request store lookup: no store, delegation and a failing store.
`TestSessionRoutesAcceptCookies` checks that a session `jwt.auth` accepts through only the `token` or `auth_token` cookie is served by `GET /fetch`, `POST /refresh` (the rotated token in the body, no cookie written, the old token refused afterwards), `POST /update`, `POST /change-password` (the calling token stays valid) and `POST /marketing-consent`; that a request without credentials still gets the old 401 bodies; that the bearer header wins over a cookie exactly as on `jwt.auth`, an invalid bearer included; and that a cut-off token and a banned user are refused through the cookie.
`TestSessionRoutesAcceptCookies` checks that a session `jwt.auth` accepts through only the `token` or `auth_token` cookie is served by `GET /fetch`, `POST /refresh` (through `token`, the `auth.cookie` default, the rotated token comes back in a rewritten cookie with a `{}` body; through `auth_token` it comes in the body with no cookie; the old token is refused afterwards), `POST /update`, `POST /change-password` (the calling token stays valid) and `POST /marketing-consent`; that a request without credentials still gets the old 401 bodies; that the bearer header wins over a cookie exactly as on `jwt.auth`, an invalid bearer included; and that a cut-off token and a banned user are refused through the cookie.
`TestLoginRequiresActivation` pins the activation gate of `POST /login`: under the default and an explicit `require_activation: true` a not-activated account's correct password gets the byte-identical 401 (status, body, `Content-Type` and `Cache-Control`) of a wrong password, stamps no `last_seen`, adds one throttle attempt and suspends the account after `attempt_limit` refusals; `require_activation: false` and activated accounts sign in; a deactivated activated account is restored with the reactivate mail, and a deactivated not-activated account is refused and stays deactivated unless the key is off.
`TestLoginThrottleClientIP` sends failed logins through one trusted proxy (`http.trusted_proxies`). It checks that the throttle row carries the `X-Forwarded-For` client, and that this client is suspended while another client behind the same proxy still signs in. It also checks that without trusted proxies a changing `X-Forwarded-For` neither moves the counter nor escapes the suspension.
`TestRefreshRewritesAuthCookie` covers the cookie-carried `POST /refresh`: the `token` cookie rewritten with the rotated token and the `LoadCookieConfig` attributes (the old token refused, the new one served by `/fetch` and `jwt.auth`), `auth_token` and bearer refreshes left body-only (the bearer wins over a cookie), a configured cookie name and refresh window, refusals that set no cookie, and a broken `auth.cookie` block that answers the opaque 500 and rotates nothing. `TestSessionTokenCookie` (in `-short`) is the carrier table of `classes.SessionTokenCookie`.
`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

@@ -229,6 +229,32 @@ func SessionToken(r *http.Request) string {
return ""
}
// SessionTokenCookie names the session cookie that supplied the token
// SessionToken returns, for a handler that writes that cookie back (POST
// /refresh rewrites it with the rotated token). It is "" when the bearer
// header supplied the token or the request carries none.
func SessionTokenCookie(r *http.Request) string {
if r == nil || bearerToken(r) != "" {
return ""
}
for _, name := range sessionCookieNames {
if c, err := r.Cookie(name); err == nil && strings.TrimSpace(c.Value) != "" {
return name
}
}
return ""
}
// bearerToken returns the trimmed token of r's Authorization bearer header,
// "" when there is none.
func bearerToken(r *http.Request) string {
h := strings.TrimSpace(r.Header.Get("Authorization"))
if token, ok := strings.CutPrefix(h, "Bearer "); ok {
return strings.TrimSpace(token)
}
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.
@@ -239,10 +265,7 @@ func sessionTokens(r *http.Request) []string {
out = append(out, v)
}
}
h := strings.TrimSpace(r.Header.Get("Authorization"))
if token, ok := strings.CutPrefix(h, "Bearer "); ok {
add(token)
}
add(bearerToken(r))
for _, name := range sessionCookieNames {
if c, err := r.Cookie(name); err == nil {
add(c.Value)

View File

@@ -190,12 +190,17 @@ func Fetch(app *backpack.App) http.HandlerFunc {
// Refresh slides a still-refreshable session token of a user who may still
// sign in. The token is read as the plugin's jwt guard reads it: the bearer
// header, then the token and auth_token cookies. The rotated token is
// answered in the body only; no cookie is written. The old token is
// blacklisted after golem15.user.jwt.blacklist_grace, so a caller that keeps
// the session in a cookie stores the new token there. Deactivated and banned
// users and tokens issued before the user's tokens_valid_after are refused
// with the token-failure 401 body.
// header, then the token and auth_token cookies. When it came from the cookie
// named auth.cookie.name with no bearer header, that cookie is rewritten with
// the rotated token (bouncer.LoadCookieConfig attributes, HttpOnly, Max-Age
// golem15.user.jwt.refresh_ttl) and the body is {}, whoever wrote the
// cookie. A bearer-carried or other-cookie refresh answers the rotated token
// in the body and writes no cookie; the old token is blacklisted after
// golem15.user.jwt.blacklist_grace, so that caller stores the new token
// itself. Refusals write and forget no cookie, and a broken auth.cookie
// block answers a cookie-carried refresh with the opaque 500 before anything
// is rotated. Deactivated and banned users and tokens issued before the
// user's tokens_valid_after are refused with the token-failure 401 body.
func Refresh(app *backpack.App) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
gdb, secret, ok := sessionDeps(w, app)
@@ -207,6 +212,20 @@ func Refresh(app *backpack.App) http.HandlerFunc {
writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "Token not found"})
return
}
// The auth.cookie block is read before bouncer.Refresh, so a broken
// block rotates and blacklists nothing; a bearer refresh never reads it.
var rotate *bouncer.CookieConfig
if name := classes.SessionTokenCookie(r); name != "" {
cc, err := tokenCookieConfig(app)
if err != nil {
pluginLog.Error("golem15.user: refresh: auth.cookie config", "err", err)
writeOpaque500(w)
return
}
if cc.Name == name {
rotate = &cc
}
}
var bl bouncer.BlacklistStore
if store, ok := app.Lookup[bouncer.BlacklistStore](); ok {
bl = store
@@ -242,7 +261,7 @@ func Refresh(app *backpack.App) http.HandlerFunc {
if err := classes.TouchLastSeen(r.Context(), gdb, userID); err != nil {
pluginLog.Warn("golem15.user: stamp last_seen on refresh", "user_id", userID, "err", err)
}
writeJSON(w, http.StatusOK, map[string]any{"token": next})
writeSession(w, app, rotate, next, map[string]any{})
}
}
@@ -1183,6 +1202,31 @@ func remoteIP(r *http.Request) string {
return host
}
// tokenCookieConfig is the auth.cookie block the plugin writes the session
// cookie with: bouncer.LoadCookieConfig of the application config, or its
// defaults when there is no config.
func tokenCookieConfig(app *backpack.App) (bouncer.CookieConfig, error) {
if app == nil || app.Config == nil {
return bouncer.LoadCookieConfig(nil)
}
return bouncer.LoadCookieConfig(app.Config)
}
// writeSession answers 200 with a session token, the user plugin's twin of
// golem15.sociallogin's writeSession. With cc nil the token goes into
// body["token"], the key set the session routes have always answered. With
// cc set it goes only into the HttpOnly cookie cc writes (Max-Age
// golem15.user.jwt.refresh_ttl), so the token never reaches JavaScript, and
// body is answered without it.
func writeSession(w http.ResponseWriter, app *backpack.App, cc *bouncer.CookieConfig, token string, body map[string]any) {
if cc == nil {
body["token"] = token
} else {
http.SetCookie(w, cc.TokenCookie(token, refreshTTL(app)))
}
writeJSON(w, http.StatusOK, body)
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Cache-Control", "no-cache, private")
wire.WriteJSON(w, status, v)

261
refresh_cookie_test.go Normal file
View File

@@ -0,0 +1,261 @@
package user
import (
"encoding/json"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"time"
"git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/compass"
)
// opaque500Body is the plugin's opaque 500 answer (wire.WriteOpaque500).
const opaque500Body = `{"error":true,"message":"Internal server error"}`
// TestRefreshRewritesAuthCookie pins the cookie-carried POST /refresh: when
// the token came from the auth.cookie cookie with no bearer header, that
// cookie is rewritten with the rotated token and the body is empty, whoever
// wrote the cookie; bearer and other-cookie refreshes stay body-only;
// refusals touch no cookie; and a broken auth.cookie block fails a cookie
// refresh before it rotates anything.
func TestRefreshRewritesAuthCookie(t *testing.T) {
e := newAdminEnv(t)
t.Run("token cookie rotates", func(t *testing.T) {
old := e.sessionLogin(t, "refresh-cookie-rotates")
rec := e.cookieSend(t, http.MethodPost, userRefreshPath, "token", old, nil)
if rec.Code != http.StatusOK {
t.Fatalf("refresh with the token cookie = %d %s", rec.Code, rec.Body.String())
}
assertBodyKeys(t, rec)
next := assertSessionCookie(t, rec, e.cookieConfig(t), 20160*60)
if next == old {
t.Fatal("the rewritten cookie carries the old token")
}
sub := parseToken(t, old)["sub"]
if got := parseToken(t, next)["sub"]; got != sub {
t.Fatalf("rotated token names %v, old one %v", got, sub)
}
fetch := e.cookieSend(t, http.MethodGet, userFetchPath, "token", next, nil)
if fetch.Code != http.StatusOK {
t.Fatalf("fetch with the rewritten cookie = %d %s", fetch.Code, fetch.Body.String())
}
if got := fetchedID(t, fetch); got != sub {
t.Fatalf("fetch served user %q, token names %v", got, sub)
}
if code := e.guarded(t, next, "token"); code != http.StatusOK {
t.Fatalf("jwt.auth with the rewritten cookie = %d", code)
}
if rec := e.cookieSend(t, http.MethodGet, userFetchPath, "token", old, nil); rec.Code != http.StatusUnauthorized {
t.Fatalf("fetch with the rotated-away token cookie = %d", rec.Code)
}
})
t.Run("auth_token cookie stays body-only", func(t *testing.T) {
old := e.sessionLogin(t, "refresh-cookie-auth-token")
rec := e.cookieSend(t, http.MethodPost, userRefreshPath, "auth_token", old, nil)
if rec.Code != http.StatusOK {
t.Fatalf("refresh with the auth_token cookie = %d %s", rec.Code, rec.Body.String())
}
body := assertBodyKeys(t, rec, "token")
if next, _ := body["token"].(string); next == "" || next == old {
t.Fatalf("refresh answered %s", rec.Body.String())
}
assertNoCookie(t, rec)
})
t.Run("bearer wins over the cookie", func(t *testing.T) {
a := e.sessionLogin(t, "refresh-cookie-bearer-a")
b := e.sessionLogin(t, "refresh-cookie-bearer-b")
rec := e.send(t, http.MethodPost, userRefreshPath, a, nil, "application/json", map[string]string{"Cookie": "token=" + b})
if rec.Code != http.StatusOK {
t.Fatalf("refresh with bearer A and cookie B = %d %s", rec.Code, rec.Body.String())
}
body := assertBodyKeys(t, rec, "token")
next, _ := body["token"].(string)
if got, want := parseToken(t, next)["sub"], parseToken(t, a)["sub"]; got != want {
t.Fatalf("refresh rotated user %v, bearer names %v", got, want)
}
assertNoCookie(t, rec)
if rec := e.cookieSend(t, http.MethodGet, userFetchPath, "token", b, nil); rec.Code != http.StatusOK {
t.Fatalf("fetch with cookie B after a bearer refresh = %d", rec.Code)
}
})
t.Run("bearer only", func(t *testing.T) {
token := e.sessionLogin(t, "refresh-cookie-bearer-only")
rec := e.refresh(t, token)
if rec.Code != http.StatusOK {
t.Fatalf("bearer refresh = %d %s", rec.Code, rec.Body.String())
}
assertBodyKeys(t, rec, "token")
assertNoCookie(t, rec)
})
t.Run("configured auth.cookie name", func(t *testing.T) {
custom := newAdminEnv(t, func(c *compass.Config) {
for key, value := range map[string]any{
"auth.cookie.name": "auth_token",
"golem15.user.jwt.refresh_ttl": 60,
} {
if err := c.Set(key, value); err != nil {
t.Fatal(err)
}
}
})
old := custom.sessionLogin(t, "refresh-cookie-custom")
rec := custom.cookieSend(t, http.MethodPost, userRefreshPath, "auth_token", old, nil)
if rec.Code != http.StatusOK {
t.Fatalf("refresh with the auth_token cookie = %d %s", rec.Code, rec.Body.String())
}
assertBodyKeys(t, rec)
next := assertSessionCookie(t, rec, custom.cookieConfig(t), 3600)
if next == old {
t.Fatal("the rewritten cookie carries the old token")
}
if rec := custom.cookieSend(t, http.MethodGet, userFetchPath, "auth_token", next, nil); rec.Code != http.StatusOK {
t.Fatalf("fetch with the rewritten auth_token cookie = %d", rec.Code)
}
other := custom.sessionLogin(t, "refresh-cookie-custom-token")
rec = custom.cookieSend(t, http.MethodPost, userRefreshPath, "token", other, nil)
if rec.Code != http.StatusOK {
t.Fatalf("refresh with the token cookie = %d %s", rec.Code, rec.Body.String())
}
assertBodyKeys(t, rec, "token")
assertNoCookie(t, rec)
})
t.Run("refusals set no cookie", func(t *testing.T) {
user := e.seedUser(t, "refresh-cookie-banned", nil)
e.seedThrottle(t, user, "", true, nil)
banned := userAudienceToken(t, userSub(user), "refresh-cookie-banned-"+adminStamp(), time.Now(), time.Now().Add(time.Hour))
rec := e.cookieSend(t, http.MethodPost, userRefreshPath, "token", banned, nil)
assertRefreshRefused(t, rec)
assertNoCookie(t, rec)
old := e.sessionLogin(t, "refresh-cookie-twice")
if rec := e.cookieSend(t, http.MethodPost, userRefreshPath, "token", old, nil); rec.Code != http.StatusOK {
t.Fatalf("first refresh = %d %s", rec.Code, rec.Body.String())
}
rec = e.cookieSend(t, http.MethodPost, userRefreshPath, "token", old, nil)
if rec.Code != http.StatusUnauthorized || rec.Body.String() != blacklistedBody {
t.Fatalf("second refresh of the same token = %d %s", rec.Code, rec.Body.String())
}
assertNoCookie(t, rec)
})
t.Run("broken auth.cookie", func(t *testing.T) {
broken := newAdminEnv(t, func(c *compass.Config) {
if err := c.Set("auth.cookie.same_site", "sideways"); err != nil {
t.Fatal(err)
}
})
token := broken.sessionLogin(t, "refresh-cookie-broken")
rec := broken.cookieSend(t, http.MethodPost, userRefreshPath, "token", token, nil)
if rec.Code != http.StatusInternalServerError || rec.Body.String() != opaque500Body {
t.Fatalf("cookie refresh with a broken auth.cookie = %d %s", rec.Code, rec.Body.String())
}
assertNoCookie(t, rec)
rec = broken.refresh(t, token)
if rec.Code != http.StatusOK {
t.Fatalf("bearer refresh after the 500 = %d %s", rec.Code, rec.Body.String())
}
if next, _ := assertBodyKeys(t, rec, "token")["token"].(string); next == "" {
t.Fatalf("bearer refresh answered %s", rec.Body.String())
}
})
}
// TestSessionTokenCookie is the carrier table of classes.SessionTokenCookie:
// it names the session cookie SessionToken read the token from, and "" when
// the bearer header supplied it or there is none.
func TestSessionTokenCookie(t *testing.T) {
cases := []struct {
name, auth, cookie, want string
}{
{"bearer plus a token cookie", "Bearer A", "token=B", ""},
{"empty bearer plus a token cookie", "Bearer ", "token=B", "token"},
{"basic auth plus a token cookie", "Basic xyz", "token=B", "token"},
{"only auth_token", "", "auth_token=C", "auth_token"},
{"token and auth_token", "", "token=B; auth_token=C", "token"},
{"empty token cookie", "", "token=; auth_token=X", "auth_token"},
{"no credentials", "", "", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, userRefreshPath, nil)
if tc.auth != "" {
r.Header.Set("Authorization", tc.auth)
}
if tc.cookie != "" {
r.Header.Set("Cookie", tc.cookie)
}
got := classes.SessionTokenCookie(r)
if got != tc.want {
t.Fatalf("SessionTokenCookie = %q, want %q", got, tc.want)
}
token := classes.SessionToken(r)
if got != "" {
c, err := r.Cookie(got)
if err != nil || strings.TrimSpace(c.Value) != token {
t.Fatalf("cookie %s does not carry SessionToken %q", got, token)
}
} else if token != "" && !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer "+token) {
t.Fatalf("SessionToken %q did not come from the bearer header", token)
}
})
}
if got := classes.SessionTokenCookie(nil); got != "" {
t.Fatalf("SessionTokenCookie(nil) = %q", got)
}
}
// assertSessionCookie requires exactly one cookie, the session cookie cfg
// writes with Max-Age maxAge, and returns its value.
func assertSessionCookie(t *testing.T, rec *httptest.ResponseRecorder, cfg bouncer.CookieConfig, maxAge int) string {
t.Helper()
cookies := rec.Result().Cookies()
if len(cookies) != 1 {
t.Fatalf("want one session cookie, got %v", rec.Result().Header.Values("Set-Cookie"))
}
c, want := cookies[0], cfg.TokenCookie("x", time.Hour)
if c.Name != cfg.Name || c.Value == "" || c.Path != want.Path || c.Domain != want.Domain ||
c.SameSite != want.SameSite || c.Secure != want.Secure || c.HttpOnly != want.HttpOnly || c.MaxAge != maxAge {
t.Fatalf("session cookie %s does not match %s with Max-Age %d", c, want, maxAge)
}
return c.Value
}
// assertNoCookie requires a response that sets no cookie.
func assertNoCookie(t *testing.T, rec *httptest.ResponseRecorder) {
t.Helper()
if cookies := rec.Result().Cookies(); len(cookies) != 0 || rec.Header().Get("Set-Cookie") != "" {
t.Fatalf("response sets cookies %v", rec.Result().Header.Values("Set-Cookie"))
}
}
// assertBodyKeys decodes the JSON object body and requires exactly keys.
func assertBodyKeys(t *testing.T, rec *httptest.ResponseRecorder, keys ...string) map[string]any {
t.Helper()
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil || body == nil {
t.Fatalf("body %s is not a JSON object: %v", rec.Body.String(), err)
}
got := make([]string, 0, len(body))
for key := range body {
got = append(got, key)
}
slices.Sort(got)
want := slices.Clone(keys)
slices.Sort(want)
if !slices.Equal(got, want) {
t.Fatalf("body keys %v, want %v: %s", got, want, rec.Body.String())
}
return body
}

View File

@@ -30,9 +30,10 @@ var sessionCookieNames = []string{"token", "auth_token"}
// header first, then the token and auth_token cookies. A cookie-only session
// that jwt.auth accepts is served; no credentials stays 401 with the old
// bodies; a bearer header wins over a cookie exactly as on jwt.auth; a
// cookie-carried token is refused for the same reasons as a bearer one; and a
// cookie-carried /refresh answers the new token in the body and sets no
// cookie.
// cookie-carried token is refused for the same reasons as a bearer one; a
// /refresh through the token cookie (the auth.cookie default) gets that
// cookie back with the rotated token and an empty body, and one through
// auth_token gets the rotated token in the body and no cookie.
func TestSessionRoutesAcceptCookies(t *testing.T) {
e := newAdminEnv(t)
@@ -99,19 +100,28 @@ func TestSessionRoutesAcceptCookies(t *testing.T) {
if rec.Code != http.StatusOK {
t.Fatalf("refresh with the %s cookie = %d %s", name, rec.Code, rec.Body.String())
}
var body struct {
Token string `json:"token"`
var next string
if name == "token" {
if rec.Body.String() != `{}` {
t.Fatalf("refresh with the token cookie answered %s", rec.Body.String())
}
next = assertSessionCookie(t, rec, e.cookieConfig(t), 20160*60)
} else {
var body struct {
Token string `json:"token"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
next = body.Token
if cookies := rec.Result().Cookies(); len(cookies) != 0 {
t.Fatalf("refresh set cookies %v", cookies)
}
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body.Token == "" || body.Token == token {
if next == "" || next == token {
t.Fatalf("refresh answered %s", rec.Body.String())
}
if cookies := rec.Result().Cookies(); len(cookies) != 0 {
t.Fatalf("refresh set cookies %v", cookies)
}
if code := e.fetchCode(t, body.Token); code != http.StatusOK {
if code := e.fetchCode(t, next); code != http.StatusOK {
t.Fatalf("fetch with the refreshed token = %d", code)
}
if rec := e.cookieSend(t, http.MethodGet, userFetchPath, name, token, nil); rec.Code != http.StatusUnauthorized {