diff --git a/pkg/kubeapiserver/authenticator/config.go b/pkg/kubeapiserver/authenticator/config.go index 4e36040be8e..5f77d2babc1 100644 --- a/pkg/kubeapiserver/authenticator/config.go +++ b/pkg/kubeapiserver/authenticator/config.go @@ -19,11 +19,11 @@ package authenticator import ( "errors" "fmt" - "os" "time" utilnet "k8s.io/apimachinery/pkg/util/net" "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/apiserver/pkg/apis/apiserver" "k8s.io/apiserver/pkg/authentication/authenticator" "k8s.io/apiserver/pkg/authentication/authenticatorfactory" "k8s.io/apiserver/pkg/authentication/group" @@ -55,15 +55,8 @@ type Config struct { BootstrapToken bool TokenAuthFile string - OIDCIssuerURL string - OIDCClientID string - OIDCCAFile string - OIDCUsernameClaim string - OIDCUsernamePrefix string - OIDCGroupsClaim string - OIDCGroupsPrefix string + AuthenticationConfig *apiserver.AuthenticationConfiguration OIDCSigningAlgs []string - OIDCRequiredClaims map[string]string ServiceAccountKeyFiles []string ServiceAccountLookup bool ServiceAccountIssuers []string @@ -153,33 +146,28 @@ func (config Config) New() (authenticator.Request, *spec.SecurityDefinitions, er // cache misses for all requests using the other. While the service account plugin // simply returns an error, the OpenID Connect plugin may query the provider to // update the keys, causing performance hits. - if len(config.OIDCIssuerURL) > 0 && len(config.OIDCClientID) > 0 { - // TODO(enj): wire up the Notifier and ControllerRunner bits when OIDC supports CA reload - var oidcCAContent oidc.CAContentProvider - if len(config.OIDCCAFile) != 0 { - var oidcCAErr error - oidcCAContent, oidcCAErr = staticCAContentProviderFromFile("oidc-authenticator", config.OIDCCAFile) - if oidcCAErr != nil { - return nil, nil, oidcCAErr + if config.AuthenticationConfig != nil { + for _, jwtAuthenticator := range config.AuthenticationConfig.JWT { + var oidcCAContent oidc.CAContentProvider + if len(jwtAuthenticator.Issuer.CertificateAuthority) > 0 { + var oidcCAError error + oidcCAContent, oidcCAError = dynamiccertificates.NewStaticCAContent("oidc-authenticator", []byte(jwtAuthenticator.Issuer.CertificateAuthority)) + if oidcCAError != nil { + return nil, nil, oidcCAError + } } + oidcAuth, err := oidc.New(oidc.Options{ + JWTAuthenticator: jwtAuthenticator, + CAContentProvider: oidcCAContent, + SupportedSigningAlgs: config.OIDCSigningAlgs, + }) + if err != nil { + return nil, nil, err + } + tokenAuthenticators = append(tokenAuthenticators, authenticator.WrapAudienceAgnosticToken(config.APIAudiences, oidcAuth)) } - - oidcAuth, err := newAuthenticatorFromOIDCIssuerURL(oidc.Options{ - IssuerURL: config.OIDCIssuerURL, - ClientID: config.OIDCClientID, - CAContentProvider: oidcCAContent, - UsernameClaim: config.OIDCUsernameClaim, - UsernamePrefix: config.OIDCUsernamePrefix, - GroupsClaim: config.OIDCGroupsClaim, - GroupsPrefix: config.OIDCGroupsPrefix, - SupportedSigningAlgs: config.OIDCSigningAlgs, - RequiredClaims: config.OIDCRequiredClaims, - }) - if err != nil { - return nil, nil, err - } - tokenAuthenticators = append(tokenAuthenticators, authenticator.WrapAudienceAgnosticToken(config.APIAudiences, oidcAuth)) } + if len(config.WebhookTokenAuthnConfigFile) > 0 { webhookTokenAuth, err := newWebhookTokenAuthenticator(config) if err != nil { @@ -243,31 +231,6 @@ func newAuthenticatorFromTokenFile(tokenAuthFile string) (authenticator.Token, e return tokenAuthenticator, nil } -// newAuthenticatorFromOIDCIssuerURL returns an authenticator.Token or an error. -func newAuthenticatorFromOIDCIssuerURL(opts oidc.Options) (authenticator.Token, error) { - const noUsernamePrefix = "-" - - if opts.UsernamePrefix == "" && opts.UsernameClaim != "email" { - // Old behavior. If a usernamePrefix isn't provided, prefix all claims other than "email" - // with the issuerURL. - // - // See https://github.com/kubernetes/kubernetes/issues/31380 - opts.UsernamePrefix = opts.IssuerURL + "#" - } - - if opts.UsernamePrefix == noUsernamePrefix { - // Special value indicating usernames shouldn't be prefixed. - opts.UsernamePrefix = "" - } - - tokenAuthenticator, err := oidc.New(opts) - if err != nil { - return nil, err - } - - return tokenAuthenticator, nil -} - // newLegacyServiceAccountAuthenticator returns an authenticator.Token or an error func newLegacyServiceAccountAuthenticator(keyfiles []string, lookup bool, apiAudiences authenticator.Audiences, serviceAccountGetter serviceaccount.ServiceAccountTokenGetter, secretsWriter typedv1core.SecretsGetter) (authenticator.Token, error) { allPublicKeys := []interface{}{} @@ -318,12 +281,3 @@ func newWebhookTokenAuthenticator(config Config) (authenticator.Token, error) { return tokencache.New(webhookTokenAuthenticator, false, config.WebhookTokenAuthnCacheTTL, config.WebhookTokenAuthnCacheTTL), nil } - -func staticCAContentProviderFromFile(purpose, filename string) (dynamiccertificates.CAContentProvider, error) { - fileBytes, err := os.ReadFile(filename) - if err != nil { - return nil, err - } - - return dynamiccertificates.NewStaticCAContent(purpose, fileBytes) -} diff --git a/pkg/kubeapiserver/options/authentication.go b/pkg/kubeapiserver/options/authentication.go index 914a3cac8e0..fae015e629e 100644 --- a/pkg/kubeapiserver/options/authentication.go +++ b/pkg/kubeapiserver/options/authentication.go @@ -20,6 +20,7 @@ import ( "errors" "fmt" "net/url" + "os" "strings" "time" @@ -28,6 +29,8 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "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" "k8s.io/apiserver/pkg/authentication/authenticator" genericapiserver "k8s.io/apiserver/pkg/server" "k8s.io/apiserver/pkg/server/egressselector" @@ -41,6 +44,7 @@ import ( kubeauthenticator "k8s.io/kubernetes/pkg/kubeapiserver/authenticator" authzmodes "k8s.io/kubernetes/pkg/kubeapiserver/authorizer/modes" "k8s.io/kubernetes/plugin/pkg/auth/authenticator/token/bootstrap" + "k8s.io/utils/pointer" ) // BuiltInAuthenticationOptions contains all build-in authentication options for API Server @@ -397,16 +401,68 @@ func (o *BuiltInAuthenticationOptions) ToAuthenticationConfig() (kubeauthenticat } } - if o.OIDC != nil { - ret.OIDCCAFile = o.OIDC.CAFile - ret.OIDCClientID = o.OIDC.ClientID - ret.OIDCGroupsClaim = o.OIDC.GroupsClaim - ret.OIDCGroupsPrefix = o.OIDC.GroupsPrefix - ret.OIDCIssuerURL = o.OIDC.IssuerURL - ret.OIDCUsernameClaim = o.OIDC.UsernameClaim - ret.OIDCUsernamePrefix = o.OIDC.UsernamePrefix + if o.OIDC != nil && len(o.OIDC.IssuerURL) > 0 && len(o.OIDC.ClientID) > 0 { + usernamePrefix := o.OIDC.UsernamePrefix + + if o.OIDC.UsernamePrefix == "" && o.OIDC.UsernameClaim != "email" { + // Legacy CLI flag behavior. If a usernamePrefix isn't provided, prefix all claims other than "email" + // with the issuerURL. + // + // See https://github.com/kubernetes/kubernetes/issues/31380 + usernamePrefix = o.OIDC.IssuerURL + "#" + } + if o.OIDC.UsernamePrefix == "-" { + // Special value indicating usernames shouldn't be prefixed. + usernamePrefix = "" + } + + jwtAuthenticator := apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: o.OIDC.IssuerURL, + Audiences: []string{o.OIDC.ClientID}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Prefix: pointer.String(usernamePrefix), + Claim: o.OIDC.UsernameClaim, + }, + }, + } + + if len(o.OIDC.GroupsClaim) > 0 { + jwtAuthenticator.ClaimMappings.Groups = apiserver.PrefixedClaimOrExpression{ + Prefix: pointer.String(o.OIDC.GroupsPrefix), + Claim: o.OIDC.GroupsClaim, + } + } + + if len(o.OIDC.CAFile) != 0 { + caContent, err := os.ReadFile(o.OIDC.CAFile) + if err != nil { + return kubeauthenticator.Config{}, err + } + jwtAuthenticator.Issuer.CertificateAuthority = string(caContent) + } + + if len(o.OIDC.RequiredClaims) > 0 { + claimValidationRules := make([]apiserver.ClaimValidationRule, 0, len(o.OIDC.RequiredClaims)) + for claim, value := range o.OIDC.RequiredClaims { + claimValidationRules = append(claimValidationRules, apiserver.ClaimValidationRule{ + Claim: claim, + RequiredValue: value, + }) + } + jwtAuthenticator.ClaimValidationRules = claimValidationRules + } + + authConfig := &apiserver.AuthenticationConfiguration{ + JWT: []apiserver.JWTAuthenticator{jwtAuthenticator}, + } + if err := apiservervalidation.ValidateAuthenticationConfiguration(authConfig).ToAggregate(); err != nil { + return kubeauthenticator.Config{}, err + } + ret.AuthenticationConfig = authConfig ret.OIDCSigningAlgs = o.OIDC.SigningAlgs - ret.OIDCRequiredClaims = o.OIDC.RequiredClaims } if o.RequestHeader != nil { diff --git a/pkg/kubeapiserver/options/authentication_test.go b/pkg/kubeapiserver/options/authentication_test.go index 65467e51245..0cb97e30a93 100644 --- a/pkg/kubeapiserver/options/authentication_test.go +++ b/pkg/kubeapiserver/options/authentication_test.go @@ -17,6 +17,7 @@ limitations under the License. package options import ( + "os" "reflect" "strings" "testing" @@ -27,11 +28,13 @@ import ( utilerrors "k8s.io/apimachinery/pkg/util/errors" "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/apiserver/pkg/apis/apiserver" "k8s.io/apiserver/pkg/authentication/authenticator" "k8s.io/apiserver/pkg/authentication/authenticatorfactory" "k8s.io/apiserver/pkg/authentication/request/headerrequest" apiserveroptions "k8s.io/apiserver/pkg/server/options" kubeauthenticator "k8s.io/kubernetes/pkg/kubeapiserver/authenticator" + "k8s.io/utils/pointer" ) func TestAuthenticationValidate(t *testing.T) { @@ -50,7 +53,7 @@ func TestAuthenticationValidate(t *testing.T) { testOIDC: &OIDCAuthenticationOptions{ UsernameClaim: "sub", SigningAlgs: []string{"RS256"}, - IssuerURL: "testIssuerURL", + IssuerURL: "https://testIssuerURL", ClientID: "testClientID", }, testSA: &ServiceAccountAuthenticationOptions{ @@ -63,7 +66,7 @@ func TestAuthenticationValidate(t *testing.T) { testOIDC: &OIDCAuthenticationOptions{ UsernameClaim: "sub", SigningAlgs: []string{"RS256"}, - IssuerURL: "testIssuerURL", + IssuerURL: "https://testIssuerURL", }, testSA: &ServiceAccountAuthenticationOptions{ Issuers: []string{"http://foo.bar.com"}, @@ -76,7 +79,7 @@ func TestAuthenticationValidate(t *testing.T) { testOIDC: &OIDCAuthenticationOptions{ UsernameClaim: "sub", SigningAlgs: []string{"RS256"}, - IssuerURL: "testIssuerURL", + IssuerURL: "https://testIssuerURL", ClientID: "testClientID", }, testSA: &ServiceAccountAuthenticationOptions{ @@ -89,7 +92,7 @@ func TestAuthenticationValidate(t *testing.T) { testOIDC: &OIDCAuthenticationOptions{ UsernameClaim: "sub", SigningAlgs: []string{"RS256"}, - IssuerURL: "testIssuerURL", + IssuerURL: "https://testIssuerURL", ClientID: "testClientID", }, testSA: &ServiceAccountAuthenticationOptions{ @@ -102,7 +105,7 @@ func TestAuthenticationValidate(t *testing.T) { testOIDC: &OIDCAuthenticationOptions{ UsernameClaim: "sub", SigningAlgs: []string{"RS256"}, - IssuerURL: "testIssuerURL", + IssuerURL: "https://testIssuerURL", ClientID: "testClientID", }, testSA: &ServiceAccountAuthenticationOptions{ @@ -115,7 +118,7 @@ func TestAuthenticationValidate(t *testing.T) { testOIDC: &OIDCAuthenticationOptions{ UsernameClaim: "sub", SigningAlgs: []string{"RS256"}, - IssuerURL: "testIssuerURL", + IssuerURL: "https://testIssuerURL", ClientID: "testClientID", }, testSA: &ServiceAccountAuthenticationOptions{ @@ -128,7 +131,7 @@ func TestAuthenticationValidate(t *testing.T) { testOIDC: &OIDCAuthenticationOptions{ UsernameClaim: "sub", SigningAlgs: []string{"RS256"}, - IssuerURL: "testIssuerURL", + IssuerURL: "https://testIssuerURL", ClientID: "testClientID", }, testSA: &ServiceAccountAuthenticationOptions{ @@ -141,7 +144,7 @@ func TestAuthenticationValidate(t *testing.T) { testOIDC: &OIDCAuthenticationOptions{ UsernameClaim: "sub", SigningAlgs: []string{"RS256"}, - IssuerURL: "testIssuerURL", + IssuerURL: "https://testIssuerURL", ClientID: "testClientID", }, testSA: &ServiceAccountAuthenticationOptions{ @@ -156,7 +159,7 @@ func TestAuthenticationValidate(t *testing.T) { testOIDC: &OIDCAuthenticationOptions{ UsernameClaim: "sub", SigningAlgs: []string{"RS256"}, - IssuerURL: "testIssuerURL", + IssuerURL: "https://testIssuerURL", ClientID: "testClientID", }, testSA: &ServiceAccountAuthenticationOptions{ @@ -171,7 +174,7 @@ func TestAuthenticationValidate(t *testing.T) { testOIDC: &OIDCAuthenticationOptions{ UsernameClaim: "sub", SigningAlgs: []string{"RS256"}, - IssuerURL: "testIssuerURL", + IssuerURL: "https://testIssuerURL", ClientID: "testClientID", }, testSA: &ServiceAccountAuthenticationOptions{ @@ -228,10 +231,10 @@ func TestToAuthenticationConfig(t *testing.T) { Enable: false, }, OIDC: &OIDCAuthenticationOptions{ - CAFile: "/testCAFile", + CAFile: "testdata/root.pem", UsernameClaim: "sub", SigningAlgs: []string{"RS256"}, - IssuerURL: "testIssuerURL", + IssuerURL: "https://testIssuerURL", ClientID: "testClientID", }, RequestHeader: &apiserveroptions.RequestHeaderAuthenticationOptions{ @@ -253,15 +256,27 @@ func TestToAuthenticationConfig(t *testing.T) { } expectConfig := kubeauthenticator.Config{ - APIAudiences: authenticator.Audiences{"http://foo.bar.com"}, - Anonymous: false, - BootstrapToken: false, - ClientCAContentProvider: nil, // this is nil because you can't compare functions - TokenAuthFile: "/testTokenFile", - OIDCIssuerURL: "testIssuerURL", - OIDCClientID: "testClientID", - OIDCCAFile: "/testCAFile", - OIDCUsernameClaim: "sub", + APIAudiences: authenticator.Audiences{"http://foo.bar.com"}, + Anonymous: false, + BootstrapToken: false, + ClientCAContentProvider: nil, // this is nil because you can't compare functions + TokenAuthFile: "/testTokenFile", + AuthenticationConfig: &apiserver.AuthenticationConfiguration{ + JWT: []apiserver.JWTAuthenticator{ + { + Issuer: apiserver.Issuer{ + URL: "https://testIssuerURL", + Audiences: []string{"testClientID"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Claim: "sub", + Prefix: pointer.String("https://testIssuerURL#"), + }, + }, + }, + }, + }, OIDCSigningAlgs: []string{"RS256"}, ServiceAccountLookup: true, ServiceAccountIssuers: []string{"http://foo.bar.com"}, @@ -280,6 +295,12 @@ func TestToAuthenticationConfig(t *testing.T) { }, } + fileBytes, err := os.ReadFile("testdata/root.pem") + if err != nil { + t.Fatal(err) + } + expectConfig.AuthenticationConfig.JWT[0].Issuer.CertificateAuthority = string(fileBytes) + resultConfig, err := testOptions.ToAuthenticationConfig() if err != nil { t.Fatal(err) @@ -385,3 +406,221 @@ func TestBuiltInAuthenticationOptionsAddFlags(t *testing.T) { t.Error(cmp.Diff(opts, expected)) } } + +func TestToAuthenticationConfig_OIDC(t *testing.T) { + testCases := []struct { + name string + args []string + expectConfig kubeauthenticator.Config + }{ + { + name: "username prefix is '-'", + args: []string{ + "--oidc-issuer-url=https://testIssuerURL", + "--oidc-client-id=testClientID", + "--oidc-username-claim=sub", + "--oidc-username-prefix=-", + "--oidc-signing-algs=RS256", + "--oidc-required-claim=foo=bar", + }, + expectConfig: kubeauthenticator.Config{ + TokenSuccessCacheTTL: 10 * time.Second, + AuthenticationConfig: &apiserver.AuthenticationConfiguration{ + JWT: []apiserver.JWTAuthenticator{ + { + Issuer: apiserver.Issuer{ + URL: "https://testIssuerURL", + Audiences: []string{"testClientID"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Claim: "sub", + Prefix: pointer.String(""), + }, + }, + ClaimValidationRules: []apiserver.ClaimValidationRule{ + { + Claim: "foo", + RequiredValue: "bar", + }, + }, + }, + }, + }, + OIDCSigningAlgs: []string{"RS256"}, + }, + }, + { + name: "--oidc-username-prefix is empty, --oidc-username-claim is not email", + args: []string{ + "--oidc-issuer-url=https://testIssuerURL", + "--oidc-client-id=testClientID", + "--oidc-username-claim=sub", + "--oidc-signing-algs=RS256", + "--oidc-required-claim=foo=bar", + }, + expectConfig: kubeauthenticator.Config{ + TokenSuccessCacheTTL: 10 * time.Second, + AuthenticationConfig: &apiserver.AuthenticationConfiguration{ + JWT: []apiserver.JWTAuthenticator{ + { + Issuer: apiserver.Issuer{ + URL: "https://testIssuerURL", + Audiences: []string{"testClientID"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Claim: "sub", + Prefix: pointer.String("https://testIssuerURL#"), + }, + }, + ClaimValidationRules: []apiserver.ClaimValidationRule{ + { + Claim: "foo", + RequiredValue: "bar", + }, + }, + }, + }, + }, + OIDCSigningAlgs: []string{"RS256"}, + }, + }, + { + name: "--oidc-username-prefix is empty, --oidc-username-claim is email", + args: []string{ + "--oidc-issuer-url=https://testIssuerURL", + "--oidc-client-id=testClientID", + "--oidc-username-claim=email", + "--oidc-signing-algs=RS256", + "--oidc-required-claim=foo=bar", + }, + expectConfig: kubeauthenticator.Config{ + TokenSuccessCacheTTL: 10 * time.Second, + AuthenticationConfig: &apiserver.AuthenticationConfiguration{ + JWT: []apiserver.JWTAuthenticator{ + { + Issuer: apiserver.Issuer{ + URL: "https://testIssuerURL", + Audiences: []string{"testClientID"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Claim: "email", + Prefix: pointer.String(""), + }, + }, + ClaimValidationRules: []apiserver.ClaimValidationRule{ + { + Claim: "foo", + RequiredValue: "bar", + }, + }, + }, + }, + }, + OIDCSigningAlgs: []string{"RS256"}, + }, + }, + { + name: "non empty username prefix", + args: []string{ + "--oidc-issuer-url=https://testIssuerURL", + "--oidc-client-id=testClientID", + "--oidc-username-claim=sub", + "--oidc-username-prefix=k8s-", + "--oidc-signing-algs=RS256", + "--oidc-required-claim=foo=bar", + }, + expectConfig: kubeauthenticator.Config{ + TokenSuccessCacheTTL: 10 * time.Second, + AuthenticationConfig: &apiserver.AuthenticationConfiguration{ + JWT: []apiserver.JWTAuthenticator{ + { + Issuer: apiserver.Issuer{ + URL: "https://testIssuerURL", + Audiences: []string{"testClientID"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Claim: "sub", + Prefix: pointer.String("k8s-"), + }, + }, + ClaimValidationRules: []apiserver.ClaimValidationRule{ + { + Claim: "foo", + RequiredValue: "bar", + }, + }, + }, + }, + }, + OIDCSigningAlgs: []string{"RS256"}, + }, + }, + { + name: "groups claim exists", + args: []string{ + "--oidc-issuer-url=https://testIssuerURL", + "--oidc-client-id=testClientID", + "--oidc-username-claim=sub", + "--oidc-username-prefix=-", + "--oidc-groups-claim=groups", + "--oidc-groups-prefix=oidc:", + "--oidc-signing-algs=RS256", + "--oidc-required-claim=foo=bar", + }, + expectConfig: kubeauthenticator.Config{ + TokenSuccessCacheTTL: 10 * time.Second, + AuthenticationConfig: &apiserver.AuthenticationConfiguration{ + JWT: []apiserver.JWTAuthenticator{ + { + Issuer: apiserver.Issuer{ + URL: "https://testIssuerURL", + Audiences: []string{"testClientID"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Claim: "sub", + Prefix: pointer.String(""), + }, + Groups: apiserver.PrefixedClaimOrExpression{ + Claim: "groups", + Prefix: pointer.String("oidc:"), + }, + }, + ClaimValidationRules: []apiserver.ClaimValidationRule{ + { + Claim: "foo", + RequiredValue: "bar", + }, + }, + }, + }, + }, + OIDCSigningAlgs: []string{"RS256"}, + }, + }, + } + + for _, testcase := range testCases { + t.Run(testcase.name, func(t *testing.T) { + opts := NewBuiltInAuthenticationOptions().WithOIDC() + pf := pflag.NewFlagSet("test-builtin-authentication-opts", pflag.ContinueOnError) + opts.AddFlags(pf) + + if err := pf.Parse(testcase.args); err != nil { + t.Fatal(err) + } + + resultConfig, err := opts.ToAuthenticationConfig() + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(resultConfig, testcase.expectConfig) { + t.Error(cmp.Diff(resultConfig, testcase.expectConfig)) + } + }) + } +} diff --git a/staging/src/k8s.io/apiserver/pkg/apis/apiserver/validation/validation.go b/staging/src/k8s.io/apiserver/pkg/apis/apiserver/validation/validation.go new file mode 100644 index 00000000000..90a5c8eb753 --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/apis/apiserver/validation/validation.go @@ -0,0 +1,204 @@ +/* +Copyright 2023 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package validation + +import ( + "fmt" + "net/url" + + "k8s.io/apimachinery/pkg/util/sets" + "k8s.io/apimachinery/pkg/util/validation/field" + api "k8s.io/apiserver/pkg/apis/apiserver" + "k8s.io/client-go/util/cert" +) + +const ( + atLeastOneRequiredErrFmt = "at least one %s is required" +) + +var ( + root = field.NewPath("jwt") +) + +// ValidateAuthenticationConfiguration validates a given AuthenticationConfiguration. +func ValidateAuthenticationConfiguration(c *api.AuthenticationConfiguration) field.ErrorList { + var allErrs field.ErrorList + + // This stricter validation is solely based on what the current implementation supports. + // TODO(aramase): when StructuredAuthenticationConfiguration feature gate is added and wired up, + // relax this check to allow 0 authenticators. This will allow us to support the case where + // API server is initially configured with no authenticators and then authenticators are added + // later via dynamic config. + if len(c.JWT) == 0 { + allErrs = append(allErrs, field.Required(root, fmt.Sprintf(atLeastOneRequiredErrFmt, root))) + return allErrs + } + + // This stricter validation is because the --oidc-* flag option is singular. + // TODO(aramase): when StructuredAuthenticationConfiguration feature gate is added and wired up, + // remove the 1 authenticator limit check and add set the limit to 64. + if len(c.JWT) > 1 { + allErrs = append(allErrs, field.TooMany(root, len(c.JWT), 1)) + return allErrs + } + + // TODO(aramase): right now we only support a single JWT authenticator as + // this is wired to the --oidc-* flags. When StructuredAuthenticationConfiguration + // feature gate is added and wired up, we will remove the 1 authenticator limit + // check and add validation for duplicate issuers. + for i, a := range c.JWT { + fldPath := root.Index(i) + allErrs = append(allErrs, validateJWTAuthenticator(a, fldPath)...) + } + + return allErrs +} + +// ValidateJWTAuthenticator validates a given JWTAuthenticator. +// This is exported for use in oidc package. +func ValidateJWTAuthenticator(authenticator api.JWTAuthenticator) field.ErrorList { + return validateJWTAuthenticator(authenticator, nil) +} + +func validateJWTAuthenticator(authenticator api.JWTAuthenticator, fldPath *field.Path) field.ErrorList { + var allErrs field.ErrorList + + allErrs = append(allErrs, validateIssuer(authenticator.Issuer, fldPath.Child("issuer"))...) + allErrs = append(allErrs, validateClaimValidationRules(authenticator.ClaimValidationRules, fldPath.Child("claimValidationRules"))...) + allErrs = append(allErrs, validateClaimMappings(authenticator.ClaimMappings, fldPath.Child("claimMappings"))...) + + return allErrs +} + +func validateIssuer(issuer api.Issuer, fldPath *field.Path) field.ErrorList { + var allErrs field.ErrorList + + allErrs = append(allErrs, validateURL(issuer.URL, fldPath.Child("url"))...) + allErrs = append(allErrs, validateAudiences(issuer.Audiences, fldPath.Child("audiences"))...) + allErrs = append(allErrs, validateCertificateAuthority(issuer.CertificateAuthority, fldPath.Child("certificateAuthority"))...) + + return allErrs +} + +func validateURL(issuerURL string, fldPath *field.Path) field.ErrorList { + var allErrs field.ErrorList + + if len(issuerURL) == 0 { + allErrs = append(allErrs, field.Required(fldPath, "URL is required")) + return allErrs + } + + u, err := url.Parse(issuerURL) + if err != nil { + allErrs = append(allErrs, field.Invalid(fldPath, issuerURL, err.Error())) + return allErrs + } + if u.Scheme != "https" { + allErrs = append(allErrs, field.Invalid(fldPath, issuerURL, "URL scheme must be https")) + } + if u.User != nil { + allErrs = append(allErrs, field.Invalid(fldPath, issuerURL, "URL must not contain a username or password")) + } + if len(u.RawQuery) > 0 { + allErrs = append(allErrs, field.Invalid(fldPath, issuerURL, "URL must not contain a query")) + } + if len(u.Fragment) > 0 { + allErrs = append(allErrs, field.Invalid(fldPath, issuerURL, "URL must not contain a fragment")) + } + + return allErrs +} + +func validateAudiences(audiences []string, fldPath *field.Path) field.ErrorList { + var allErrs field.ErrorList + + if len(audiences) == 0 { + 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 + } + + for i, audience := range audiences { + fldPath := fldPath.Index(i) + if len(audience) == 0 { + allErrs = append(allErrs, field.Required(fldPath, "audience can't be empty")) + } + } + + return allErrs +} + +func validateCertificateAuthority(certificateAuthority string, fldPath *field.Path) field.ErrorList { + var allErrs field.ErrorList + + if len(certificateAuthority) == 0 { + return allErrs + } + _, err := cert.NewPoolFromBytes([]byte(certificateAuthority)) + if err != nil { + allErrs = append(allErrs, field.Invalid(fldPath, "", err.Error())) + } + + return allErrs +} + +func validateClaimValidationRules(rules []api.ClaimValidationRule, fldPath *field.Path) field.ErrorList { + var allErrs field.ErrorList + + seenClaims := sets.NewString() + for i, rule := range rules { + fldPath := fldPath.Index(i) + + if len(rule.Claim) == 0 { + allErrs = append(allErrs, field.Required(fldPath.Child("claim"), "claim name is required")) + continue + } + + if seenClaims.Has(rule.Claim) { + allErrs = append(allErrs, field.Duplicate(fldPath.Child("claim"), rule.Claim)) + continue + } + seenClaims.Insert(rule.Claim) + } + + return allErrs +} + +func validateClaimMappings(m api.ClaimMappings, fldPath *field.Path) field.ErrorList { + var allErrs field.ErrorList + + if len(m.Username.Claim) == 0 { + allErrs = append(allErrs, field.Required(fldPath.Child("username", "claim"), "claim name is required")) + } + // TODO(aramase): when Expression is added to PrefixedClaimOrExpression, check prefix and expression are not both set. + if m.Username.Prefix == nil { + allErrs = append(allErrs, field.Required(fldPath.Child("username", "prefix"), "prefix is required")) + } + if len(m.Groups.Claim) > 0 && m.Groups.Prefix == nil { + allErrs = append(allErrs, field.Required(fldPath.Child("groups", "prefix"), "prefix is required when claim is set")) + } + if m.Groups.Prefix != nil && len(m.Groups.Claim) == 0 { + allErrs = append(allErrs, field.Required(fldPath.Child("groups", "claim"), "non-empty claim name is required when prefix is set")) + } + + return allErrs +} diff --git a/staging/src/k8s.io/apiserver/pkg/apis/apiserver/validation/validation_test.go b/staging/src/k8s.io/apiserver/pkg/apis/apiserver/validation/validation_test.go new file mode 100644 index 00000000000..7931a458e5f --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/apis/apiserver/validation/validation_test.go @@ -0,0 +1,414 @@ +/* +Copyright 2023 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package validation + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "encoding/pem" + "testing" + + "github.com/google/go-cmp/cmp" + + "k8s.io/apimachinery/pkg/util/errors" + "k8s.io/apimachinery/pkg/util/validation/field" + api "k8s.io/apiserver/pkg/apis/apiserver" + certutil "k8s.io/client-go/util/cert" + "k8s.io/utils/pointer" +) + +func TestValidateAuthenticationConfiguration(t *testing.T) { + testCases := []struct { + name string + in *api.AuthenticationConfiguration + want string + }{ + { + name: "jwt authenticator is empty", + in: &api.AuthenticationConfiguration{}, + want: "jwt: Required value: at least one jwt is required", + }, + { + name: ">1 jwt authenticator", + in: &api.AuthenticationConfiguration{ + JWT: []api.JWTAuthenticator{ + {Issuer: api.Issuer{URL: "https://issuer-url", Audiences: []string{"audience"}}}, + {Issuer: api.Issuer{URL: "https://issuer-url", Audiences: []string{"audience"}}}, + }, + }, + want: "jwt: Too many: 2: must have at most 1 items", + }, + { + name: "failed issuer validation", + in: &api.AuthenticationConfiguration{ + JWT: []api.JWTAuthenticator{ + { + Issuer: api.Issuer{ + URL: "invalid-url", + Audiences: []string{"audience"}, + }, + ClaimMappings: api.ClaimMappings{ + Username: api.PrefixedClaimOrExpression{ + Claim: "claim", + Prefix: pointer.String("prefix"), + }, + }, + }, + }, + }, + want: `jwt[0].issuer.url: Invalid value: "invalid-url": URL scheme must be https`, + }, + { + name: "failed claimValidationRule validation", + in: &api.AuthenticationConfiguration{ + JWT: []api.JWTAuthenticator{ + { + Issuer: api.Issuer{ + URL: "https://issuer-url", + Audiences: []string{"audience"}, + }, + ClaimValidationRules: []api.ClaimValidationRule{ + { + Claim: "foo", + RequiredValue: "bar", + }, + { + Claim: "foo", + RequiredValue: "baz", + }, + }, + ClaimMappings: api.ClaimMappings{ + Username: api.PrefixedClaimOrExpression{ + Claim: "claim", + Prefix: pointer.String("prefix"), + }, + }, + }, + }, + }, + want: `jwt[0].claimValidationRules[1].claim: Duplicate value: "foo"`, + }, + { + name: "failed claimMapping validation", + in: &api.AuthenticationConfiguration{ + JWT: []api.JWTAuthenticator{ + { + Issuer: api.Issuer{ + URL: "https://issuer-url", + Audiences: []string{"audience"}, + }, + ClaimValidationRules: []api.ClaimValidationRule{ + { + Claim: "foo", + RequiredValue: "bar", + }, + }, + ClaimMappings: api.ClaimMappings{ + Username: api.PrefixedClaimOrExpression{ + Prefix: pointer.String("prefix"), + }, + }, + }, + }, + }, + want: "jwt[0].claimMappings.username.claim: Required value: claim name is required", + }, + { + name: "valid authentication configuration", + in: &api.AuthenticationConfiguration{ + JWT: []api.JWTAuthenticator{ + { + Issuer: api.Issuer{ + URL: "https://issuer-url", + Audiences: []string{"audience"}, + }, + ClaimValidationRules: []api.ClaimValidationRule{ + { + Claim: "foo", + RequiredValue: "bar", + }, + }, + ClaimMappings: api.ClaimMappings{ + Username: api.PrefixedClaimOrExpression{ + Claim: "sub", + Prefix: pointer.String("prefix"), + }, + }, + }, + }, + }, + want: "", + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + got := ValidateAuthenticationConfiguration(tt.in).ToAggregate() + if d := cmp.Diff(tt.want, errString(got)); d != "" { + t.Fatalf("AuthenticationConfiguration validation mismatch (-want +got):\n%s", d) + } + }) + } +} + +func TestValidateURL(t *testing.T) { + fldPath := field.NewPath("issuer", "url") + + testCases := []struct { + name string + in string + want string + }{ + { + name: "url is empty", + in: "", + want: "issuer.url: Required value: URL is required", + }, + { + name: "url parse error", + in: "https://issuer-url:invalid-port", + want: `issuer.url: Invalid value: "https://issuer-url:invalid-port": parse "https://issuer-url:invalid-port": invalid port ":invalid-port" after host`, + }, + { + name: "url is not https", + in: "http://issuer-url", + want: `issuer.url: Invalid value: "http://issuer-url": URL scheme must be https`, + }, + { + name: "url user info is not allowed", + in: "https://user:pass@issuer-url", + want: `issuer.url: Invalid value: "https://user:pass@issuer-url": URL must not contain a username or password`, + }, + { + name: "url raw query is not allowed", + in: "https://issuer-url?query", + want: `issuer.url: Invalid value: "https://issuer-url?query": URL must not contain a query`, + }, + { + name: "url fragment is not allowed", + in: "https://issuer-url#fragment", + want: `issuer.url: Invalid value: "https://issuer-url#fragment": URL must not contain a fragment`, + }, + { + name: "valid url", + in: "https://issuer-url", + want: "", + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + got := validateURL(tt.in, fldPath).ToAggregate() + if d := cmp.Diff(tt.want, errString(got)); d != "" { + t.Fatalf("URL validation mismatch (-want +got):\n%s", d) + } + }) + } +} + +func TestValidateAudiences(t *testing.T) { + fldPath := field.NewPath("issuer", "audiences") + + testCases := []struct { + name string + in []string + want string + }{ + { + name: "audiences is empty", + in: []string{}, + want: "issuer.audiences: Required value: at least one issuer.audiences is required", + }, + { + name: "at most one audiences is allowed", + in: []string{"audience1", "audience2"}, + want: "issuer.audiences: Too many: 2: must have at most 1 items", + }, + { + name: "audience is empty", + in: []string{""}, + want: "issuer.audiences[0]: Required value: audience can't be empty", + }, + { + name: "valid audience", + in: []string{"audience"}, + want: "", + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + got := validateAudiences(tt.in, fldPath).ToAggregate() + if d := cmp.Diff(tt.want, errString(got)); d != "" { + t.Fatalf("Audiences validation mismatch (-want +got):\n%s", d) + } + }) + } +} + +func TestValidateCertificateAuthority(t *testing.T) { + fldPath := field.NewPath("issuer", "certificateAuthority") + + testCases := []struct { + name string + in func() string + want string + }{ + { + name: "invalid certificate authority", + in: func() string { return "invalid" }, + want: `issuer.certificateAuthority: Invalid value: "": data does not contain any valid RSA or ECDSA certificates`, + }, + { + name: "certificate authority is empty", + in: func() string { return "" }, + want: "", + }, + { + name: "valid certificate authority", + in: func() string { + caPrivateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + caCert, err := certutil.NewSelfSignedCACert(certutil.Config{CommonName: "test-ca"}, caPrivateKey) + if err != nil { + t.Fatal(err) + } + return string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caCert.Raw})) + }, + want: "", + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + got := validateCertificateAuthority(tt.in(), fldPath).ToAggregate() + if d := cmp.Diff(tt.want, errString(got)); d != "" { + t.Fatalf("CertificateAuthority validation mismatch (-want +got):\n%s", d) + } + }) + } +} + +func TestClaimValidationRules(t *testing.T) { + fldPath := field.NewPath("issuer", "claimValidationRules") + + testCases := []struct { + name string + in []api.ClaimValidationRule + want string + }{ + { + name: "claim validation rule claim is empty", + in: []api.ClaimValidationRule{{Claim: ""}}, + want: "issuer.claimValidationRules[0].claim: Required value: claim name is required", + }, + { + name: "duplicate claim", + in: []api.ClaimValidationRule{{ + Claim: "claim", RequiredValue: "value1"}, + {Claim: "claim", RequiredValue: "value2"}, + }, + want: `issuer.claimValidationRules[1].claim: Duplicate value: "claim"`, + }, + { + name: "valid claim validation rule", + in: []api.ClaimValidationRule{{Claim: "claim", RequiredValue: "value"}}, + want: "", + }, + { + name: "valid claim validation rule with multiple rules", + in: []api.ClaimValidationRule{ + {Claim: "claim1", RequiredValue: "value1"}, + {Claim: "claim2", RequiredValue: "value2"}, + }, + want: "", + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + got := validateClaimValidationRules(tt.in, fldPath).ToAggregate() + if d := cmp.Diff(tt.want, errString(got)); d != "" { + t.Fatalf("ClaimValidationRules validation mismatch (-want +got):\n%s", d) + } + }) + } +} + +func TestValidateClaimMappings(t *testing.T) { + fldPath := field.NewPath("issuer", "claimMappings") + + testCases := []struct { + name string + in api.ClaimMappings + want string + }{ + { + name: "username claim is empty", + in: api.ClaimMappings{Username: api.PrefixedClaimOrExpression{Claim: "", Prefix: pointer.String("prefix")}}, + want: "issuer.claimMappings.username.claim: Required value: claim name is required", + }, + { + name: "username prefix is empty", + in: api.ClaimMappings{Username: api.PrefixedClaimOrExpression{Claim: "claim"}}, + want: "issuer.claimMappings.username.prefix: Required value: prefix is required", + }, + { + name: "groups prefix is empty", + in: api.ClaimMappings{ + Username: api.PrefixedClaimOrExpression{Claim: "claim", Prefix: pointer.String("prefix")}, + Groups: api.PrefixedClaimOrExpression{Claim: "claim"}, + }, + want: "issuer.claimMappings.groups.prefix: Required value: prefix is required when claim is set", + }, + { + name: "groups prefix set but claim is empty", + in: api.ClaimMappings{ + Username: api.PrefixedClaimOrExpression{Claim: "claim", Prefix: pointer.String("prefix")}, + Groups: api.PrefixedClaimOrExpression{Prefix: pointer.String("prefix")}, + }, + want: "issuer.claimMappings.groups.claim: Required value: non-empty claim name is required when prefix is set", + }, + { + name: "valid claim mappings", + in: api.ClaimMappings{ + Username: api.PrefixedClaimOrExpression{Claim: "claim", Prefix: pointer.String("prefix")}, + Groups: api.PrefixedClaimOrExpression{Claim: "claim", Prefix: pointer.String("prefix")}, + }, + want: "", + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + got := validateClaimMappings(tt.in, fldPath).ToAggregate() + if d := cmp.Diff(tt.want, errString(got)); d != "" { + t.Fatalf("ClaimMappings validation mismatch (-want +got):\n%s", d) + } + }) + } +} + +func errString(errs errors.Aggregate) string { + if errs != nil { + return errs.Error() + } + return "" +} diff --git a/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/oidc.go b/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/oidc.go index 9e13c58350e..c48ddb49ad3 100644 --- a/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/oidc.go +++ b/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/oidc.go @@ -32,11 +32,9 @@ import ( "crypto/x509" "encoding/base64" "encoding/json" - "errors" "fmt" "io/ioutil" "net/http" - "net/url" "strings" "sync" "sync/atomic" @@ -46,6 +44,8 @@ import ( "k8s.io/apimachinery/pkg/util/net" "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/apiserver/pkg/apis/apiserver" + apiservervalidation "k8s.io/apiserver/pkg/apis/apiserver/validation" "k8s.io/apiserver/pkg/authentication/authenticator" "k8s.io/apiserver/pkg/authentication/user" certutil "k8s.io/client-go/util/cert" @@ -59,50 +59,17 @@ var ( ) type Options struct { - // IssuerURL is the URL the provider signs ID Tokens as. This will be the "iss" - // field of all tokens produced by the provider and is used for configuration - // discovery. - // - // The URL is usually the provider's URL without a path, for example - // "https://accounts.google.com" or "https://login.salesforce.com". - // - // The provider must implement configuration discovery. - // See: https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig - IssuerURL string - + // JWTAuthenticator is the authenticator that will be used to verify the JWT. + JWTAuthenticator apiserver.JWTAuthenticator // Optional KeySet to allow for synchronous initialization instead of fetching from the remote issuer. KeySet oidc.KeySet - // ClientID the JWT must be issued for, the "sub" field. This plugin only trusts a single - // client to ensure the plugin can be used with public providers. - // - // The plugin supports the "authorized party" OpenID Connect claim, which allows - // specialized providers to issue tokens to a client for a different client. - // See: https://openid.net/specs/openid-connect-core-1_0.html#IDToken - ClientID string - // PEM encoded root certificate contents of the provider. Mutually exclusive with Client. CAContentProvider CAContentProvider // Optional http.Client used to make all requests to the remote issuer. Mutually exclusive with CAContentProvider. Client *http.Client - // UsernameClaim is the JWT field to use as the user's username. - UsernameClaim string - - // UsernamePrefix, if specified, causes claims mapping to username to be prefix with - // the provided value. A value "oidc:" would result in usernames like "oidc:john". - UsernamePrefix string - - // GroupsClaim, if specified, causes the OIDCAuthenticator to try to populate the user's - // groups with an ID Token field. If the GroupsClaim field is present in an ID Token the value - // must be a string or list of strings. - GroupsClaim string - - // GroupsPrefix, if specified, causes claims mapping to group names to be prefixed with the - // value. A value "oidc:" would result in groups like "oidc:engineering" and "oidc:marketing". - GroupsPrefix string - // SupportedSigningAlgs sets the accepted set of JOSE signing algorithms that // can be used by the provider to sign tokens. // @@ -114,10 +81,6 @@ type Options struct { // https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation SupportedSigningAlgs []string - // RequiredClaims, if specified, causes the OIDCAuthenticator to verify that all the - // required claims key value pairs are present in the ID Token. - RequiredClaims map[string]string - // now is used for testing. It defaults to time.Now. now func() time.Time } @@ -192,13 +155,7 @@ func (a *asyncIDTokenVerifier) verifier() *oidc.IDTokenVerifier { } type Authenticator struct { - issuerURL string - - usernameClaim string - usernamePrefix string - groupsClaim string - groupsPrefix string - requiredClaims map[string]string + jwtAuthenticator apiserver.JWTAuthenticator // Contains an *oidc.IDTokenVerifier. Do not access directly use the // idTokenVerifier method. @@ -240,19 +197,10 @@ var allowedSigningAlgs = map[string]bool{ } func New(opts Options) (*Authenticator, error) { - url, err := url.Parse(opts.IssuerURL) - if err != nil { + if err := apiservervalidation.ValidateJWTAuthenticator(opts.JWTAuthenticator).ToAggregate(); err != nil { return nil, err } - if url.Scheme != "https" { - return nil, fmt.Errorf("'oidc-issuer-url' (%q) has invalid scheme (%q), require 'https'", opts.IssuerURL, url.Scheme) - } - - if opts.UsernameClaim == "" { - return nil, errors.New("no username claim provided") - } - supportedSigningAlgs := opts.SupportedSigningAlgs if len(supportedSigningAlgs) == 0 { // RS256 is the default recommended by OpenID Connect and an 'alg' value @@ -273,6 +221,7 @@ func New(opts Options) (*Authenticator, error) { if client == nil { var roots *x509.CertPool + var err error if opts.CAContentProvider != nil { // TODO(enj): make this reload CA data dynamically roots, err = certutil.NewPoolFromBytes(opts.CAContentProvider.CurrentCABundleContent()) @@ -302,35 +251,30 @@ func New(opts Options) (*Authenticator, error) { } verifierConfig := &oidc.Config{ - ClientID: opts.ClientID, + ClientID: opts.JWTAuthenticator.Issuer.Audiences[0], SupportedSigningAlgs: supportedSigningAlgs, Now: now, } var resolver *claimResolver - if opts.GroupsClaim != "" { - resolver = newClaimResolver(opts.GroupsClaim, client, verifierConfig) + if opts.JWTAuthenticator.ClaimMappings.Groups.Claim != "" { + resolver = newClaimResolver(opts.JWTAuthenticator.ClaimMappings.Groups.Claim, client, verifierConfig) } authenticator := &Authenticator{ - issuerURL: opts.IssuerURL, - usernameClaim: opts.UsernameClaim, - usernamePrefix: opts.UsernamePrefix, - groupsClaim: opts.GroupsClaim, - groupsPrefix: opts.GroupsPrefix, - requiredClaims: opts.RequiredClaims, - cancel: cancel, - resolver: resolver, + jwtAuthenticator: opts.JWTAuthenticator, + cancel: cancel, + resolver: resolver, } if opts.KeySet != nil { // We already have a key set, synchronously initialize the verifier. - authenticator.setVerifier(oidc.NewVerifier(opts.IssuerURL, opts.KeySet, verifierConfig)) + authenticator.setVerifier(oidc.NewVerifier(opts.JWTAuthenticator.Issuer.URL, opts.KeySet, verifierConfig)) } else { // Asynchronously attempt to initialize the authenticator. This enables // self-hosted providers, providers that run on top of Kubernetes itself. go wait.PollImmediateUntil(10*time.Second, func() (done bool, err error) { - provider, err := oidc.NewProvider(ctx, opts.IssuerURL) + provider, err := oidc.NewProvider(ctx, opts.JWTAuthenticator.Issuer.URL) if err != nil { klog.Errorf("oidc authenticator: initializing plugin: %v", err) return false, nil @@ -552,7 +496,7 @@ func (r *claimResolver) resolve(ctx context.Context, endpoint endpoint, allClaim } func (a *Authenticator) AuthenticateToken(ctx context.Context, token string) (*authenticator.Response, bool, error) { - if !hasCorrectIssuer(a.issuerURL, token) { + if !hasCorrectIssuer(a.jwtAuthenticator.Issuer.URL, token) { return nil, false, nil } @@ -577,11 +521,11 @@ func (a *Authenticator) AuthenticateToken(ctx context.Context, token string) (*a } var username string - if err := c.unmarshalClaim(a.usernameClaim, &username); err != nil { - return nil, false, fmt.Errorf("oidc: parse username claims %q: %v", a.usernameClaim, err) + if err := c.unmarshalClaim(a.jwtAuthenticator.ClaimMappings.Username.Claim, &username); err != nil { + return nil, false, fmt.Errorf("oidc: parse username claims %q: %v", a.jwtAuthenticator.ClaimMappings.Username.Claim, err) } - if a.usernameClaim == "email" { + if a.jwtAuthenticator.ClaimMappings.Username.Claim == "email" { // If the email_verified claim is present, ensure the email is valid. // https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims if hasEmailVerified := c.hasClaim("email_verified"); hasEmailVerified { @@ -597,33 +541,36 @@ func (a *Authenticator) AuthenticateToken(ctx context.Context, token string) (*a } } - if a.usernamePrefix != "" { - username = a.usernamePrefix + username + if a.jwtAuthenticator.ClaimMappings.Username.Prefix != nil && *a.jwtAuthenticator.ClaimMappings.Username.Prefix != "" { + username = *a.jwtAuthenticator.ClaimMappings.Username.Prefix + username } info := &user.DefaultInfo{Name: username} - if a.groupsClaim != "" { - if _, ok := c[a.groupsClaim]; ok { + if a.jwtAuthenticator.ClaimMappings.Groups.Claim != "" { + if _, ok := c[a.jwtAuthenticator.ClaimMappings.Groups.Claim]; ok { // Some admins want to use string claims like "role" as the group value. // Allow the group claim to be a single string instead of an array. // // See: https://github.com/kubernetes/kubernetes/issues/33290 var groups stringOrArray - if err := c.unmarshalClaim(a.groupsClaim, &groups); err != nil { - return nil, false, fmt.Errorf("oidc: parse groups claim %q: %v", a.groupsClaim, err) + if err := c.unmarshalClaim(a.jwtAuthenticator.ClaimMappings.Groups.Claim, &groups); err != nil { + return nil, false, fmt.Errorf("oidc: parse groups claim %q: %v", a.jwtAuthenticator.ClaimMappings.Groups.Claim, err) } info.Groups = []string(groups) } } - if a.groupsPrefix != "" { + if a.jwtAuthenticator.ClaimMappings.Groups.Prefix != nil && *a.jwtAuthenticator.ClaimMappings.Groups.Prefix != "" { for i, group := range info.Groups { - info.Groups[i] = a.groupsPrefix + group + info.Groups[i] = *a.jwtAuthenticator.ClaimMappings.Groups.Prefix + group } } // check to ensure all required claims are present in the ID token and have matching values. - for claim, value := range a.requiredClaims { + for _, claimValidationRule := range a.jwtAuthenticator.ClaimValidationRules { + claim := claimValidationRule.Claim + value := claimValidationRule.RequiredValue + if !c.hasClaim(claim) { return nil, false, fmt.Errorf("oidc: required claim %s not present in ID token", claim) } diff --git a/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/oidc_test.go b/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/oidc_test.go index b6081a1b716..692b5cea1a4 100644 --- a/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/oidc_test.go +++ b/staging/src/k8s.io/apiserver/plugin/pkg/authenticator/token/oidc/oidc_test.go @@ -36,9 +36,11 @@ import ( "gopkg.in/square/go-jose.v2" + "k8s.io/apiserver/pkg/apis/apiserver" "k8s.io/apiserver/pkg/authentication/user" "k8s.io/apiserver/pkg/server/dynamiccertificates" "k8s.io/klog/v2" + "k8s.io/utils/pointer" ) // utilities for loading JOSE keys. @@ -255,7 +257,7 @@ func (c *claimsTest) run(t *testing.T) { } c.claims = replace(c.claims, &v) c.openIDConfig = replace(c.openIDConfig, &v) - c.options.IssuerURL = replace(c.options.IssuerURL, &v) + c.options.JWTAuthenticator.Issuer.URL = replace(c.options.JWTAuthenticator.Issuer.URL, &v) for claim, response := range c.claimToResponseMap { c.claimToResponseMap[claim] = replace(response, &v) } @@ -336,10 +338,19 @@ func TestToken(t *testing.T) { { name: "token", options: Options{ - IssuerURL: "https://auth.example.com", - ClientID: "my-client", - UsernameClaim: "username", - now: func() time.Time { return now }, + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com", + Audiences: []string{"my-client"}, + }, + 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{ @@ -358,10 +369,19 @@ func TestToken(t *testing.T) { { name: "no-username", options: Options{ - IssuerURL: "https://auth.example.com", - ClientID: "my-client", - UsernameClaim: "username", - now: func() time.Time { return now }, + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Claim: "username", + Prefix: pointer.String("prefix:"), + }, + }, + }, + now: func() time.Time { return now }, }, signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), pubKeys: []*jose.JSONWebKey{ @@ -377,10 +397,19 @@ func TestToken(t *testing.T) { { name: "email", options: Options{ - IssuerURL: "https://auth.example.com", - ClientID: "my-client", - UsernameClaim: "email", - now: func() time.Time { return now }, + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Claim: "email", + Prefix: pointer.String(""), + }, + }, + }, + now: func() time.Time { return now }, }, signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), pubKeys: []*jose.JSONWebKey{ @@ -400,10 +429,19 @@ func TestToken(t *testing.T) { { name: "email-not-verified", options: Options{ - IssuerURL: "https://auth.example.com", - ClientID: "my-client", - UsernameClaim: "email", - now: func() time.Time { return now }, + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Claim: "email", + Prefix: pointer.String(""), + }, + }, + }, + now: func() time.Time { return now }, }, signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), pubKeys: []*jose.JSONWebKey{ @@ -422,10 +460,19 @@ func TestToken(t *testing.T) { // If "email_verified" isn't present, assume true name: "no-email-verified-claim", options: Options{ - IssuerURL: "https://auth.example.com", - ClientID: "my-client", - UsernameClaim: "email", - now: func() time.Time { return now }, + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Claim: "email", + Prefix: pointer.String(""), + }, + }, + }, + now: func() time.Time { return now }, }, signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), pubKeys: []*jose.JSONWebKey{ @@ -444,10 +491,19 @@ func TestToken(t *testing.T) { { name: "invalid-email-verified-claim", options: Options{ - IssuerURL: "https://auth.example.com", - ClientID: "my-client", - UsernameClaim: "email", - now: func() time.Time { return now }, + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Claim: "email", + Prefix: pointer.String(""), + }, + }, + }, + now: func() time.Time { return now }, }, signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), pubKeys: []*jose.JSONWebKey{ @@ -466,11 +522,23 @@ func TestToken(t *testing.T) { { name: "groups", options: Options{ - IssuerURL: "https://auth.example.com", - ClientID: "my-client", - UsernameClaim: "username", - GroupsClaim: "groups", - now: func() time.Time { return now }, + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Claim: "username", + Prefix: pointer.String(""), + }, + Groups: apiserver.PrefixedClaimOrExpression{ + Claim: "groups", + Prefix: pointer.String(""), + }, + }, + }, + now: func() time.Time { return now }, }, signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), pubKeys: []*jose.JSONWebKey{ @@ -491,11 +559,23 @@ func TestToken(t *testing.T) { { name: "groups-distributed", options: Options{ - IssuerURL: "{{.URL}}", - ClientID: "my-client", - UsernameClaim: "username", - GroupsClaim: "groups", - now: func() time.Time { return now }, + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "{{.URL}}", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Claim: "username", + Prefix: pointer.String(""), + }, + Groups: apiserver.PrefixedClaimOrExpression{ + Claim: "groups", + Prefix: pointer.String(""), + }, + }, + }, + now: func() time.Time { return now }, }, signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), pubKeys: []*jose.JSONWebKey{ @@ -536,12 +616,24 @@ func TestToken(t *testing.T) { { name: "groups-distributed invalid client", options: Options{ - IssuerURL: "{{.URL}}", - ClientID: "my-client", - UsernameClaim: "username", - GroupsClaim: "groups", - Client: &http.Client{Transport: errTransport("some unexpected oidc error")}, // return an error that we can assert against - now: func() time.Time { return now }, + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "{{.URL}}", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Claim: "username", + Prefix: pointer.String(""), + }, + Groups: apiserver.PrefixedClaimOrExpression{ + Claim: "groups", + Prefix: pointer.String(""), + }, + }, + }, + Client: &http.Client{Transport: errTransport("some unexpected oidc error")}, // return an error that we can assert against + now: func() time.Time { return now }, }, signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), pubKeys: []*jose.JSONWebKey{ @@ -582,11 +674,23 @@ func TestToken(t *testing.T) { { name: "groups-distributed-malformed-claim-names", options: Options{ - IssuerURL: "{{.URL}}", - ClientID: "my-client", - UsernameClaim: "username", - GroupsClaim: "groups", - now: func() time.Time { return now }, + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "{{.URL}}", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Claim: "username", + Prefix: pointer.String(""), + }, + Groups: apiserver.PrefixedClaimOrExpression{ + Claim: "groups", + Prefix: pointer.String(""), + }, + }, + }, + now: func() time.Time { return now }, }, signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), pubKeys: []*jose.JSONWebKey{ @@ -624,11 +728,23 @@ func TestToken(t *testing.T) { { name: "groups-distributed-malformed-names-and-sources", options: Options{ - IssuerURL: "{{.URL}}", - ClientID: "my-client", - UsernameClaim: "username", - GroupsClaim: "groups", - now: func() time.Time { return now }, + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "{{.URL}}", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Claim: "username", + Prefix: pointer.String(""), + }, + Groups: apiserver.PrefixedClaimOrExpression{ + Claim: "groups", + Prefix: pointer.String(""), + }, + }, + }, + now: func() time.Time { return now }, }, signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), pubKeys: []*jose.JSONWebKey{ @@ -660,11 +776,23 @@ func TestToken(t *testing.T) { { name: "groups-distributed-malformed-distributed-claim", options: Options{ - IssuerURL: "{{.URL}}", - ClientID: "my-client", - UsernameClaim: "username", - GroupsClaim: "groups", - now: func() time.Time { return now }, + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "{{.URL}}", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Claim: "username", + Prefix: pointer.String(""), + }, + Groups: apiserver.PrefixedClaimOrExpression{ + Claim: "groups", + Prefix: pointer.String(""), + }, + }, + }, + now: func() time.Time { return now }, }, signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), pubKeys: []*jose.JSONWebKey{ @@ -702,11 +830,23 @@ func TestToken(t *testing.T) { { name: "groups-distributed-unusual-name", options: Options{ - IssuerURL: "{{.URL}}", - ClientID: "my-client", - UsernameClaim: "username", - GroupsClaim: "rabbits", - now: func() time.Time { return now }, + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "{{.URL}}", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Claim: "username", + Prefix: pointer.String(""), + }, + Groups: apiserver.PrefixedClaimOrExpression{ + Claim: "rabbits", + Prefix: pointer.String(""), + }, + }, + }, + now: func() time.Time { return now }, }, signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), pubKeys: []*jose.JSONWebKey{ @@ -747,11 +887,23 @@ func TestToken(t *testing.T) { { name: "groups-distributed-wrong-audience", options: Options{ - IssuerURL: "{{.URL}}", - ClientID: "my-client", - UsernameClaim: "username", - GroupsClaim: "groups", - now: func() time.Time { return now }, + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "{{.URL}}", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Claim: "username", + Prefix: pointer.String(""), + }, + Groups: apiserver.PrefixedClaimOrExpression{ + Claim: "groups", + Prefix: pointer.String(""), + }, + }, + }, + now: func() time.Time { return now }, }, signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), pubKeys: []*jose.JSONWebKey{ @@ -790,11 +942,23 @@ func TestToken(t *testing.T) { { name: "groups-distributed-expired-token", options: Options{ - IssuerURL: "{{.URL}}", - ClientID: "my-client", - UsernameClaim: "username", - GroupsClaim: "groups", - now: func() time.Time { return now }, + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "{{.URL}}", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Claim: "username", + Prefix: pointer.String(""), + }, + Groups: apiserver.PrefixedClaimOrExpression{ + Claim: "groups", + Prefix: pointer.String(""), + }, + }, + }, + now: func() time.Time { return now }, }, signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), pubKeys: []*jose.JSONWebKey{ @@ -835,11 +999,23 @@ func TestToken(t *testing.T) { // normal claim wins over a distributed claim by the same name. name: "groups-distributed-normal-claim-wins", options: Options{ - IssuerURL: "{{.URL}}", - ClientID: "my-client", - UsernameClaim: "username", - GroupsClaim: "groups", - now: func() time.Time { return now }, + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "{{.URL}}", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Claim: "username", + Prefix: pointer.String(""), + }, + Groups: apiserver.PrefixedClaimOrExpression{ + Claim: "groups", + Prefix: pointer.String(""), + }, + }, + }, + now: func() time.Time { return now }, }, signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), pubKeys: []*jose.JSONWebKey{ @@ -883,11 +1059,23 @@ func TestToken(t *testing.T) { // Groups should be able to be a single string, not just a slice. name: "group-string-claim", options: Options{ - IssuerURL: "https://auth.example.com", - ClientID: "my-client", - UsernameClaim: "username", - GroupsClaim: "groups", - now: func() time.Time { return now }, + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Claim: "username", + Prefix: pointer.String(""), + }, + Groups: apiserver.PrefixedClaimOrExpression{ + Claim: "groups", + Prefix: pointer.String(""), + }, + }, + }, + now: func() time.Time { return now }, }, signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), pubKeys: []*jose.JSONWebKey{ @@ -909,11 +1097,23 @@ func TestToken(t *testing.T) { // Groups should be able to be a single string, not just a slice. name: "group-string-claim-distributed", options: Options{ - IssuerURL: "{{.URL}}", - ClientID: "my-client", - UsernameClaim: "username", - GroupsClaim: "groups", - now: func() time.Time { return now }, + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "{{.URL}}", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Claim: "username", + Prefix: pointer.String(""), + }, + Groups: apiserver.PrefixedClaimOrExpression{ + Claim: "groups", + Prefix: pointer.String(""), + }, + }, + }, + now: func() time.Time { return now }, }, signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), pubKeys: []*jose.JSONWebKey{ @@ -954,11 +1154,23 @@ func TestToken(t *testing.T) { { name: "group-string-claim-aggregated-not-supported", options: Options{ - IssuerURL: "https://auth.example.com", - ClientID: "my-client", - UsernameClaim: "username", - GroupsClaim: "groups", - now: func() time.Time { return now }, + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Claim: "username", + Prefix: pointer.String(""), + }, + Groups: apiserver.PrefixedClaimOrExpression{ + Claim: "groups", + Prefix: pointer.String(""), + }, + }, + }, + now: func() time.Time { return now }, }, signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), pubKeys: []*jose.JSONWebKey{ @@ -986,11 +1198,23 @@ func TestToken(t *testing.T) { // if the groups claim isn't provided, this shouldn't error out name: "no-groups-claim", options: Options{ - IssuerURL: "https://auth.example.com", - ClientID: "my-client", - UsernameClaim: "username", - GroupsClaim: "groups", - now: func() time.Time { return now }, + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Claim: "username", + Prefix: pointer.String(""), + }, + Groups: apiserver.PrefixedClaimOrExpression{ + Claim: "groups", + Prefix: pointer.String(""), + }, + }, + }, + now: func() time.Time { return now }, }, signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), pubKeys: []*jose.JSONWebKey{ @@ -1009,11 +1233,23 @@ func TestToken(t *testing.T) { { name: "invalid-groups-claim", options: Options{ - IssuerURL: "https://auth.example.com", - ClientID: "my-client", - UsernameClaim: "username", - GroupsClaim: "groups", - now: func() time.Time { return now }, + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Claim: "username", + Prefix: pointer.String(""), + }, + Groups: apiserver.PrefixedClaimOrExpression{ + Claim: "groups", + Prefix: pointer.String(""), + }, + }, + }, + now: func() time.Time { return now }, }, signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), pubKeys: []*jose.JSONWebKey{ @@ -1031,13 +1267,31 @@ func TestToken(t *testing.T) { { name: "required-claim", options: Options{ - IssuerURL: "https://auth.example.com", - ClientID: "my-client", - UsernameClaim: "username", - GroupsClaim: "groups", - RequiredClaims: map[string]string{ - "hd": "example.com", - "sub": "test", + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Claim: "username", + Prefix: pointer.String(""), + }, + Groups: apiserver.PrefixedClaimOrExpression{ + Claim: "groups", + Prefix: pointer.String(""), + }, + }, + ClaimValidationRules: []apiserver.ClaimValidationRule{ + { + Claim: "hd", + RequiredValue: "example.com", + }, + { + Claim: "sub", + RequiredValue: "test", + }, + }, }, now: func() time.Time { return now }, }, @@ -1060,12 +1314,27 @@ func TestToken(t *testing.T) { { name: "no-required-claim", options: Options{ - IssuerURL: "https://auth.example.com", - ClientID: "my-client", - UsernameClaim: "username", - GroupsClaim: "groups", - RequiredClaims: map[string]string{ - "hd": "example.com", + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Claim: "username", + Prefix: pointer.String(""), + }, + Groups: apiserver.PrefixedClaimOrExpression{ + Claim: "groups", + Prefix: pointer.String(""), + }, + }, + ClaimValidationRules: []apiserver.ClaimValidationRule{ + { + Claim: "hd", + RequiredValue: "example.com", + }, + }, }, now: func() time.Time { return now }, }, @@ -1084,12 +1353,27 @@ func TestToken(t *testing.T) { { name: "invalid-required-claim", options: Options{ - IssuerURL: "https://auth.example.com", - ClientID: "my-client", - UsernameClaim: "username", - GroupsClaim: "groups", - RequiredClaims: map[string]string{ - "hd": "example.com", + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Claim: "username", + Prefix: pointer.String(""), + }, + Groups: apiserver.PrefixedClaimOrExpression{ + Claim: "groups", + Prefix: pointer.String(""), + }, + }, + ClaimValidationRules: []apiserver.ClaimValidationRule{ + { + Claim: "hd", + RequiredValue: "example.com", + }, + }, }, now: func() time.Time { return now }, }, @@ -1109,10 +1393,19 @@ func TestToken(t *testing.T) { { name: "invalid-signature", options: Options{ - IssuerURL: "https://auth.example.com", - ClientID: "my-client", - UsernameClaim: "username", - now: func() time.Time { return now }, + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Claim: "username", + Prefix: pointer.String("prefix:"), + }, + }, + }, + now: func() time.Time { return now }, }, signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), pubKeys: []*jose.JSONWebKey{ @@ -1129,10 +1422,19 @@ func TestToken(t *testing.T) { { name: "expired", options: Options{ - IssuerURL: "https://auth.example.com", - ClientID: "my-client", - UsernameClaim: "username", - now: func() time.Time { return now }, + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Claim: "username", + Prefix: pointer.String("prefix:"), + }, + }, + }, + now: func() time.Time { return now }, }, signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), pubKeys: []*jose.JSONWebKey{ @@ -1149,10 +1451,19 @@ func TestToken(t *testing.T) { { name: "invalid-aud", options: Options{ - IssuerURL: "https://auth.example.com", - ClientID: "my-client", - UsernameClaim: "username", - now: func() time.Time { return now }, + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Claim: "username", + Prefix: pointer.String("prefix:"), + }, + }, + }, + now: func() time.Time { return now }, }, signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), pubKeys: []*jose.JSONWebKey{ @@ -1171,10 +1482,19 @@ func TestToken(t *testing.T) { // https://openid.net/specs/openid-connect-core-1_0.html#IDToken name: "multiple-audiences", options: Options{ - IssuerURL: "https://auth.example.com", - ClientID: "my-client", - UsernameClaim: "username", - now: func() time.Time { return now }, + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com", + Audiences: []string{"my-client"}, + }, + 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{ @@ -1194,10 +1514,19 @@ func TestToken(t *testing.T) { { name: "invalid-issuer", options: Options{ - IssuerURL: "https://auth.example.com", - ClientID: "my-client", - UsernameClaim: "username", - now: func() time.Time { return now }, + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Claim: "username", + Prefix: pointer.String("prefix:"), + }, + }, + }, + now: func() time.Time { return now }, }, signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), pubKeys: []*jose.JSONWebKey{ @@ -1214,11 +1543,19 @@ func TestToken(t *testing.T) { { name: "username-prefix", options: Options{ - IssuerURL: "https://auth.example.com", - ClientID: "my-client", - UsernameClaim: "username", - UsernamePrefix: "oidc:", - now: func() time.Time { return now }, + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Claim: "username", + Prefix: pointer.String("oidc:"), + }, + }, + }, + now: func() time.Time { return now }, }, signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), pubKeys: []*jose.JSONWebKey{ @@ -1237,13 +1574,23 @@ func TestToken(t *testing.T) { { name: "groups-prefix", options: Options{ - IssuerURL: "https://auth.example.com", - ClientID: "my-client", - UsernameClaim: "username", - UsernamePrefix: "oidc:", - GroupsClaim: "groups", - GroupsPrefix: "groups:", - now: func() time.Time { return now }, + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Claim: "username", + Prefix: pointer.String("oidc:"), + }, + Groups: apiserver.PrefixedClaimOrExpression{ + Claim: "groups", + Prefix: pointer.String("groups:"), + }, + }, + }, + now: func() time.Time { return now }, }, signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), pubKeys: []*jose.JSONWebKey{ @@ -1264,13 +1611,23 @@ func TestToken(t *testing.T) { { name: "groups-prefix-distributed", options: Options{ - IssuerURL: "{{.URL}}", - ClientID: "my-client", - UsernameClaim: "username", - UsernamePrefix: "oidc:", - GroupsClaim: "groups", - GroupsPrefix: "groups:", - now: func() time.Time { return now }, + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "{{.URL}}", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Claim: "username", + Prefix: pointer.String("oidc:"), + }, + Groups: apiserver.PrefixedClaimOrExpression{ + Claim: "groups", + Prefix: pointer.String("groups:"), + }, + }, + }, + now: func() time.Time { return now }, }, signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), pubKeys: []*jose.JSONWebKey{ @@ -1311,10 +1668,19 @@ func TestToken(t *testing.T) { { name: "invalid-signing-alg", options: Options{ - IssuerURL: "https://auth.example.com", - ClientID: "my-client", - UsernameClaim: "username", - now: func() time.Time { return now }, + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Claim: "username", + Prefix: pointer.String("prefix:"), + }, + }, + }, + now: func() time.Time { return now }, }, // Correct key but invalid signature algorithm "PS256" signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.PS256), @@ -1332,9 +1698,18 @@ func TestToken(t *testing.T) { { name: "ps256", options: Options{ - IssuerURL: "https://auth.example.com", - ClientID: "my-client", - UsernameClaim: "username", + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Claim: "username", + Prefix: pointer.String(""), + }, + }, + }, SupportedSigningAlgs: []string{"PS256"}, now: func() time.Time { return now }, }, @@ -1355,9 +1730,18 @@ func TestToken(t *testing.T) { { name: "es512", options: Options{ - IssuerURL: "https://auth.example.com", - ClientID: "my-client", - UsernameClaim: "username", + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Claim: "username", + Prefix: pointer.String(""), + }, + }, + }, SupportedSigningAlgs: []string{"ES512"}, now: func() time.Time { return now }, }, @@ -1379,34 +1763,61 @@ func TestToken(t *testing.T) { { name: "not-https", options: Options{ - IssuerURL: "http://auth.example.com", - ClientID: "my-client", - UsernameClaim: "username", - now: func() time.Time { return now }, + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "http://auth.example.com", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Claim: "username", + Prefix: pointer.String("prefix:"), + }, + }, + }, + now: func() time.Time { return now }, }, pubKeys: []*jose.JSONWebKey{ loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256), }, - wantInitErr: `'oidc-issuer-url' ("http://auth.example.com") has invalid scheme ("http"), require 'https'`, + wantInitErr: `issuer.url: Invalid value: "http://auth.example.com": URL scheme must be https`, }, { name: "no-username-claim", options: Options{ - IssuerURL: "https://auth.example.com", - ClientID: "my-client", - now: func() time.Time { return now }, + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Prefix: pointer.String(""), + }, + }, + }, + now: func() time.Time { return now }, }, pubKeys: []*jose.JSONWebKey{ loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256), }, - wantInitErr: "no username claim provided", + wantInitErr: `claimMappings.username.claim: Required value: claim name is required`, }, { name: "invalid-sig-alg", options: Options{ - IssuerURL: "https://auth.example.com", - ClientID: "my-client", - UsernameClaim: "username", + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Claim: "username", + Prefix: pointer.String("prefix:"), + }, + }, + }, SupportedSigningAlgs: []string{"HS256"}, now: func() time.Time { return now }, }, @@ -1418,9 +1829,18 @@ func TestToken(t *testing.T) { { name: "client and ca mutually exclusive", options: Options{ - IssuerURL: "https://auth.example.com", - ClientID: "my-client", - UsernameClaim: "username", + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Claim: "username", + Prefix: pointer.String("prefix:"), + }, + }, + }, SupportedSigningAlgs: []string{"RS256"}, now: func() time.Time { return now }, Client: http.DefaultClient, // test automatically sets CAContentProvider @@ -1433,10 +1853,19 @@ func TestToken(t *testing.T) { { name: "accounts.google.com issuer", options: Options{ - IssuerURL: "https://accounts.google.com", - ClientID: "my-client", - UsernameClaim: "email", - now: func() time.Time { return now }, + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://accounts.google.com", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Claim: "email", + Prefix: pointer.String(""), + }, + }, + }, + now: func() time.Time { return now }, }, claims: fmt.Sprintf(`{ "iss": "accounts.google.com", @@ -1455,10 +1884,19 @@ func TestToken(t *testing.T) { { name: "good token with bad client id", options: Options{ - IssuerURL: "https://auth.example.com", - ClientID: "my-client", - UsernameClaim: "username", - now: func() time.Time { return now }, + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com", + Audiences: []string{"my-client"}, + }, + ClaimMappings: apiserver.ClaimMappings{ + Username: apiserver.PrefixedClaimOrExpression{ + Claim: "username", + Prefix: pointer.String("prefix:"), + }, + }, + }, + now: func() time.Time { return now }, }, signingKey: loadRSAPrivKey(t, "testdata/rsa_1.pem", jose.RS256), pubKeys: []*jose.JSONWebKey{ diff --git a/vendor/modules.txt b/vendor/modules.txt index 8a1bc7e73fd..f0c1e786728 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -1455,6 +1455,7 @@ k8s.io/apiserver/pkg/apis/apiserver/install k8s.io/apiserver/pkg/apis/apiserver/v1 k8s.io/apiserver/pkg/apis/apiserver/v1alpha1 k8s.io/apiserver/pkg/apis/apiserver/v1beta1 +k8s.io/apiserver/pkg/apis/apiserver/validation k8s.io/apiserver/pkg/apis/audit k8s.io/apiserver/pkg/apis/audit/install k8s.io/apiserver/pkg/apis/audit/v1