wire up discovery url in authenticator

Signed-off-by: Anish Ramasekar <anish.ramasekar@gmail.com>
This commit is contained in:
Anish Ramasekar 2024-02-26 16:17:58 -08:00
parent 84852ff56f
commit 78fb0bae22
No known key found for this signature in database
GPG Key ID: E96F745A34A409C2
4 changed files with 417 additions and 45 deletions

View File

@ -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.

View File

@ -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

View File

@ -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 {

View File

@ -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),
}
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
}
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)
discoveryDocHandler(t, writer, httpServer.URL, issuer)
})
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)
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
}