fix(09): WR-14 let logout revoke an expired token that is still refreshable and always clear the cookie

This commit is contained in:
Jakub Zych
2026-10-01 21:23:42 +02:00
parent c9bb14944a
commit 299d220b51
9 changed files with 157 additions and 9 deletions

View File

@@ -88,6 +88,7 @@ func Login(secret string) (string, error) {
| `bouncer.VerifyClaimsAudience` | `bouncer.VerifyClaims` with a required audience. |
| `bouncer.Refresh` | Reissues a frontend token inside the refresh window and blacklists the old jti. |
| `bouncer.RefreshAudience` | Refresh for a token that carries the given audience. |
| `bouncer.VerifyRefreshableClaimsAudience` | Verifies signature and audience without checking `exp`, while the refresh window is open; for revoking a refreshable token on logout. |
| `bouncer.RefreshAudienceFor` | `bouncer.RefreshAudience` plus the guard's user checks. |
| `bouncer.ErrSubjectRejected` | The token subject is not a loadable user, or the token predates the user's cutoff. |
| `bouncer.AudienceUser`, `bouncer.AudienceBackend` | The frontend and admin audience values. |

View File

@@ -51,6 +51,42 @@ func RefreshAudienceFor(ctx context.Context, users UserProvider, secret, tokenSt
return refreshAudience(secret, tokenString, audience, false, refreshTTL, bl, grace, issuerURL, check)
}
// VerifyRefreshableClaimsAudience verifies the signature and the required
// audience of a token without checking exp, and accepts it while its refresh
// window (iat plus refreshTTL) is open: exactly the tokens RefreshAudience still
// reissues. It lets a logout revoke a token whose access lifetime has passed but
// which could still be refreshed. It returns the subject, iat, exp and jti; a
// token with no jti, no iat or no exp is refused.
func VerifyRefreshableClaimsAudience(tokenString, secret, audience string, refreshTTL time.Duration) (sub string, iat, exp time.Time, jti string, err error) {
if strings.TrimSpace(audience) == "" {
return "", time.Time{}, time.Time{}, "", errors.New("bouncer: jwt audience is empty")
}
if strings.TrimSpace(secret) == "" {
return "", time.Time{}, time.Time{}, "", errors.New("bouncer: jwt secret is empty")
}
parser := jwt.NewParser(jwt.WithValidMethods([]string{"HS256"}), jwt.WithoutClaimsValidation())
claims := jwt.MapClaims{}
if _, err := parser.ParseWithClaims(tokenString, claims, func(*jwt.Token) (any, error) {
return []byte(secret), nil
}); err != nil {
return "", time.Time{}, time.Time{}, "", mapJWTError(err)
}
if !audienceMatches(claims, audience) {
return "", time.Time{}, time.Time{}, "", errors.New(msgBadSignature)
}
sub = subject(claims)
jti, _ = claims["jti"].(string)
iat, iatOK := claimTime(claims, "iat")
exp, expOK := claimTime(claims, "exp")
if sub == "" || jti == "" || !iatOK || !expOK {
return "", time.Time{}, time.Time{}, "", errors.New(msgRequiredClaims)
}
if time.Now().After(iat.Add(refreshTTL)) {
return "", time.Time{}, time.Time{}, "", errors.New("Token has expired and can no longer be refreshed")
}
return sub, iat, exp, jti, nil
}
// refreshAudience holds the shared refresh flow. check, when non-nil, runs
// after every token-only check and immediately before minting.
func refreshAudience(secret, tokenString, audience string, allowMissing bool, refreshTTL time.Duration, bl BlacklistStore, grace time.Duration, issuerURL string, check func(sub string, iat time.Time) error) (string, error) {

View File

@@ -259,3 +259,48 @@ func TestRefreshAudienceForSubject(t *testing.T) {
}
})
}
func TestVerifyRefreshableClaimsAudience(t *testing.T) {
now := time.Now()
token := func(method jwt.SigningMethod, key []byte, claims jwt.MapClaims) string {
return sign(t, method, claims, key)
}
base := func() jwt.MapClaims {
return jwt.MapClaims{"sub": "7", "jti": "rj", "aud": AudienceBackend, "iat": now.Add(-10 * time.Minute).Unix(), "exp": now.Add(-time.Minute).Unix()}
}
sub, iat, exp, jti, err := VerifyRefreshableClaimsAudience(token(jwt.SigningMethodHS256, []byte(secret), base()), secret, AudienceBackend, time.Hour)
if err != nil || sub != "7" || jti != "rj" || !exp.Before(now) || !iat.Before(exp) {
t.Fatalf("expired token inside the refresh window: %q %v %v %q %v", sub, iat, exp, jti, err)
}
// VerifyClaimsAudience rejects the same token: the difference is the point.
if _, _, _, _, err := VerifyClaimsAudience(token(jwt.SigningMethodHS256, []byte(secret), base()), secret, AudienceBackend); err == nil {
t.Fatal("VerifyClaimsAudience accepted an expired token")
}
tests := map[string]string{
"wrong secret": token(jwt.SigningMethodHS256, []byte("another-secret-value-for-the-test!"), base()),
"empty token": "",
"window closed": token(jwt.SigningMethodHS256, []byte(secret), func() jwt.MapClaims {
c := base()
c["iat"] = now.Add(-2 * time.Hour).Unix()
return c
}()),
"wrong audience": token(jwt.SigningMethodHS256, []byte(secret), func() jwt.MapClaims { c := base(); c["aud"] = AudienceUser; return c }()),
"missing audience": token(jwt.SigningMethodHS256, []byte(secret), func() jwt.MapClaims {
c := base()
delete(c, "aud")
return c
}()),
"missing jti": token(jwt.SigningMethodHS256, []byte(secret), func() jwt.MapClaims { c := base(); delete(c, "jti"); return c }()),
"missing iat": token(jwt.SigningMethodHS256, []byte(secret), func() jwt.MapClaims { c := base(); delete(c, "iat"); return c }()),
}
for name, raw := range tests {
if _, _, _, _, err := VerifyRefreshableClaimsAudience(raw, secret, AudienceBackend, time.Hour); err == nil {
t.Fatalf("%s: token was accepted", name)
}
}
if _, _, _, _, err := VerifyRefreshableClaimsAudience("x", secret, "", time.Hour); err == nil {
t.Fatal("empty audience was accepted")
}
}