support multiple audiences with jwt authenticator

Signed-off-by: Anish Ramasekar <anish.ramasekar@gmail.com>
This commit is contained in:
Anish Ramasekar 2024-01-24 17:15:11 +00:00
parent 19da90d639
commit 18c563546a
No known key found for this signature in database
GPG Key ID: E96F745A34A409C2
5 changed files with 182 additions and 23 deletions

View File

@ -143,21 +143,23 @@ func validateAudiences(audiences []string, audienceMatchPolicy api.AudienceMatch
allErrs = append(allErrs, field.Required(fldPath, fmt.Sprintf(atLeastOneRequiredErrFmt, fldPath)))
return allErrs
}
// This stricter validation is because the --oidc-client-id flag option is singular.
// This will be removed when we support multiple audiences with the StructuredAuthenticationConfiguration feature gate.
if len(audiences) > 1 {
allErrs = append(allErrs, field.TooMany(fldPath, len(audiences), 1))
return allErrs
}
seenAudiences := sets.NewString()
for i, audience := range audiences {
fldPath := fldPath.Index(i)
if len(audience) == 0 {
allErrs = append(allErrs, field.Required(fldPath, "audience can't be empty"))
}
if seenAudiences.Has(audience) {
allErrs = append(allErrs, field.Duplicate(fldPath, audience))
}
seenAudiences.Insert(audience)
}
if len(audienceMatchPolicy) > 0 && audienceMatchPolicy != api.AudienceMatchPolicyMatchAny {
if len(audiences) > 1 && audienceMatchPolicy != api.AudienceMatchPolicyMatchAny {
allErrs = append(allErrs, field.Invalid(audienceMatchPolicyFldPath, audienceMatchPolicy, "audienceMatchPolicy must be MatchAny for multiple audiences"))
}
if len(audiences) == 1 && (len(audienceMatchPolicy) > 0 && audienceMatchPolicy != api.AudienceMatchPolicyMatchAny) {
allErrs = append(allErrs, field.Invalid(audienceMatchPolicyFldPath, audienceMatchPolicy, "audienceMatchPolicy must be empty or MatchAny for single audience"))
}

View File

@ -304,6 +304,23 @@ func TestValidateAudiences(t *testing.T) {
matchPolicy: "MatchAny",
want: "",
},
{
name: "duplicate audience",
in: []string{"audience", "audience"},
matchPolicy: "MatchAny",
want: `issuer.audiences[1]: Duplicate value: "audience"`,
},
{
name: "match policy not set with multiple audiences",
in: []string{"audience1", "audience2"},
want: `issuer.audienceMatchPolicy: Invalid value: "": audienceMatchPolicy must be MatchAny for multiple audiences`,
},
{
name: "valid multiple audiences",
in: []string{"audience1", "audience2"},
matchPolicy: "MatchAny",
want: "",
},
}
for _, tt := range testCases {

View File

@ -49,6 +49,7 @@ import (
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/util/net"
"k8s.io/apimachinery/pkg/util/sets"
"k8s.io/apimachinery/pkg/util/wait"
"k8s.io/apiserver/pkg/apis/apiserver"
apiservervalidation "k8s.io/apiserver/pkg/apis/apiserver/validation"
@ -99,12 +100,12 @@ type CAContentProvider interface {
// initVerifier creates a new ID token verifier for the given configuration and issuer URL. On success, calls setVerifier with the
// resulting verifier.
func initVerifier(ctx context.Context, config *oidc.Config, iss string) (*oidc.IDTokenVerifier, error) {
func initVerifier(ctx context.Context, config *oidc.Config, iss string, audiences sets.Set[string]) (*idTokenVerifier, error) {
provider, err := oidc.NewProvider(ctx, iss)
if err != nil {
return nil, fmt.Errorf("init verifier failed: %v", err)
}
return provider.Verifier(config), nil
return &idTokenVerifier{provider.Verifier(config), audiences}, nil
}
// asyncIDTokenVerifier is an ID token verifier that allows async initialization
@ -115,13 +116,13 @@ type asyncIDTokenVerifier struct {
// v is the ID token verifier initialized asynchronously. It remains nil
// up until it is eventually initialized.
// Guarded by m
v *oidc.IDTokenVerifier
v *idTokenVerifier
}
// newAsyncIDTokenVerifier creates a new asynchronous token verifier. The
// verifier is available immediately, but may remain uninitialized for some time
// after creation.
func newAsyncIDTokenVerifier(ctx context.Context, c *oidc.Config, iss string) *asyncIDTokenVerifier {
func newAsyncIDTokenVerifier(ctx context.Context, c *oidc.Config, iss string, audiences sets.Set[string]) *asyncIDTokenVerifier {
t := &asyncIDTokenVerifier{}
sync := make(chan struct{})
@ -129,7 +130,7 @@ func newAsyncIDTokenVerifier(ctx context.Context, c *oidc.Config, iss string) *a
// verifier, or until context canceled.
initFn := func() (done bool, err error) {
klog.V(4).Infof("oidc authenticator: attempting init: iss=%v", iss)
v, err := initVerifier(ctx, c, iss)
v, err := initVerifier(ctx, c, iss, audiences)
if err != nil {
klog.Errorf("oidc authenticator: async token verifier for issuer: %q: %v", iss, err)
return false, nil
@ -155,7 +156,7 @@ func newAsyncIDTokenVerifier(ctx context.Context, c *oidc.Config, iss string) *a
}
// verifier returns the underlying ID token verifier, or nil if one is not yet initialized.
func (a *asyncIDTokenVerifier) verifier() *oidc.IDTokenVerifier {
func (a *asyncIDTokenVerifier) verifier() *idTokenVerifier {
a.m.Lock()
defer a.m.Unlock()
return a.v
@ -181,13 +182,20 @@ type Authenticator struct {
requiredClaims map[string]string
}
func (a *Authenticator) setVerifier(v *oidc.IDTokenVerifier) {
// idTokenVerifier is a wrapper around oidc.IDTokenVerifier. It uses the oidc.IDTokenVerifier
// to verify the raw ID token and then performs audience validation locally.
type idTokenVerifier struct {
verifier *oidc.IDTokenVerifier
audiences sets.Set[string]
}
func (a *Authenticator) setVerifier(v *idTokenVerifier) {
a.verifier.Store(v)
}
func (a *Authenticator) idTokenVerifier() (*oidc.IDTokenVerifier, bool) {
func (a *Authenticator) idTokenVerifier() (*idTokenVerifier, bool) {
if v := a.verifier.Load(); v != nil {
return v.(*oidc.IDTokenVerifier), true
return v.(*idTokenVerifier), true
}
return nil, false
}
@ -265,16 +273,26 @@ func New(opts Options) (*Authenticator, error) {
now = time.Now
}
audiences := sets.New[string](opts.JWTAuthenticator.Issuer.Audiences...)
verifierConfig := &oidc.Config{
ClientID: opts.JWTAuthenticator.Issuer.Audiences[0],
SupportedSigningAlgs: supportedSigningAlgs,
Now: now,
}
if audiences.Len() > 1 {
verifierConfig.ClientID = ""
// SkipClientIDCheck is set to true because we want to support multiple audiences
// in the authentication configuration.
// The go oidc library does not support validating
// multiple audiences, so we have to skip the client ID check and do it ourselves.
// xref: https://github.com/coreos/go-oidc/issues/397
verifierConfig.SkipClientIDCheck = true
}
var resolver *claimResolver
groupsClaim := opts.JWTAuthenticator.ClaimMappings.Groups.Claim
if groupsClaim != "" {
resolver = newClaimResolver(groupsClaim, client, verifierConfig)
resolver = newClaimResolver(groupsClaim, client, verifierConfig, audiences)
}
requiredClaims := make(map[string]string)
@ -294,7 +312,10 @@ func New(opts Options) (*Authenticator, error) {
if opts.KeySet != nil {
// We already have a key set, synchronously initialize the verifier.
authenticator.setVerifier(oidc.NewVerifier(opts.JWTAuthenticator.Issuer.URL, opts.KeySet, verifierConfig))
authenticator.setVerifier(&idTokenVerifier{
oidc.NewVerifier(opts.JWTAuthenticator.Issuer.URL, opts.KeySet, verifierConfig),
audiences,
})
} else {
// Asynchronously attempt to initialize the authenticator. This enables
// self-hosted providers, providers that run on top of Kubernetes itself.
@ -306,7 +327,7 @@ func New(opts Options) (*Authenticator, error) {
}
verifier := provider.Verifier(verifierConfig)
authenticator.setVerifier(verifier)
authenticator.setVerifier(&idTokenVerifier{verifier, audiences})
return true, nil
}, ctx.Done())
}
@ -374,6 +395,10 @@ type claimResolver struct {
// claim is the distributed claim that may be resolved.
claim string
// audiences is the set of acceptable audiences the JWT must be issued to.
// At least one of the entries must match the "aud" claim in presented JWTs.
audiences sets.Set[string]
// client is the to use for resolving distributed claims
client *http.Client
@ -390,19 +415,25 @@ type claimResolver struct {
}
// newClaimResolver creates a new resolver for distributed claims.
func newClaimResolver(claim string, client *http.Client, config *oidc.Config) *claimResolver {
return &claimResolver{claim: claim, client: client, config: config, verifierPerIssuer: map[string]*asyncIDTokenVerifier{}}
func newClaimResolver(claim string, client *http.Client, config *oidc.Config, audiences sets.Set[string]) *claimResolver {
return &claimResolver{
claim: claim,
audiences: audiences,
client: client,
config: config,
verifierPerIssuer: map[string]*asyncIDTokenVerifier{},
}
}
// Verifier returns either the verifier for the specified issuer, or error.
func (r *claimResolver) Verifier(iss string) (*oidc.IDTokenVerifier, error) {
func (r *claimResolver) Verifier(iss string) (*idTokenVerifier, error) {
r.m.Lock()
av := r.verifierPerIssuer[iss]
if av == nil {
// This lazy init should normally be very quick.
// TODO: Make this context cancelable.
ctx := oidc.ClientContext(context.Background(), r.client)
av = newAsyncIDTokenVerifier(ctx, r.config, iss)
av = newAsyncIDTokenVerifier(ctx, r.config, iss, r.audiences)
r.verifierPerIssuer[iss] = av
}
r.m.Unlock()
@ -520,6 +551,39 @@ func (r *claimResolver) resolve(ctx context.Context, endpoint endpoint, allClaim
return nil
}
func (v *idTokenVerifier) Verify(ctx context.Context, rawIDToken string) (*oidc.IDToken, error) {
t, err := v.verifier.Verify(ctx, rawIDToken)
if err != nil {
return nil, err
}
if err := v.verifyAudience(t); err != nil {
return nil, err
}
return t, nil
}
// verifyAudience verifies the audience field in the ID token matches the expected audience.
// This is added based on https://github.com/coreos/go-oidc/blob/b203e58c24394ddf5e816706a7645f01280245c7/oidc/verify.go#L275-L281
// with the difference that we allow multiple audiences.
//
// AuthenticationConfiguration has a audienceMatchPolicy field, but the only supported value now is "MatchAny".
// So, The default match behavior is to match at least one of the audiences in the ID token.
func (v *idTokenVerifier) verifyAudience(t *oidc.IDToken) error {
// We validate audience field is not empty in the authentication configuration.
// This check ensures callers of "Verify" using idTokenVerifier are not passing
// an empty audience.
if v.audiences.Len() == 0 {
return fmt.Errorf("oidc: invalid configuration, audiences cannot be empty")
}
for _, aud := range t.Audience {
if v.audiences.Has(aud) {
return nil
}
}
return fmt.Errorf("oidc: expected audience in %q got %q", sets.List(v.audiences), t.Audience)
}
func (a *Authenticator) AuthenticateToken(ctx context.Context, token string) (*authenticator.Response, bool, error) {
if !hasCorrectIssuer(a.jwtAuthenticator.Issuer.URL, token) {
return nil, false, nil

View File

@ -1521,6 +1521,70 @@ func TestToken(t *testing.T) {
Name: "jane",
},
},
{
name: "multiple-audiences in authentication config",
options: Options{
JWTAuthenticator: apiserver.JWTAuthenticator{
Issuer: apiserver.Issuer{
URL: "https://auth.example.com",
Audiences: []string{"random-client", "my-client"},
AudienceMatchPolicy: "MatchAny",
},
ClaimMappings: apiserver.ClaimMappings{
Username: apiserver.PrefixedClaimOrExpression{
Claim: "username",
Prefix: pointer.String(""),
},
},
},
now: func() time.Time { return now },
},
signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256),
pubKeys: []*jose.JSONWebKey{
loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256),
},
claims: fmt.Sprintf(`{
"iss": "https://auth.example.com",
"aud": ["not-my-client", "my-client"],
"azp": "not-my-client",
"username": "jane",
"exp": %d
}`, valid.Unix()),
want: &user.DefaultInfo{
Name: "jane",
},
},
{
name: "multiple-audiences in authentication config, no match",
options: Options{
JWTAuthenticator: apiserver.JWTAuthenticator{
Issuer: apiserver.Issuer{
URL: "https://auth.example.com",
Audiences: []string{"random-client", "my-client"},
AudienceMatchPolicy: "MatchAny",
},
ClaimMappings: apiserver.ClaimMappings{
Username: apiserver.PrefixedClaimOrExpression{
Claim: "username",
Prefix: pointer.String(""),
},
},
},
now: func() time.Time { return now },
},
signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256),
pubKeys: []*jose.JSONWebKey{
loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256),
},
claims: fmt.Sprintf(`{
"iss": "https://auth.example.com",
"aud": ["not-my-client"],
"azp": "not-my-client",
"username": "jane",
"exp": %d
}`, valid.Unix()),
wantErr: `oidc: verify token: oidc: expected audience in ["my-client" "random-client"] got ["not-my-client"]`,
},
{
name: "invalid-issuer",
options: Options{

View File

@ -432,6 +432,8 @@ jwt:
url: %s
audiences:
- %s
- another-audience
audienceMatchPolicy: MatchAny
certificateAuthority: |
%s
claimMappings:
@ -475,6 +477,8 @@ jwt:
url: %s
audiences:
- %s
- another-audience
audienceMatchPolicy: MatchAny
certificateAuthority: |
%s
claimMappings:
@ -522,6 +526,8 @@ jwt:
url: %s
audiences:
- %s
- another-audience
audienceMatchPolicy: MatchAny
certificateAuthority: |
%s
claimMappings:
@ -565,6 +571,8 @@ jwt:
url: %s
audiences:
- %s
- another-audience
audienceMatchPolicy: MatchAny
certificateAuthority: |
%s
claimMappings:
@ -621,6 +629,8 @@ jwt:
url: %s
audiences:
- %s
- another-audience
audienceMatchPolicy: MatchAny
certificateAuthority: |
%s
claimMappings:
@ -668,6 +678,8 @@ jwt:
url: %s
audiences:
- %s
- another-audience
audienceMatchPolicy: MatchAny
certificateAuthority: |
%s
claimMappings: