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

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