mirror of
https://github.com/k3s-io/kubernetes.git
synced 2025-08-04 09:49:50 +00:00
support multiple audiences with jwt authenticator
Signed-off-by: Anish Ramasekar <anish.ramasekar@gmail.com>
This commit is contained in:
parent
19da90d639
commit
18c563546a
@ -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"))
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
|
@ -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{
|
||||
|
@ -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:
|
||||
|
Loading…
Reference in New Issue
Block a user