fix: a device revoke cannot lose to a concurrent refresh

The revoke now reads the device's jti with UPDATE ... RETURNING under the
row lock, and the refresh's hand-over (classes.FollowDeviceJTI) reports a
session revoked meanwhile, so the refresh answers the blacklisted-token
401 without a token. A hand-over error fails the refresh closed instead
of leaving the session on a stale jti.
This commit is contained in:
2026-10-26 16:00:43 +01:00
parent 1f3266a417
commit cc3636cea9
5 changed files with 187 additions and 45 deletions

View File

@@ -300,29 +300,52 @@ func Refresh(app *backpack.App) http.HandlerFunc {
})
return
}
// A device session follows its token through refresh (D-12b), so a
// revoke blacklists the token the device holds now. Reading the old
// jti unverified is safe: bouncer.Refresh has just verified it. The
// hand-over and the revoke take the session's row lock, so whichever
// runs second sees the other: a device revoked after bouncer.Refresh
// checked the blacklist is refused here with the blacklisted-token
// answer and the new token is never sent. The hand-over fails
// closed: a database error answers the opaque 500 (the presented
// token is already rotated out), never a token the device row does
// not follow. With golem15.user.jwt.blacklist_grace above 0 two
// concurrent refreshes of one token can fork a device's lineage and
// only one fork is followed; a password change still ends both
// (tokens_valid_after). Log lines carry no token or jti.
if oldJTI := unverifiedJTI(raw); oldJTI != "" {
_, _, _, newJTI, err := bouncer.VerifyClaims(next, secret)
if err != nil {
pluginLog.Error("golem15.user: read the refreshed token", "user_id", userID, "err", err)
writeOpaque500(w)
return
}
revoked, err := classes.FollowDeviceJTI(r.Context(), gdb, oldJTI, newJTI)
if err != nil {
pluginLog.Error("golem15.user: hand a device session over on refresh", "user_id", userID, "err", err)
writeOpaque500(w)
return
}
if revoked {
writeJSON(w, http.StatusUnauthorized, map[string]any{
"error": "Could not refresh token",
"msg": refreshBlacklistedMsg,
})
return
}
}
// A failed last_seen write is logged and never fails the refresh.
if err := classes.TouchLastSeen(r.Context(), gdb, userID); err != nil {
pluginLog.Warn("golem15.user: stamp last_seen on refresh", "user_id", userID, "err", err)
}
// A device session follows its token through refresh (D-12b), so a
// revoke blacklists the token the device holds now. Reading the old
// jti unverified is safe: bouncer.Refresh has just verified it. With
// golem15.user.jwt.blacklist_grace above 0 two concurrent refreshes of
// one token can fork a device's lineage and only one fork is
// followed; a password change still ends both (tokens_valid_after).
// A failure is logged without any token or jti and never fails the
// refresh.
if oldJTI := unverifiedJTI(raw); oldJTI != "" {
if _, _, _, newJTI, err := bouncer.VerifyClaims(next, secret); err == nil {
if err := classes.HandOverDeviceJTI(r.Context(), gdb, oldJTI, newJTI); err != nil {
pluginLog.Warn("golem15.user: hand a device session over on refresh", "user_id", userID, "err", err)
}
}
}
writeSession(w, app, rotate, next, map[string]any{})
}
}
// refreshBlacklistedMsg is bouncer.Refresh's message for a blacklisted
// token, which POST /refresh also answers for a revoked device session.
const refreshBlacklistedMsg = "The token has been blacklisted"
// unverifiedJTI returns the jti claim of a token without verifying it, ""
// when there is none. Only call it on a token that was verified already.
func unverifiedJTI(raw string) string {