JWTAuthenticator distributed claims resolution honors tls config

Kube 1.23 introduced a new field on the OIDC Authenticator which
allows us to pass in a client with our own TLS config. See
https://github.com/kubernetes/kubernetes/pull/106141.

Signed-off-by: Margo Crawford <margaretc@vmware.com>
This commit is contained in:
Margo Crawford 2022-04-18 11:46:33 -07:00
parent c40bca5e65
commit 0b72f7084c
2 changed files with 112 additions and 30 deletions

View File

@ -1,4 +1,4 @@
// Copyright 2020-2021 the Pinniped contributors. All Rights Reserved. // Copyright 2020-2022 the Pinniped contributors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
// Package jwtcachefiller implements a controller for filling an authncache.Cache with each // Package jwtcachefiller implements a controller for filling an authncache.Cache with each
@ -17,7 +17,6 @@ import (
"gopkg.in/square/go-jose.v2" "gopkg.in/square/go-jose.v2"
"k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apiserver/pkg/authentication/authenticator" "k8s.io/apiserver/pkg/authentication/authenticator"
"k8s.io/apiserver/pkg/server/dynamiccertificates"
"k8s.io/apiserver/plugin/pkg/authenticator/token/oidc" "k8s.io/apiserver/plugin/pkg/authenticator/token/oidc"
"k8s.io/klog/v2" "k8s.io/klog/v2"
@ -150,19 +149,11 @@ func (c *controller) extractValueAsJWTAuthenticator(value authncache.Value) *jwt
// newJWTAuthenticator creates a jwt authenticator from the provided spec. // newJWTAuthenticator creates a jwt authenticator from the provided spec.
func newJWTAuthenticator(spec *auth1alpha1.JWTAuthenticatorSpec) (*jwtAuthenticator, error) { func newJWTAuthenticator(spec *auth1alpha1.JWTAuthenticatorSpec) (*jwtAuthenticator, error) {
rootCAs, caBundle, err := pinnipedauthenticator.CABundle(spec.TLS) rootCAs, _, err := pinnipedauthenticator.CABundle(spec.TLS)
if err != nil { if err != nil {
return nil, fmt.Errorf("invalid TLS configuration: %w", err) return nil, fmt.Errorf("invalid TLS configuration: %w", err)
} }
var caContentProvider oidc.CAContentProvider
if len(caBundle) != 0 {
var caContentProviderErr error
caContentProvider, caContentProviderErr = dynamiccertificates.NewStaticCAContent("ignored", caBundle)
if caContentProviderErr != nil {
return nil, caContentProviderErr // impossible since caBundle is validated already
}
}
usernameClaim := spec.Claims.Username usernameClaim := spec.Claims.Username
if usernameClaim == "" { if usernameClaim == "" {
usernameClaim = defaultUsernameClaim usernameClaim = defaultUsernameClaim
@ -199,7 +190,6 @@ func newJWTAuthenticator(spec *auth1alpha1.JWTAuthenticatorSpec) (*jwtAuthentica
if len(providerJSON.JWKSURL) == 0 { if len(providerJSON.JWKSURL) == 0 {
return nil, fmt.Errorf("issuer %q does not have jwks_uri set", spec.Issuer) return nil, fmt.Errorf("issuer %q does not have jwks_uri set", spec.Issuer)
} }
oidcAuthenticator, err := oidc.New(oidc.Options{ oidcAuthenticator, err := oidc.New(oidc.Options{
IssuerURL: spec.Issuer, IssuerURL: spec.Issuer,
KeySet: coreosoidc.NewRemoteKeySet(ctx, providerJSON.JWKSURL), KeySet: coreosoidc.NewRemoteKeySet(ctx, providerJSON.JWKSURL),
@ -207,9 +197,7 @@ func newJWTAuthenticator(spec *auth1alpha1.JWTAuthenticatorSpec) (*jwtAuthentica
UsernameClaim: usernameClaim, UsernameClaim: usernameClaim,
GroupsClaim: groupsClaim, GroupsClaim: groupsClaim,
SupportedSigningAlgs: defaultSupportedSigningAlgos(), SupportedSigningAlgs: defaultSupportedSigningAlgos(),
// this is still needed for distributed claim resolution, meaning this uses a http client that does not honor our TLS config Client: client,
// TODO fix when we pick up https://github.com/kubernetes/kubernetes/pull/106141
CAContentProvider: caContentProvider,
}) })
if err != nil { if err != nil {
return nil, fmt.Errorf("could not initialize authenticator: %w", err) return nil, fmt.Errorf("could not initialize authenticator: %w", err)

View File

@ -58,6 +58,9 @@ func TestController(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
goodRSASigningAlgo := jose.RS256 goodRSASigningAlgo := jose.RS256
customGroupsClaim := "my-custom-groups-claim"
distributedGroups := []string{"some-distributed-group-1", "some-distributed-group-2"}
mux := http.NewServeMux() mux := http.NewServeMux()
server := tlsserver.TLSTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { server := tlsserver.TLSTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tlsserver.AssertTLS(t, r, ptls.Default) tlsserver.AssertTLS(t, r, ptls.Default)
@ -87,6 +90,59 @@ func TestController(t *testing.T) {
} }
require.NoError(t, json.NewEncoder(w).Encode(jwks)) require.NoError(t, json.NewEncoder(w).Encode(jwks))
})) }))
// Claims without the subject, to be used distributed claims tests.
// OIDC 1.0 section 5.6.2:
// A sub (subject) Claim SHOULD NOT be returned from the Claims Provider unless its value
// is an identifier for the End-User at the Claims Provider (and not for the OpenID Provider or another party);
// this typically means that a sub Claim SHOULD NOT be provided.
claimsWithoutSubject := jwt.Claims{
Issuer: server.URL,
Audience: []string{goodAudience},
Expiry: jwt.NewNumericDate(time.Now().Add(time.Hour)),
NotBefore: jwt.NewNumericDate(time.Now().Add(-time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now().Add(-time.Hour)),
}
mux.Handle("/claim_source", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Unfortunately we have to set this up pretty early in the test because we can't redeclare
// mux.Handle. This means that we can't return a different groups claim per test; we have to
// return both and predecide which groups are returned.
sig, err := jose.NewSigner(
jose.SigningKey{Algorithm: goodECSigningAlgo, Key: goodECSigningKey},
(&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", goodECSigningKeyID),
)
require.NoError(t, err)
builder := jwt.Signed(sig).Claims(claimsWithoutSubject)
builder = builder.Claims(map[string]interface{}{customGroupsClaim: distributedGroups})
builder = builder.Claims(map[string]interface{}{"groups": distributedGroups})
distributedClaimsJwt, err := builder.CompactSerialize()
require.NoError(t, err)
_, err = w.Write([]byte(distributedClaimsJwt))
require.NoError(t, err)
}))
mux.Handle("/wrong_claim_source", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Unfortunately we have to set this up pretty early in the test because we can't redeclare
// mux.Handle. This means that we can't return a different groups claim per test; we have to
// return both and predecide which groups are returned.
sig, err := jose.NewSigner(
jose.SigningKey{Algorithm: goodECSigningAlgo, Key: goodECSigningKey},
(&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", goodECSigningKeyID),
)
require.NoError(t, err)
builder := jwt.Signed(sig).Claims(claimsWithoutSubject)
builder = builder.Claims(map[string]interface{}{"some-other-claim": distributedGroups})
distributedClaimsJwt, err := builder.CompactSerialize()
require.NoError(t, err)
_, err = w.Write([]byte(distributedClaimsJwt))
require.NoError(t, err)
}))
goodIssuer := server.URL goodIssuer := server.URL
@ -108,7 +164,7 @@ func TestController(t *testing.T) {
Audience: goodAudience, Audience: goodAudience,
TLS: tlsSpecFromTLSConfig(server.TLS), TLS: tlsSpecFromTLSConfig(server.TLS),
Claims: auth1alpha1.JWTTokenClaims{ Claims: auth1alpha1.JWTTokenClaims{
Groups: "my-custom-groups-claim", Groups: customGroupsClaim,
}, },
} }
otherJWTAuthenticatorSpec := &auth1alpha1.JWTAuthenticatorSpec{ otherJWTAuthenticatorSpec := &auth1alpha1.JWTAuthenticatorSpec{
@ -384,6 +440,7 @@ func TestController(t *testing.T) {
goodUsername, goodUsername,
tt.wantUsernameClaim, tt.wantUsernameClaim,
tt.wantGroupsClaim, tt.wantGroupsClaim,
goodIssuer,
) { ) {
test := test test := test
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
@ -418,6 +475,7 @@ func TestController(t *testing.T) {
&wellKnownClaims, &wellKnownClaims,
tt.wantGroupsClaim, tt.wantGroupsClaim,
groups, groups,
test.distributedGroupsClaimURL,
tt.wantUsernameClaim, tt.wantUsernameClaim,
username, username,
) )
@ -447,9 +505,10 @@ func TestController(t *testing.T) {
} }
// isNotInitialized checks if the error is the internally-defined "oidc: authenticator not initialized" error from // isNotInitialized checks if the error is the internally-defined "oidc: authenticator not initialized" error from
// the underlying OIDC authenticator, which is initialized asynchronously. // the underlying OIDC authenticator or "verifier is not initialized" from verifying distributed claims,
// both of which are initialized asynchronously.
func isNotInitialized(err error) bool { func isNotInitialized(err error) bool {
return err != nil && strings.Contains(err.Error(), "authenticator not initialized") return err != nil && (strings.Contains(err.Error(), "authenticator not initialized") || strings.Contains(err.Error(), "verifier not initialized"))
} }
func testTableForAuthenticateTokenTests( func testTableForAuthenticateTokenTests(
@ -462,21 +521,24 @@ func testTableForAuthenticateTokenTests(
goodUsername string, goodUsername string,
expectedUsernameClaim string, expectedUsernameClaim string,
expectedGroupsClaim string, expectedGroupsClaim string,
issuer string,
) []struct { ) []struct {
name string name string
jwtClaims func(wellKnownClaims *jwt.Claims, groups *interface{}, username *string) jwtClaims func(wellKnownClaims *jwt.Claims, groups *interface{}, username *string)
jwtSignature func(key *interface{}, algo *jose.SignatureAlgorithm, kid *string) jwtSignature func(key *interface{}, algo *jose.SignatureAlgorithm, kid *string)
wantResponse *authenticator.Response wantResponse *authenticator.Response
wantAuthenticated bool wantAuthenticated bool
wantErrorRegexp string wantErrorRegexp string
distributedGroupsClaimURL string
} { } {
tests := []struct { tests := []struct {
name string name string
jwtClaims func(wellKnownClaims *jwt.Claims, groups *interface{}, username *string) jwtClaims func(wellKnownClaims *jwt.Claims, groups *interface{}, username *string)
jwtSignature func(key *interface{}, algo *jose.SignatureAlgorithm, kid *string) jwtSignature func(key *interface{}, algo *jose.SignatureAlgorithm, kid *string)
wantResponse *authenticator.Response wantResponse *authenticator.Response
wantAuthenticated bool wantAuthenticated bool
wantErrorRegexp string wantErrorRegexp string
distributedGroupsClaimURL string
}{ }{
{ {
name: "good token without groups and with EC signature", name: "good token without groups and with EC signature",
@ -514,6 +576,33 @@ func testTableForAuthenticateTokenTests(
}, },
wantAuthenticated: true, wantAuthenticated: true,
}, },
{
name: "good token with good distributed groups",
jwtClaims: func(claims *jwt.Claims, groups *interface{}, username *string) {
},
distributedGroupsClaimURL: issuer + "/claim_source",
wantResponse: &authenticator.Response{
User: &user.DefaultInfo{
Name: goodUsername,
Groups: []string{"some-distributed-group-1", "some-distributed-group-2"},
},
},
wantAuthenticated: true,
},
{
name: "distributed groups returns a 404",
jwtClaims: func(claims *jwt.Claims, groups *interface{}, username *string) {
},
distributedGroupsClaimURL: issuer + "/not_found_claim_source",
wantErrorRegexp: `oidc: could not expand distributed claims: while getting distributed claim "` + expectedGroupsClaim + `": error while getting distributed claim JWT: 404 Not Found`,
},
{
name: "distributed groups doesn't return the right claim",
jwtClaims: func(claims *jwt.Claims, groups *interface{}, username *string) {
},
distributedGroupsClaimURL: issuer + "/wrong_claim_source",
wantErrorRegexp: `oidc: could not expand distributed claims: jwt returned by distributed claim endpoint "` + issuer + `/wrong_claim_source" did not contain claim: `,
},
{ {
name: "good token with groups as string", name: "good token with groups as string",
jwtClaims: func(_ *jwt.Claims, groups *interface{}, username *string) { jwtClaims: func(_ *jwt.Claims, groups *interface{}, username *string) {
@ -644,6 +733,7 @@ func createJWT(
claims *jwt.Claims, claims *jwt.Claims,
groupsClaim string, groupsClaim string,
groupsValue interface{}, groupsValue interface{},
distributedGroupsClaimURL string,
usernameClaim string, usernameClaim string,
usernameValue string, usernameValue string,
) string { ) string {
@ -659,6 +749,10 @@ func createJWT(
if groupsValue != nil { if groupsValue != nil {
builder = builder.Claims(map[string]interface{}{groupsClaim: groupsValue}) builder = builder.Claims(map[string]interface{}{groupsClaim: groupsValue})
} }
if distributedGroupsClaimURL != "" {
builder = builder.Claims(map[string]interface{}{"_claim_names": map[string]string{groupsClaim: "src1"}})
builder = builder.Claims(map[string]interface{}{"_claim_sources": map[string]interface{}{"src1": map[string]string{"endpoint": distributedGroupsClaimURL}}})
}
if usernameValue != "" { if usernameValue != "" {
builder = builder.Claims(map[string]interface{}{usernameClaim: usernameValue}) builder = builder.Claims(map[string]interface{}{usernameClaim: usernameValue})
} }