From 18b2e851063f3a50b7a13c87413ac4ae3ece9998 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 24 Sep 2026 17:20:57 +0200 Subject: [PATCH] feat(09-01): reject cross-audience tokens on both guards - Frontend verification accepts a missing audience for PHP tokens - An explicit audience must match the guard, even when the secret is shared --- bouncer/jwt.go | 32 +++++++++++++++----------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/bouncer/jwt.go b/bouncer/jwt.go index 7fcfb12..30e014b 100644 --- a/bouncer/jwt.go +++ b/bouncer/jwt.go @@ -153,23 +153,11 @@ func (g *jwtGuard) WriteUnauthorized(w http.ResponseWriter, err error) { } // Verify parses a token with HS256 pinned and a required exp and sub. +// A missing audience is accepted for PHP-issued frontend tokens. An explicit +// audience must be AudienceUser, so a backend token cannot pass this guard. func Verify(tokenString, secret string) (string, error) { - if strings.TrimSpace(secret) == "" { - return "", fmt.Errorf("bouncer: jwt secret is empty") - } - parser := jwt.NewParser(jwt.WithValidMethods([]string{"HS256"}), jwt.WithExpirationRequired()) - claims := jwt.MapClaims{} - _, err := parser.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (any, error) { - return []byte(secret), nil - }) - if err != nil { - return "", mapJWTError(err) - } - sub := subject(claims) - if sub == "" { - return "", errors.New(msgRequiredClaims) - } - return sub, nil + sub, _, _, _, err := verifyClaims(tokenString, secret, "") + return sub, err } // VerifyClaims parses a token the same way Verify does and also returns iat, exp, and jti. @@ -198,7 +186,7 @@ func verifyClaims(tokenString, secret, audience string) (sub string, iat, exp ti if err != nil { return "", time.Time{}, time.Time{}, "", mapJWTError(err) } - if audience != "" && !audienceMatches(claims, audience) { + if !frontendAudienceOK(claims, audience) { return "", time.Time{}, time.Time{}, "", errors.New(msgBadSignature) } sub = subject(claims) @@ -211,6 +199,16 @@ func verifyClaims(tokenString, secret, audience string) (sub string, iat, exp ti return sub, iat, exp, jti, nil } +// frontendAudienceOK accepts a missing audience when expected is empty +// (legacy frontend tokens) and otherwise requires expected. +func frontendAudienceOK(claims jwt.MapClaims, expected string) bool { + auds := claimAudiences(claims) + if expected == "" { + return len(auds) == 0 || audienceMatches(claims, AudienceUser) + } + return audienceMatches(claims, expected) +} + func audienceMatches(claims jwt.MapClaims, expected string) bool { for _, aud := range claimAudiences(claims) { if aud == expected {