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:
@@ -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.
|
// 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) {
|
func Verify(tokenString, secret string) (string, error) {
|
||||||
if strings.TrimSpace(secret) == "" {
|
sub, _, _, _, err := verifyClaims(tokenString, secret, "")
|
||||||
return "", fmt.Errorf("bouncer: jwt secret is empty")
|
return sub, err
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// VerifyClaims parses a token the same way Verify does and also returns iat, exp, and jti.
|
// 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 {
|
if err != nil {
|
||||||
return "", time.Time{}, time.Time{}, "", mapJWTError(err)
|
return "", time.Time{}, time.Time{}, "", mapJWTError(err)
|
||||||
}
|
}
|
||||||
if audience != "" && !audienceMatches(claims, audience) {
|
if !frontendAudienceOK(claims, audience) {
|
||||||
return "", time.Time{}, time.Time{}, "", errors.New(msgBadSignature)
|
return "", time.Time{}, time.Time{}, "", errors.New(msgBadSignature)
|
||||||
}
|
}
|
||||||
sub = subject(claims)
|
sub = subject(claims)
|
||||||
@@ -211,6 +199,16 @@ func verifyClaims(tokenString, secret, audience string) (sub string, iat, exp ti
|
|||||||
return sub, iat, exp, jti, nil
|
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 {
|
func audienceMatches(claims jwt.MapClaims, expected string) bool {
|
||||||
for _, aud := range claimAudiences(claims) {
|
for _, aud := range claimAudiences(claims) {
|
||||||
if aud == expected {
|
if aud == expected {
|
||||||
|
|||||||
Reference in New Issue
Block a user