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
This commit is contained in:
Jakub Zych
2026-09-24 17:20:57 +02:00
parent 8c1de83f01
commit 18b2e85106

View File

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