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/rsa"
"encoding/base64"
"errors"
"fmt"
"math"
"math/big"
@@ -195,11 +196,16 @@ func (j *JWT) Verify(issuerURL, expectedAudience string) error {
//
// Returns:
// - 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 {
switch aud := tokenAudience.(type) {
case string:
if aud != expectedAudience {
return fmt.Errorf("invalid audience")
return errInvalidAudience
}
case []interface{}:
found := false
@@ -210,7 +216,7 @@ func verifyAudience(tokenAudience interface{}, expectedAudience string) error {
}
}
if !found {
return fmt.Errorf("invalid audience")
return errInvalidAudience
}
default:
return fmt.Errorf("invalid 'aud' claim type")
+2 -2
View File
@@ -15,6 +15,7 @@ package traefikoidc
import (
"encoding/base64"
"encoding/json"
"errors"
"strings"
"time"
)
@@ -266,8 +267,7 @@ func (t *TraefikOidc) validateStandardTokensRS(rs *requestState) (bool, bool, bo
var accessVerifyErr error
if err := t.verifyToken(rs.accessToken); err != nil {
accessVerifyErr = err
errMsg := err.Error()
if strings.Contains(errMsg, "invalid audience") || strings.Contains(errMsg, "audience") {
if errors.Is(err, errInvalidAudience) {
if t.strictAudienceValidation {
if rs.refreshToken != "" {
return false, true, false