diff --git a/staging/src/k8s.io/apiserver/pkg/apis/apiserver/types.go b/staging/src/k8s.io/apiserver/pkg/apis/apiserver/types.go index 9153dfaf79af2..a31b8753693b0 100644 --- a/staging/src/k8s.io/apiserver/pkg/apis/apiserver/types.go +++ b/staging/src/k8s.io/apiserver/pkg/apis/apiserver/types.go @@ -175,9 +175,43 @@ type JWTAuthenticator struct { UserValidationRules []UserValidationRule } -// Issuer provides the configuration for a external provider specific settings. +// Issuer provides the configuration for an external provider's specific settings. type Issuer struct { - URL string + // url points to the issuer URL in a format https://url or https://url/path. + // This must match the "iss" claim in the presented JWT, and the issuer returned from discovery. + // Same value as the --oidc-issuer-url flag. + // Discovery information is fetched from "{url}/.well-known/openid-configuration" unless overridden by discoveryURL. + // Required to be unique across all JWT authenticators. + // Note that egress selection configuration is not used for this network connection. + // +required + URL string + // discoveryURL, if specified, overrides the URL used to fetch discovery + // information instead of using "{url}/.well-known/openid-configuration". + // The exact value specified is used, so "/.well-known/openid-configuration" + // must be included in discoveryURL if needed. + // + // The "issuer" field in the fetched discovery information must match the "issuer.url" field + // in the AuthenticationConfiguration and will be used to validate the "iss" claim in the presented JWT. + // This is for scenarios where the well-known and jwks endpoints are hosted at a different + // location than the issuer (such as locally in the cluster). + // + // Example: + // A discovery url that is exposed using kubernetes service 'oidc' in namespace 'oidc-namespace' + // and discovery information is available at '/.well-known/openid-configuration'. + // discoveryURL: "https://oidc.oidc-namespace/.well-known/openid-configuration" + // certificateAuthority is used to verify the TLS connection and the hostname on the leaf certificate + // must be set to 'oidc.oidc-namespace'. + // + // curl https://oidc.oidc-namespace/.well-known/openid-configuration (.discoveryURL field) + // { + // issuer: "https://oidc.example.com" (.url field) + // } + // + // discoveryURL must be different from url. + // Required to be unique across all JWT authenticators. + // Note that egress selection configuration is not used for this network connection. + // +optional + DiscoveryURL string CertificateAuthority string Audiences []string AudienceMatchPolicy AudienceMatchPolicyType diff --git a/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1alpha1/types.go b/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1alpha1/types.go index be9a67ef58824..840c6f1ec2512 100644 --- a/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1alpha1/types.go +++ b/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1alpha1/types.go @@ -209,17 +209,45 @@ type JWTAuthenticator struct { UserValidationRules []UserValidationRule `json:"userValidationRules,omitempty"` } -// Issuer provides the configuration for a external provider specific settings. +// Issuer provides the configuration for an external provider's specific settings. type Issuer struct { // url points to the issuer URL in a format https://url or https://url/path. // This must match the "iss" claim in the presented JWT, and the issuer returned from discovery. // Same value as the --oidc-issuer-url flag. - // Used to fetch discovery information unless overridden by discoveryURL. - // Required to be unique. + // Discovery information is fetched from "{url}/.well-known/openid-configuration" unless overridden by discoveryURL. + // Required to be unique across all JWT authenticators. // Note that egress selection configuration is not used for this network connection. // +required URL string `json:"url"` + // discoveryURL, if specified, overrides the URL used to fetch discovery + // information instead of using "{url}/.well-known/openid-configuration". + // The exact value specified is used, so "/.well-known/openid-configuration" + // must be included in discoveryURL if needed. + // + // The "issuer" field in the fetched discovery information must match the "issuer.url" field + // in the AuthenticationConfiguration and will be used to validate the "iss" claim in the presented JWT. + // This is for scenarios where the well-known and jwks endpoints are hosted at a different + // location than the issuer (such as locally in the cluster). + // + // Example: + // A discovery url that is exposed using kubernetes service 'oidc' in namespace 'oidc-namespace' + // and discovery information is available at '/.well-known/openid-configuration'. + // discoveryURL: "https://oidc.oidc-namespace/.well-known/openid-configuration" + // certificateAuthority is used to verify the TLS connection and the hostname on the leaf certificate + // must be set to 'oidc.oidc-namespace'. + // + // curl https://oidc.oidc-namespace/.well-known/openid-configuration (.discoveryURL field) + // { + // issuer: "https://oidc.example.com" (.url field) + // } + // + // discoveryURL must be different from url. + // Required to be unique across all JWT authenticators. + // Note that egress selection configuration is not used for this network connection. + // +optional + DiscoveryURL *string `json:"discoveryURL,omitempty"` + // certificateAuthority contains PEM-encoded certificate authority certificates // used to validate the connection when fetching discovery information. // If unset, the system verifier is used. diff --git a/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1alpha1/zz_generated.conversion.go b/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1alpha1/zz_generated.conversion.go index 815df06aa9f4b..9ee1ef8a4b5b3 100644 --- a/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1alpha1/zz_generated.conversion.go +++ b/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1alpha1/zz_generated.conversion.go @@ -24,6 +24,7 @@ package v1alpha1 import ( unsafe "unsafe" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" conversion "k8s.io/apimachinery/pkg/conversion" runtime "k8s.io/apimachinery/pkg/runtime" apiserver "k8s.io/apiserver/pkg/apis/apiserver" @@ -324,7 +325,17 @@ func Convert_apiserver_AdmissionPluginConfiguration_To_v1alpha1_AdmissionPluginC } func autoConvert_v1alpha1_AuthenticationConfiguration_To_apiserver_AuthenticationConfiguration(in *AuthenticationConfiguration, out *apiserver.AuthenticationConfiguration, s conversion.Scope) error { - out.JWT = *(*[]apiserver.JWTAuthenticator)(unsafe.Pointer(&in.JWT)) + if in.JWT != nil { + in, out := &in.JWT, &out.JWT + *out = make([]apiserver.JWTAuthenticator, len(*in)) + for i := range *in { + if err := Convert_v1alpha1_JWTAuthenticator_To_apiserver_JWTAuthenticator(&(*in)[i], &(*out)[i], s); err != nil { + return err + } + } + } else { + out.JWT = nil + } return nil } @@ -334,7 +345,17 @@ func Convert_v1alpha1_AuthenticationConfiguration_To_apiserver_AuthenticationCon } func autoConvert_apiserver_AuthenticationConfiguration_To_v1alpha1_AuthenticationConfiguration(in *apiserver.AuthenticationConfiguration, out *AuthenticationConfiguration, s conversion.Scope) error { - out.JWT = *(*[]JWTAuthenticator)(unsafe.Pointer(&in.JWT)) + if in.JWT != nil { + in, out := &in.JWT, &out.JWT + *out = make([]JWTAuthenticator, len(*in)) + for i := range *in { + if err := Convert_apiserver_JWTAuthenticator_To_v1alpha1_JWTAuthenticator(&(*in)[i], &(*out)[i], s); err != nil { + return err + } + } + } else { + out.JWT = nil + } return nil } @@ -580,6 +601,9 @@ func Convert_apiserver_ExtraMapping_To_v1alpha1_ExtraMapping(in *apiserver.Extra func autoConvert_v1alpha1_Issuer_To_apiserver_Issuer(in *Issuer, out *apiserver.Issuer, s conversion.Scope) error { out.URL = in.URL + if err := v1.Convert_Pointer_string_To_string(&in.DiscoveryURL, &out.DiscoveryURL, s); err != nil { + return err + } out.CertificateAuthority = in.CertificateAuthority out.Audiences = *(*[]string)(unsafe.Pointer(&in.Audiences)) out.AudienceMatchPolicy = apiserver.AudienceMatchPolicyType(in.AudienceMatchPolicy) @@ -593,6 +617,9 @@ func Convert_v1alpha1_Issuer_To_apiserver_Issuer(in *Issuer, out *apiserver.Issu func autoConvert_apiserver_Issuer_To_v1alpha1_Issuer(in *apiserver.Issuer, out *Issuer, s conversion.Scope) error { out.URL = in.URL + if err := v1.Convert_string_To_Pointer_string(&in.DiscoveryURL, &out.DiscoveryURL, s); err != nil { + return err + } out.CertificateAuthority = in.CertificateAuthority out.Audiences = *(*[]string)(unsafe.Pointer(&in.Audiences)) out.AudienceMatchPolicy = AudienceMatchPolicyType(in.AudienceMatchPolicy) diff --git a/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1alpha1/zz_generated.deepcopy.go b/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1alpha1/zz_generated.deepcopy.go index 932af6127072c..e618178bfecbf 100644 --- a/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1alpha1/zz_generated.deepcopy.go +++ b/staging/src/k8s.io/apiserver/pkg/apis/apiserver/v1alpha1/zz_generated.deepcopy.go @@ -308,6 +308,11 @@ func (in *ExtraMapping) DeepCopy() *ExtraMapping { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Issuer) DeepCopyInto(out *Issuer) { *out = *in + if in.DiscoveryURL != nil { + in, out := &in.DiscoveryURL, &out.DiscoveryURL + *out = new(string) + **out = **in + } if in.Audiences != nil { in, out := &in.Audiences, &out.Audiences *out = make([]string, len(*in)) 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 index ee75cd042f3a6..74513ff3836c4 100644 --- a/staging/src/k8s.io/apiserver/pkg/apis/apiserver/validation/validation.go +++ b/staging/src/k8s.io/apiserver/pkg/apis/apiserver/validation/validation.go @@ -100,21 +100,40 @@ func validateJWTAuthenticator(authenticator api.JWTAuthenticator, fldPath *field 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, validateIssuerURL(issuer.URL, fldPath.Child("url"))...) + allErrs = append(allErrs, validateIssuerDiscoveryURL(issuer.URL, issuer.DiscoveryURL, fldPath.Child("discoveryURL"))...) allErrs = append(allErrs, validateAudiences(issuer.Audiences, issuer.AudienceMatchPolicy, fldPath.Child("audiences"), fldPath.Child("audienceMatchPolicy"))...) allErrs = append(allErrs, validateCertificateAuthority(issuer.CertificateAuthority, fldPath.Child("certificateAuthority"))...) return allErrs } -func validateURL(issuerURL string, fldPath *field.Path) field.ErrorList { +func validateIssuerURL(issuerURL string, fldPath *field.Path) field.ErrorList { + if len(issuerURL) == 0 { + return field.ErrorList{field.Required(fldPath, "URL is required")} + } + + return validateURL(issuerURL, fldPath) +} + +func validateIssuerDiscoveryURL(issuerURL, issuerDiscoveryURL 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 + if len(issuerDiscoveryURL) == 0 { + return nil } + if len(issuerURL) > 0 && strings.TrimRight(issuerURL, "/") == strings.TrimRight(issuerDiscoveryURL, "/") { + allErrs = append(allErrs, field.Invalid(fldPath, issuerDiscoveryURL, "discoveryURL must be different from URL")) + } + + allErrs = append(allErrs, validateURL(issuerDiscoveryURL, fldPath)...) + return allErrs +} + +func validateURL(issuerURL string, fldPath *field.Path) field.ErrorList { + var allErrs field.ErrorList + u, err := url.Parse(issuerURL) if err != nil { allErrs = append(allErrs, field.Invalid(fldPath, issuerURL, err.Error())) 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 index 54cf78e4ae638..551d2cc203d6f 100644 --- 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 @@ -212,7 +212,7 @@ func TestValidateAuthenticationConfiguration(t *testing.T) { } } -func TestValidateURL(t *testing.T) { +func TestValidateIssuerURL(t *testing.T) { fldPath := field.NewPath("issuer", "url") testCases := []struct { @@ -259,7 +259,92 @@ func TestValidateURL(t *testing.T) { for _, tt := range testCases { t.Run(tt.name, func(t *testing.T) { - got := validateURL(tt.in, fldPath).ToAggregate() + got := validateIssuerURL(tt.in, fldPath).ToAggregate() + if d := cmp.Diff(tt.want, errString(got)); d != "" { + t.Fatalf("URL validation mismatch (-want +got):\n%s", d) + } + }) + } +} + +func TestValidateIssuerDiscoveryURL(t *testing.T) { + fldPath := field.NewPath("issuer", "discoveryURL") + + testCases := []struct { + name string + in string + issuerURL string + want string + }{ + { + name: "url is empty", + in: "", + want: "", + }, + { + name: "url parse error", + in: "https://oidc.oidc-namespace.svc:invalid-port", + want: `issuer.discoveryURL: Invalid value: "https://oidc.oidc-namespace.svc:invalid-port": parse "https://oidc.oidc-namespace.svc:invalid-port": invalid port ":invalid-port" after host`, + }, + { + name: "url is not https", + in: "http://oidc.oidc-namespace.svc", + want: `issuer.discoveryURL: Invalid value: "http://oidc.oidc-namespace.svc": URL scheme must be https`, + }, + { + name: "url user info is not allowed", + in: "https://user:pass@oidc.oidc-namespace.svc", + want: `issuer.discoveryURL: Invalid value: "https://user:pass@oidc.oidc-namespace.svc": URL must not contain a username or password`, + }, + { + name: "url raw query is not allowed", + in: "https://oidc.oidc-namespace.svc?query", + want: `issuer.discoveryURL: Invalid value: "https://oidc.oidc-namespace.svc?query": URL must not contain a query`, + }, + { + name: "url fragment is not allowed", + in: "https://oidc.oidc-namespace.svc#fragment", + want: `issuer.discoveryURL: Invalid value: "https://oidc.oidc-namespace.svc#fragment": URL must not contain a fragment`, + }, + { + name: "valid url", + in: "https://oidc.oidc-namespace.svc", + want: "", + }, + { + name: "valid url with path", + in: "https://oidc.oidc-namespace.svc/path", + want: "", + }, + { + name: "discovery url same as issuer url", + issuerURL: "https://issuer-url", + in: "https://issuer-url", + want: `issuer.discoveryURL: Invalid value: "https://issuer-url": discoveryURL must be different from URL`, + }, + { + name: "discovery url same as issuer url, with trailing slash", + issuerURL: "https://issuer-url", + in: "https://issuer-url/", + want: `issuer.discoveryURL: Invalid value: "https://issuer-url/": discoveryURL must be different from URL`, + }, + { + name: "discovery url same as issuer url, with multiple trailing slashes", + issuerURL: "https://issuer-url", + in: "https://issuer-url///", + want: `issuer.discoveryURL: Invalid value: "https://issuer-url///": discoveryURL must be different from URL`, + }, + { + name: "discovery url same as issuer url, issuer url with trailing slash", + issuerURL: "https://issuer-url/", + in: "https://issuer-url", + want: `issuer.discoveryURL: Invalid value: "https://issuer-url": discoveryURL must be different from URL`, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + got := validateIssuerDiscoveryURL(tt.issuerURL, tt.in, fldPath).ToAggregate() if d := cmp.Diff(tt.want, errString(got)); d != "" { t.Fatalf("URL validation mismatch (-want +got):\n%s", d) } 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 87ee459a0a03c..b0b633e827323 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 @@ -35,6 +35,7 @@ import ( "fmt" "io/ioutil" "net/http" + "net/url" "reflect" "strings" "sync" @@ -66,6 +67,10 @@ var ( synchronizeTokenIDVerifierForTest = false ) +const ( + wellKnownEndpointPath = "/.well-known/openid-configuration" +) + type Options struct { // JWTAuthenticator is the authenticator that will be used to verify the JWT. JWTAuthenticator apiserver.JWTAuthenticator @@ -268,6 +273,28 @@ func New(opts Options) (authenticator.Token, error) { client = &http.Client{Transport: tr, Timeout: 30 * time.Second} } + // If the discovery URL is set in authentication configuration, we set up a + // roundTripper to rewrite the {url}/.well-known/openid-configuration to + // the discovery URL. This is useful for self-hosted providers, for example, + // providers that run on top of Kubernetes itself. + if len(opts.JWTAuthenticator.Issuer.DiscoveryURL) > 0 { + discoveryURL, err := url.Parse(opts.JWTAuthenticator.Issuer.DiscoveryURL) + if err != nil { + return nil, fmt.Errorf("oidc: invalid discovery URL: %w", err) + } + + clientWithDiscoveryURL := *client + baseTransport := clientWithDiscoveryURL.Transport + if baseTransport == nil { + baseTransport = http.DefaultTransport + } + // This matches the url construction in oidc.NewProvider as of go-oidc v2.2.1. + // xref: https://github.com/coreos/go-oidc/blob/40cd342c4a2076195294612a834d11df23c1b25a/oidc.go#L114 + urlToRewrite := strings.TrimSuffix(opts.JWTAuthenticator.Issuer.URL, "/") + wellKnownEndpointPath + clientWithDiscoveryURL.Transport = &discoveryURLRoundTripper{baseTransport, discoveryURL, urlToRewrite} + client = &clientWithDiscoveryURL + } + ctx, cancel := context.WithCancel(context.Background()) ctx = oidc.ClientContext(ctx, client) @@ -339,6 +366,26 @@ func New(opts Options) (authenticator.Token, error) { return newInstrumentedAuthenticator(issuerURL, authenticator), nil } +// discoveryURLRoundTripper is a http.RoundTripper that rewrites the +// {url}/.well-known/openid-configuration to the discovery URL. +type discoveryURLRoundTripper struct { + base http.RoundTripper + // discoveryURL is the URL to use to fetch the openid configuration + discoveryURL *url.URL + // urlToRewrite is the URL to rewrite to the discovery URL + urlToRewrite string +} + +func (t *discoveryURLRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + if req.Method == http.MethodGet && req.URL.String() == t.urlToRewrite { + clone := req.Clone(req.Context()) + clone.Host = "" + clone.URL = t.discoveryURL + return t.base.RoundTrip(clone) + } + return t.base.RoundTrip(req) +} + // untrustedIssuer extracts an untrusted "iss" claim from the given JWT token, // or returns an error if the token can not be parsed. Since the JWT is not // verified, the returned issuer should not be trusted. 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 c8991fda93670..f8bf78ed2c706 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,6 +36,7 @@ import ( "gopkg.in/square/go-jose.v2" + "k8s.io/apimachinery/pkg/util/wait" "k8s.io/apiserver/pkg/apis/apiserver" "k8s.io/apiserver/pkg/authentication/user" "k8s.io/apiserver/pkg/features" @@ -134,18 +135,19 @@ var ( ) type claimsTest struct { - name string - options Options - optsFunc func(*Options) - signingKey *jose.JSONWebKey - pubKeys []*jose.JSONWebKey - claims string - want *user.DefaultInfo - wantSkip bool - wantErr string - wantInitErr string - claimToResponseMap map[string]string - openIDConfig string + name string + options Options + optsFunc func(*Options) + signingKey *jose.JSONWebKey + pubKeys []*jose.JSONWebKey + claims string + want *user.DefaultInfo + wantSkip bool + wantErr string + wantInitErr string + claimToResponseMap map[string]string + openIDConfig string + fetchKeysFromRemote bool } // Replace formats the contents of v into the provided template. @@ -175,7 +177,8 @@ func newClaimServer(t *testing.T, keys jose.JSONWebKeySet, signer jose.Signer, c klog.V(5).Infof("%v: returning: %+v", r.URL, string(keyBytes)) w.Write(keyBytes) - case "/.well-known/openid-configuration": + // /c/d/bar/.well-known/openid-configuration is used to test issuer url and discovery url with a path + case "/.well-known/openid-configuration", "/c/d/bar/.well-known/openid-configuration": w.Header().Set("Content-Type", "application/json") klog.V(5).Infof("%v: returning: %+v", r.URL, *openIDConfig) w.Write([]byte(*openIDConfig)) @@ -262,14 +265,17 @@ func (c *claimsTest) run(t *testing.T) { c.claims = replace(c.claims, &v) c.openIDConfig = replace(c.openIDConfig, &v) c.options.JWTAuthenticator.Issuer.URL = replace(c.options.JWTAuthenticator.Issuer.URL, &v) + c.options.JWTAuthenticator.Issuer.DiscoveryURL = replace(c.options.JWTAuthenticator.Issuer.DiscoveryURL, &v) for claim, response := range c.claimToResponseMap { c.claimToResponseMap[claim] = replace(response, &v) } c.wantErr = replace(c.wantErr, &v) c.wantInitErr = replace(c.wantInitErr, &v) - // Set the verifier to use the public key set instead of reading from a remote. - c.options.KeySet = &staticKeySet{keys: c.pubKeys} + if !c.fetchKeysFromRemote { + // Set the verifier to use the public key set instead of reading from a remote. + c.options.KeySet = &staticKeySet{keys: c.pubKeys} + } if c.optsFunc != nil { c.optsFunc(&c.options) @@ -307,7 +313,27 @@ func (c *claimsTest) run(t *testing.T) { t.Fatalf("serialize token: %v", err) } - got, ok, err := a.AuthenticateToken(testContext(t), token) + ia, ok := a.(*instrumentedAuthenticator) + if !ok { + t.Fatalf("expected authenticator to be instrumented") + } + authenticator, ok := ia.delegate.(*Authenticator) + if !ok { + t.Fatalf("expected delegate to be Authenticator") + } + ctx := testContext(t) + // wait for the authenticator to be initialized + err = wait.PollUntilContextCancel(ctx, time.Millisecond, true, func(context.Context) (bool, error) { + if v, _ := authenticator.idTokenVerifier(); v == nil { + return false, nil + } + return true, nil + }) + if err != nil { + t.Fatalf("failed to initialize the authenticator: %v", err) + } + + got, ok, err := a.AuthenticateToken(ctx, token) expectErr := len(c.wantErr) > 0 @@ -2986,6 +3012,191 @@ func TestToken(t *testing.T) { Name: "jane", }, }, + { + name: "discovery-url", + options: Options{ + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com", + DiscoveryURL: "{{.URL}}/.well-known/openid-configuration", + 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{ + loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256), + }, + claims: fmt.Sprintf(`{ + "iss": "https://auth.example.com", + "aud": "my-client", + "username": "jane", + "exp": %d + }`, valid.Unix()), + openIDConfig: `{ + "issuer": "https://auth.example.com", + "jwks_uri": "{{.URL}}/.testing/keys" + }`, + fetchKeysFromRemote: true, + want: &user.DefaultInfo{ + Name: "jane", + }, + }, + { + name: "discovery url, issuer has a path", + options: Options{ + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com/a/b/foo", + DiscoveryURL: "{{.URL}}/.well-known/openid-configuration", + 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{ + loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256), + }, + claims: fmt.Sprintf(`{ + "iss": "https://auth.example.com/a/b/foo", + "aud": "my-client", + "username": "jane", + "exp": %d + }`, valid.Unix()), + openIDConfig: `{ + "issuer": "https://auth.example.com/a/b/foo", + "jwks_uri": "{{.URL}}/.testing/keys" + }`, + fetchKeysFromRemote: true, + want: &user.DefaultInfo{ + Name: "jane", + }, + }, + { + name: "discovery url has a path, issuer url has no path", + options: Options{ + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com", + DiscoveryURL: "{{.URL}}/c/d/bar/.well-known/openid-configuration", + 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{ + loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256), + }, + claims: fmt.Sprintf(`{ + "iss": "https://auth.example.com", + "aud": "my-client", + "username": "jane", + "exp": %d + }`, valid.Unix()), + openIDConfig: `{ + "issuer": "https://auth.example.com", + "jwks_uri": "{{.URL}}/.testing/keys" + }`, + fetchKeysFromRemote: true, + want: &user.DefaultInfo{ + Name: "jane", + }, + }, + { + name: "discovery url and issuer url have paths", + options: Options{ + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com/a/b/foo", + DiscoveryURL: "{{.URL}}/c/d/bar/.well-known/openid-configuration", + 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{ + loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256), + }, + claims: fmt.Sprintf(`{ + "iss": "https://auth.example.com/a/b/foo", + "aud": "my-client", + "username": "jane", + "exp": %d + }`, valid.Unix()), + openIDConfig: `{ + "issuer": "https://auth.example.com/a/b/foo", + "jwks_uri": "{{.URL}}/.testing/keys" + }`, + fetchKeysFromRemote: true, + want: &user.DefaultInfo{ + Name: "jane", + }, + }, + { + name: "discovery url and issuer url have paths, issuer url has trailing slash", + options: Options{ + JWTAuthenticator: apiserver.JWTAuthenticator{ + Issuer: apiserver.Issuer{ + URL: "https://auth.example.com/a/b/foo/", + DiscoveryURL: "{{.URL}}/c/d/bar/.well-known/openid-configuration", + 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{ + loadRSAKey(t, "testdata/rsa_1.pem", jose.RS256), + }, + claims: fmt.Sprintf(`{ + "iss": "https://auth.example.com/a/b/foo/", + "aud": "my-client", + "username": "jane", + "exp": %d + }`, valid.Unix()), + openIDConfig: `{ + "issuer": "https://auth.example.com/a/b/foo/", + "jwks_uri": "{{.URL}}/.testing/keys" + }`, + fetchKeysFromRemote: true, + want: &user.DefaultInfo{ + Name: "jane", + }, + }, } var successTestCount, failureTestCount int diff --git a/test/integration/apiserver/oidc/oidc_test.go b/test/integration/apiserver/oidc/oidc_test.go index 22dd5a239c253..ffd7375600236 100644 --- a/test/integration/apiserver/oidc/oidc_test.go +++ b/test/integration/apiserver/oidc/oidc_test.go @@ -141,7 +141,7 @@ func runTests(t *testing.T, useAuthenticationConfig bool) { ) { caCertContent, _, caFilePath, caKeyFilePath := generateCert(t) signingPrivateKey, publicKey := keyFunc(t) - oidcServer = utilsoidc.BuildAndRunTestServer(t, caFilePath, caKeyFilePath) + oidcServer = utilsoidc.BuildAndRunTestServer(t, caFilePath, caKeyFilePath, "") if useAuthenticationConfig { authenticationConfig := fmt.Sprintf(` @@ -274,7 +274,7 @@ jwt: signingPrivateKey, _ = keyFunc(t) - oidcServer = utilsoidc.BuildAndRunTestServer(t, caFilePath, caKeyFilePath) + oidcServer = utilsoidc.BuildAndRunTestServer(t, caFilePath, caKeyFilePath, "") if useAuthenticationConfig { authenticationConfig := fmt.Sprintf(` @@ -888,6 +888,104 @@ jwt: } } +// TestStructuredAuthenticationDiscoveryURL tests that the discovery URL configured in jwt.issuer.discoveryURL is used to +// fetch the discovery document and the issuer in jwt.issuer.url is used to validate the ID token. +func TestStructuredAuthenticationDiscoveryURL(t *testing.T) { + defer featuregatetesting.SetFeatureGateDuringTest(t, utilfeature.DefaultFeatureGate, features.StructuredAuthenticationConfiguration, true)() + + tests := []struct { + name string + issuerURL string + discoveryURL func(baseURL string) string + }{ + { + name: "discovery url and issuer url with no path", + issuerURL: "https://example.com", + discoveryURL: func(baseURL string) string { return baseURL }, + }, + { + name: "discovery url has path, issuer url has no path", + issuerURL: "https://example.com", + discoveryURL: func(baseURL string) string { return fmt.Sprintf("%s/c/d/bar", baseURL) }, + }, + { + name: "discovery url has no path, issuer url has path", + issuerURL: "https://example.com/a/b/foo", + discoveryURL: func(baseURL string) string { return baseURL }, + }, + { + name: "discovery url and issuer url have paths", + issuerURL: "https://example.com/a/b/foo", + discoveryURL: func(baseURL string) string { + return fmt.Sprintf("%s/c/d/bar", baseURL) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + caCertContent, _, caFilePath, caKeyFilePath := generateCert(t) + signingPrivateKey, publicKey := rsaGenerateKey(t) + // set the issuer in the discovery document to issuer url (different from the discovery URL) to assert + // 1. discovery URL is used to fetch the discovery document and + // 2. issuer in the discovery document is used to validate the ID token + oidcServer := utilsoidc.BuildAndRunTestServer(t, caFilePath, caKeyFilePath, tt.issuerURL) + discoveryURL := strings.TrimSuffix(tt.discoveryURL(oidcServer.URL()), "/") + "/.well-known/openid-configuration" + + authenticationConfig := fmt.Sprintf(` +apiVersion: apiserver.config.k8s.io/v1alpha1 +kind: AuthenticationConfiguration +jwt: +- issuer: + url: %s + discoveryURL: %s + audiences: + - foo + audienceMatchPolicy: MatchAny + certificateAuthority: | + %s + claimMappings: + username: + expression: "'k8s-' + claims.sub" + claimValidationRules: + - expression: 'claims.hd == "example.com"' + message: "the hd claim must be set to example.com" +`, tt.issuerURL, discoveryURL, indentCertificateAuthority(string(caCertContent))) + + oidcServer.JwksHandler().EXPECT().KeySet().AnyTimes().DoAndReturn(utilsoidc.DefaultJwksHandlerBehavior(t, publicKey)) + + apiServer := startTestAPIServerForOIDC(t, apiServerOIDCConfig{authenticationConfigYAML: authenticationConfig}, publicKey) + + idTokenLifetime := time.Second * 1200 + oidcServer.TokenHandler().EXPECT().Token().Times(1).DoAndReturn(utilsoidc.TokenHandlerBehaviorReturningPredefinedJWT( + t, + signingPrivateKey, + map[string]interface{}{ + "iss": tt.issuerURL, // issuer in the discovery document is used to validate the ID token + "sub": defaultOIDCClaimedUsername, + "aud": "foo", + "exp": time.Now().Add(idTokenLifetime).Unix(), + "hd": "example.com", + }, + defaultStubAccessToken, + defaultStubRefreshToken, + )) + + tokenURL, err := oidcServer.TokenURL() + require.NoError(t, err) + + client := configureClientFetchingOIDCCredentials(t, apiServer.ClientConfig, caCertContent, caFilePath, oidcServer.URL(), tokenURL) + ctx := testContext(t) + res, err := client.AuthenticationV1().SelfSubjectReviews().Create(ctx, &authenticationv1.SelfSubjectReview{}, metav1.CreateOptions{}) + require.NoError(t, err) + assert.Equal(t, authenticationv1.UserInfo{ + Username: "k8s-john_doe", + Groups: []string{"system:authenticated"}, + }, res.Status.UserInfo) + }) + } +} + func rsaGenerateKey(t *testing.T) (*rsa.PrivateKey, *rsa.PublicKey) { t.Helper() @@ -919,7 +1017,7 @@ func configureTestInfrastructure[K utilsoidc.JosePrivateKey, L utilsoidc.JosePub signingPrivateKey, publicKey := keyFunc(t) - oidcServer = utilsoidc.BuildAndRunTestServer(t, caFilePath, caKeyFilePath) + oidcServer = utilsoidc.BuildAndRunTestServer(t, caFilePath, caKeyFilePath, "") authenticationConfig := fn(t, oidcServer.URL(), string(caCertContent)) if len(authenticationConfig) > 0 { diff --git a/test/utils/oidc/testserver.go b/test/utils/oidc/testserver.go index 6d425b426dbb8..1ffdbc9323d07 100644 --- a/test/utils/oidc/testserver.go +++ b/test/utils/oidc/testserver.go @@ -80,7 +80,7 @@ func (ts *TestServer) TokenURL() (string, error) { } // BuildAndRunTestServer configures OIDC TLS server and its routing -func BuildAndRunTestServer(t *testing.T, caPath, caKeyPath string) *TestServer { +func BuildAndRunTestServer(t *testing.T, caPath, caKeyPath, issuerOverride string) *TestServer { t.Helper() certContent, err := os.ReadFile(caPath) @@ -111,33 +111,21 @@ func BuildAndRunTestServer(t *testing.T, caPath, caKeyPath string) *TestServer { jwksHandler: NewMockJWKsHandler(mockCtrl), } - mux.HandleFunc(openIDWellKnownWebPath, func(writer http.ResponseWriter, request *http.Request) { - authURL, err := url.JoinPath(httpServer.URL + authWebPath) - require.NoError(t, err) - tokenURL, err := url.JoinPath(httpServer.URL + tokenWebPath) - require.NoError(t, err) - jwksURL, err := url.JoinPath(httpServer.URL + jwksWebPath) - require.NoError(t, err) - userInfoURL, err := url.JoinPath(httpServer.URL + authWebPath) - require.NoError(t, err) + issuer := httpServer.URL + // issuerOverride is used to override the issuer URL in the well-known configuration. + // This is useful to validate scenarios where discovery url is different from the issuer url. + if len(issuerOverride) > 0 { + issuer = issuerOverride + } - err = json.NewEncoder(writer).Encode(struct { - Issuer string `json:"issuer"` - AuthURL string `json:"authorization_endpoint"` - TokenURL string `json:"token_endpoint"` - JWKSURL string `json:"jwks_uri"` - UserInfoURL string `json:"userinfo_endpoint"` - }{ - Issuer: httpServer.URL, - AuthURL: authURL, - TokenURL: tokenURL, - JWKSURL: jwksURL, - UserInfoURL: userInfoURL, - }) - require.NoError(t, err) + mux.HandleFunc(openIDWellKnownWebPath, func(writer http.ResponseWriter, request *http.Request) { + discoveryDocHandler(t, writer, httpServer.URL, issuer) + }) - writer.Header().Add("Content-Type", "application/json") - writer.WriteHeader(http.StatusOK) + // /c/d/bar/.well-known/openid-configuration is used to validate scenarios where discovery url is different from the issuer url + // and discovery url contains path. + mux.HandleFunc("/c/d/bar"+openIDWellKnownWebPath, func(writer http.ResponseWriter, request *http.Request) { + discoveryDocHandler(t, writer, httpServer.URL, issuer) }) mux.HandleFunc(tokenWebPath, func(writer http.ResponseWriter, request *http.Request) { @@ -171,6 +159,34 @@ func BuildAndRunTestServer(t *testing.T, caPath, caKeyPath string) *TestServer { return oidcServer } +func discoveryDocHandler(t *testing.T, writer http.ResponseWriter, httpServerURL, issuer string) { + authURL, err := url.JoinPath(httpServerURL + authWebPath) + require.NoError(t, err) + tokenURL, err := url.JoinPath(httpServerURL + tokenWebPath) + require.NoError(t, err) + jwksURL, err := url.JoinPath(httpServerURL + jwksWebPath) + require.NoError(t, err) + userInfoURL, err := url.JoinPath(httpServerURL + authWebPath) + require.NoError(t, err) + + writer.Header().Add("Content-Type", "application/json") + + err = json.NewEncoder(writer).Encode(struct { + Issuer string `json:"issuer"` + AuthURL string `json:"authorization_endpoint"` + TokenURL string `json:"token_endpoint"` + JWKSURL string `json:"jwks_uri"` + UserInfoURL string `json:"userinfo_endpoint"` + }{ + Issuer: issuer, + AuthURL: authURL, + TokenURL: tokenURL, + JWKSURL: jwksURL, + UserInfoURL: userInfoURL, + }) + require.NoError(t, err) +} + type JosePrivateKey interface { *rsa.PrivateKey | *ecdsa.PrivateKey }