fix(security): classify audience failures by sentinel error

The lenient-audience path chose itself when the verify error text
contained the word audience. The key lookup error embeds the kid from the
unverified token header. A kid of audience made a failed signature look
like an audience-only failure. verifyAudience now returns a sentinel and
the caller uses errors.Is.
This commit is contained in:
2026-11-03 01:24:28 +00:00
parent 92c311fde1
commit fd3fbbdbab
3 changed files with 34 additions and 4 deletions
+24
View File
@@ -0,0 +1,24 @@
package traefikoidc
import (
"errors"
"fmt"
"testing"
)
// Only a real audience mismatch may select the lenient-audience path; an
// error text that merely embeds the attacker-chosen kid must not.
func TestAudienceErrorClassifiedBySentinel(t *testing.T) {
if !errors.Is(fmt.Errorf("standard claim verification failed: %w", verifyAudience("x", "y")), errInvalidAudience) {
t.Fatal("audience mismatch not recognized through wrapping")
}
if errors.Is(verifyAudience("y", "y"), errInvalidAudience) {
t.Fatal("matching audience classified as mismatch")
}
for _, kid := range []string{"audience", "invalid audience"} {
err := fmt.Errorf("failed to get public key: no matching public key found for kid: %s", kid)
if errors.Is(err, errInvalidAudience) {
t.Errorf("kid %q fooled the audience classifier", kid)
}
}
}
+8 -2
View File
@@ -6,6 +6,7 @@ import (
"crypto/ecdsa" "crypto/ecdsa"
"crypto/rsa" "crypto/rsa"
"encoding/base64" "encoding/base64"
"errors"
"fmt" "fmt"
"math" "math"
"math/big" "math/big"
@@ -195,11 +196,16 @@ func (j *JWT) Verify(issuerURL, expectedAudience string) error {
// //
// Returns: // Returns:
// - An error if the claim type is invalid or the expected audience is not present // - An error if the claim type is invalid or the expected audience is not present
//
// errInvalidAudience marks a token whose aud does not match; callers match it
// with errors.Is, never by message text.
var errInvalidAudience = errors.New("invalid audience")
func verifyAudience(tokenAudience interface{}, expectedAudience string) error { func verifyAudience(tokenAudience interface{}, expectedAudience string) error {
switch aud := tokenAudience.(type) { switch aud := tokenAudience.(type) {
case string: case string:
if aud != expectedAudience { if aud != expectedAudience {
return fmt.Errorf("invalid audience") return errInvalidAudience
} }
case []interface{}: case []interface{}:
found := false found := false
@@ -210,7 +216,7 @@ func verifyAudience(tokenAudience interface{}, expectedAudience string) error {
} }
} }
if !found { if !found {
return fmt.Errorf("invalid audience") return errInvalidAudience
} }
default: default:
return fmt.Errorf("invalid 'aud' claim type") return fmt.Errorf("invalid 'aud' claim type")
+2 -2
View File
@@ -15,6 +15,7 @@ package traefikoidc
import ( import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors"
"strings" "strings"
"time" "time"
) )
@@ -266,8 +267,7 @@ func (t *TraefikOidc) validateStandardTokensRS(rs *requestState) (bool, bool, bo
var accessVerifyErr error var accessVerifyErr error
if err := t.verifyToken(rs.accessToken); err != nil { if err := t.verifyToken(rs.accessToken); err != nil {
accessVerifyErr = err accessVerifyErr = err
errMsg := err.Error() if errors.Is(err, errInvalidAudience) {
if strings.Contains(errMsg, "invalid audience") || strings.Contains(errMsg, "audience") {
if t.strictAudienceValidation { if t.strictAudienceValidation {
if rs.refreshToken != "" { if rs.refreshToken != "" {
return false, true, false return false, true, false