diff --git a/plugin/pkg/authenticator/token/oidc/oidc.go b/plugin/pkg/authenticator/token/oidc/oidc.go index 87ee459a0..b0b633e82 100644 --- a/plugin/pkg/authenticator/token/oidc/oidc.go +++ b/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/plugin/pkg/authenticator/token/oidc/oidc_test.go b/plugin/pkg/authenticator/token/oidc/oidc_test.go index c8991fda9..f8bf78ed2 100644 --- a/plugin/pkg/authenticator/token/oidc/oidc_test.go +++ b/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