c6e4133c5e
Used this as an opportunity to refactor how some tests were making assertions about error strings. New test helpers make it easy for an error string to be expected as an exact string, as a string built using sprintf, as a regexp, or as a string built to include the platform-specific x509 error string. All of these helpers can be used in a single `wantErr` field of a test table. They can be used for both unit tests and integration tests. Co-authored-by: Benjamin A. Petersen <ben@benjaminapetersen.me>
780 lines
28 KiB
Go
780 lines
28 KiB
Go
// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved.
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package jwtcachefiller
|
|
|
|
import (
|
|
"context"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/tls"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/golang/mock/gomock"
|
|
"github.com/stretchr/testify/require"
|
|
"gopkg.in/square/go-jose.v2"
|
|
"gopkg.in/square/go-jose.v2/jwt"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/runtime"
|
|
"k8s.io/apimachinery/pkg/util/wait"
|
|
"k8s.io/apiserver/pkg/authentication/authenticator"
|
|
"k8s.io/apiserver/pkg/authentication/user"
|
|
|
|
auth1alpha1 "go.pinniped.dev/generated/latest/apis/concierge/authentication/v1alpha1"
|
|
pinnipedfake "go.pinniped.dev/generated/latest/client/concierge/clientset/versioned/fake"
|
|
pinnipedinformers "go.pinniped.dev/generated/latest/client/concierge/informers/externalversions"
|
|
"go.pinniped.dev/internal/controller/authenticator/authncache"
|
|
"go.pinniped.dev/internal/controllerlib"
|
|
"go.pinniped.dev/internal/crypto/ptls"
|
|
"go.pinniped.dev/internal/mocks/mocktokenauthenticatorcloser"
|
|
"go.pinniped.dev/internal/testutil"
|
|
"go.pinniped.dev/internal/testutil/testlogger"
|
|
"go.pinniped.dev/internal/testutil/tlsserver"
|
|
)
|
|
|
|
func TestController(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const (
|
|
goodECSigningKeyID = "some-ec-key-id"
|
|
goodRSASigningKeyID = "some-rsa-key-id"
|
|
goodAudience = "some-audience"
|
|
)
|
|
|
|
goodECSigningKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
goodECSigningAlgo := jose.ES256
|
|
require.NoError(t, err)
|
|
|
|
goodRSASigningKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
|
require.NoError(t, err)
|
|
goodRSASigningAlgo := jose.RS256
|
|
|
|
customGroupsClaim := "my-custom-groups-claim"
|
|
distributedGroups := []string{"some-distributed-group-1", "some-distributed-group-2"}
|
|
|
|
mux := http.NewServeMux()
|
|
server := tlsserver.TLSTestServer(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
tlsserver.AssertTLS(t, r, ptls.Default)
|
|
mux.ServeHTTP(w, r)
|
|
}), tlsserver.RecordTLSHello)
|
|
|
|
mux.Handle("/.well-known/openid-configuration", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, err := fmt.Fprintf(w, `{"issuer": "%s", "jwks_uri": "%s"}`, server.URL, server.URL+"/jwks.json")
|
|
require.NoError(t, err)
|
|
}))
|
|
mux.Handle("/jwks.json", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
ecJWK := jose.JSONWebKey{
|
|
Key: goodECSigningKey,
|
|
KeyID: goodECSigningKeyID,
|
|
Algorithm: string(goodECSigningAlgo),
|
|
Use: "sig",
|
|
}
|
|
rsaJWK := jose.JSONWebKey{
|
|
Key: goodRSASigningKey,
|
|
KeyID: goodRSASigningKeyID,
|
|
Algorithm: string(goodRSASigningAlgo),
|
|
Use: "sig",
|
|
}
|
|
jwks := jose.JSONWebKeySet{
|
|
Keys: []jose.JSONWebKey{ecJWK.Public(), rsaJWK.Public()},
|
|
}
|
|
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
|
|
|
|
someJWTAuthenticatorSpec := &auth1alpha1.JWTAuthenticatorSpec{
|
|
Issuer: goodIssuer,
|
|
Audience: goodAudience,
|
|
TLS: tlsSpecFromTLSConfig(server.TLS),
|
|
}
|
|
someJWTAuthenticatorSpecWithUsernameClaim := &auth1alpha1.JWTAuthenticatorSpec{
|
|
Issuer: goodIssuer,
|
|
Audience: goodAudience,
|
|
TLS: tlsSpecFromTLSConfig(server.TLS),
|
|
Claims: auth1alpha1.JWTTokenClaims{
|
|
Username: "my-custom-username-claim",
|
|
},
|
|
}
|
|
someJWTAuthenticatorSpecWithGroupsClaim := &auth1alpha1.JWTAuthenticatorSpec{
|
|
Issuer: goodIssuer,
|
|
Audience: goodAudience,
|
|
TLS: tlsSpecFromTLSConfig(server.TLS),
|
|
Claims: auth1alpha1.JWTTokenClaims{
|
|
Groups: customGroupsClaim,
|
|
},
|
|
}
|
|
otherJWTAuthenticatorSpec := &auth1alpha1.JWTAuthenticatorSpec{
|
|
Issuer: "https://some-other-issuer.com",
|
|
Audience: goodAudience,
|
|
TLS: &auth1alpha1.TLSSpec{CertificateAuthorityData: "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURVVENDQWptZ0F3SUJBZ0lWQUpzNStTbVRtaTJXeUI0bGJJRXBXaUs5a1RkUE1BMEdDU3FHU0liM0RRRUIKQ3dVQU1COHhDekFKQmdOVkJBWVRBbFZUTVJBd0RnWURWUVFLREFkUWFYWnZkR0ZzTUI0WERUSXdNRFV3TkRFMgpNamMxT0ZvWERUSTBNRFV3TlRFMk1qYzFPRm93SHpFTE1Ba0dBMVVFQmhNQ1ZWTXhFREFPQmdOVkJBb01CMUJwCmRtOTBZV3d3Z2dFaU1BMEdDU3FHU0liM0RRRUJBUVVBQTRJQkR3QXdnZ0VLQW9JQkFRRERZWmZvWGR4Z2NXTEMKZEJtbHB5a0tBaG9JMlBuUWtsVFNXMno1cGcwaXJjOGFRL1E3MXZzMTRZYStmdWtFTGlvOTRZYWw4R01DdVFrbApMZ3AvUEE5N1VYelhQNDBpK25iNXcwRGpwWWd2dU9KQXJXMno2MFRnWE5NSFh3VHk4ME1SZEhpUFVWZ0VZd0JpCmtkNThzdEFVS1Y1MnBQTU1reTJjNy9BcFhJNmRXR2xjalUvaFBsNmtpRzZ5dEw2REtGYjJQRWV3MmdJM3pHZ2IKOFVVbnA1V05DZDd2WjNVY0ZHNXlsZEd3aGc3cnZ4U1ZLWi9WOEhCMGJmbjlxamlrSVcxWFM4dzdpUUNlQmdQMApYZWhKZmVITlZJaTJtZlczNlVQbWpMdnVKaGpqNDIrdFBQWndvdDkzdWtlcEgvbWpHcFJEVm9wamJyWGlpTUYrCkYxdnlPNGMxQWdNQkFBR2pnWU13Z1lBd0hRWURWUjBPQkJZRUZNTWJpSXFhdVkwajRVWWphWDl0bDJzby9LQ1IKTUI4R0ExVWRJd1FZTUJhQUZNTWJpSXFhdVkwajRVWWphWDl0bDJzby9LQ1JNQjBHQTFVZEpRUVdNQlFHQ0NzRwpBUVVGQndNQ0JnZ3JCZ0VGQlFjREFUQVBCZ05WSFJNQkFmOEVCVEFEQVFIL01BNEdBMVVkRHdFQi93UUVBd0lCCkJqQU5CZ2txaGtpRzl3MEJBUXNGQUFPQ0FRRUFYbEh4M2tIMDZwY2NDTDlEVE5qTnBCYnlVSytGd2R6T2IwWFYKcmpNaGtxdHVmdEpUUnR5T3hKZ0ZKNXhUR3pCdEtKamcrVU1pczBOV0t0VDBNWThVMU45U2c5SDl0RFpHRHBjVQpxMlVRU0Y4dXRQMVR3dnJIUzIrdzB2MUoxdHgrTEFiU0lmWmJCV0xXQ21EODUzRlVoWlFZekkvYXpFM28vd0p1CmlPUklMdUpNUk5vNlBXY3VLZmRFVkhaS1RTWnk3a25FcHNidGtsN3EwRE91eUFWdG9HVnlkb3VUR0FOdFhXK2YKczNUSTJjKzErZXg3L2RZOEJGQTFzNWFUOG5vZnU3T1RTTzdiS1kzSkRBUHZOeFQzKzVZUXJwNGR1Nmh0YUFMbAppOHNaRkhidmxpd2EzdlhxL3p1Y2JEaHEzQzBhZnAzV2ZwRGxwSlpvLy9QUUFKaTZLQT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K"},
|
|
}
|
|
missingTLSJWTAuthenticatorSpec := &auth1alpha1.JWTAuthenticatorSpec{
|
|
Issuer: goodIssuer,
|
|
Audience: goodAudience,
|
|
}
|
|
invalidTLSJWTAuthenticatorSpec := &auth1alpha1.JWTAuthenticatorSpec{
|
|
Issuer: "https://some-other-issuer.com",
|
|
Audience: goodAudience,
|
|
TLS: &auth1alpha1.TLSSpec{CertificateAuthorityData: "invalid base64-encoded data"},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
cache func(*testing.T, *authncache.Cache, bool)
|
|
syncKey controllerlib.Key
|
|
jwtAuthenticators []runtime.Object
|
|
wantClose bool
|
|
wantErr testutil.RequireErrorStringFunc
|
|
wantLogs []string
|
|
wantCacheEntries int
|
|
wantUsernameClaim string
|
|
wantGroupsClaim string
|
|
runTestsOnResultingAuthenticator bool
|
|
}{
|
|
{
|
|
name: "not found",
|
|
syncKey: controllerlib.Key{Name: "test-name"},
|
|
wantLogs: []string{
|
|
`jwtcachefiller-controller "level"=0 "msg"="Sync() found that the JWTAuthenticator does not exist yet or was deleted"`,
|
|
},
|
|
},
|
|
{
|
|
name: "valid jwt authenticator with CA",
|
|
syncKey: controllerlib.Key{Name: "test-name"},
|
|
jwtAuthenticators: []runtime.Object{
|
|
&auth1alpha1.JWTAuthenticator{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-name",
|
|
},
|
|
Spec: *someJWTAuthenticatorSpec,
|
|
},
|
|
},
|
|
wantLogs: []string{
|
|
`jwtcachefiller-controller "level"=0 "msg"="added new jwt authenticator" "issuer"="` + goodIssuer + `" "jwtAuthenticator"={"name":"test-name"}`,
|
|
},
|
|
wantCacheEntries: 1,
|
|
runTestsOnResultingAuthenticator: true,
|
|
},
|
|
{
|
|
name: "valid jwt authenticator with custom username claim",
|
|
syncKey: controllerlib.Key{Name: "test-name"},
|
|
jwtAuthenticators: []runtime.Object{
|
|
&auth1alpha1.JWTAuthenticator{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-name",
|
|
},
|
|
Spec: *someJWTAuthenticatorSpecWithUsernameClaim,
|
|
},
|
|
},
|
|
wantLogs: []string{
|
|
`jwtcachefiller-controller "level"=0 "msg"="added new jwt authenticator" "issuer"="` + goodIssuer + `" "jwtAuthenticator"={"name":"test-name"}`,
|
|
},
|
|
wantCacheEntries: 1,
|
|
wantUsernameClaim: someJWTAuthenticatorSpecWithUsernameClaim.Claims.Username,
|
|
runTestsOnResultingAuthenticator: true,
|
|
},
|
|
{
|
|
name: "valid jwt authenticator with custom groups claim",
|
|
syncKey: controllerlib.Key{Name: "test-name"},
|
|
jwtAuthenticators: []runtime.Object{
|
|
&auth1alpha1.JWTAuthenticator{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-name",
|
|
},
|
|
Spec: *someJWTAuthenticatorSpecWithGroupsClaim,
|
|
},
|
|
},
|
|
wantLogs: []string{
|
|
`jwtcachefiller-controller "level"=0 "msg"="added new jwt authenticator" "issuer"="` + goodIssuer + `" "jwtAuthenticator"={"name":"test-name"}`,
|
|
},
|
|
wantCacheEntries: 1,
|
|
wantGroupsClaim: someJWTAuthenticatorSpecWithGroupsClaim.Claims.Groups,
|
|
runTestsOnResultingAuthenticator: true,
|
|
},
|
|
{
|
|
name: "updating jwt authenticator with new fields closes previous instance",
|
|
cache: func(t *testing.T, cache *authncache.Cache, wantClose bool) {
|
|
cache.Store(
|
|
authncache.Key{
|
|
Name: "test-name",
|
|
Kind: "JWTAuthenticator",
|
|
APIGroup: auth1alpha1.SchemeGroupVersion.Group,
|
|
},
|
|
newCacheValue(t, *otherJWTAuthenticatorSpec, wantClose),
|
|
)
|
|
},
|
|
wantClose: true,
|
|
syncKey: controllerlib.Key{Name: "test-name"},
|
|
jwtAuthenticators: []runtime.Object{
|
|
&auth1alpha1.JWTAuthenticator{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-name",
|
|
},
|
|
Spec: *someJWTAuthenticatorSpec,
|
|
},
|
|
},
|
|
wantLogs: []string{
|
|
`jwtcachefiller-controller "level"=0 "msg"="added new jwt authenticator" "issuer"="` + goodIssuer + `" "jwtAuthenticator"={"name":"test-name"}`,
|
|
},
|
|
wantCacheEntries: 1,
|
|
runTestsOnResultingAuthenticator: true,
|
|
},
|
|
{
|
|
name: "updating jwt authenticator with the same value does nothing",
|
|
cache: func(t *testing.T, cache *authncache.Cache, wantClose bool) {
|
|
cache.Store(
|
|
authncache.Key{
|
|
Name: "test-name",
|
|
Kind: "JWTAuthenticator",
|
|
APIGroup: auth1alpha1.SchemeGroupVersion.Group,
|
|
},
|
|
newCacheValue(t, *someJWTAuthenticatorSpec, wantClose),
|
|
)
|
|
},
|
|
wantClose: false,
|
|
syncKey: controllerlib.Key{Name: "test-name"},
|
|
jwtAuthenticators: []runtime.Object{
|
|
&auth1alpha1.JWTAuthenticator{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-name",
|
|
},
|
|
Spec: *someJWTAuthenticatorSpec,
|
|
},
|
|
},
|
|
wantLogs: []string{
|
|
`jwtcachefiller-controller "level"=0 "msg"="actual jwt authenticator and desired jwt authenticator are the same" "issuer"="` + goodIssuer + `" "jwtAuthenticator"={"name":"test-name"}`,
|
|
},
|
|
wantCacheEntries: 1,
|
|
runTestsOnResultingAuthenticator: false, // skip the tests because the authenticator left in the cache is the mock version that was added above
|
|
},
|
|
{
|
|
name: "updating jwt authenticator when cache value is wrong type",
|
|
cache: func(t *testing.T, cache *authncache.Cache, wantClose bool) {
|
|
cache.Store(
|
|
authncache.Key{
|
|
Name: "test-name",
|
|
Kind: "JWTAuthenticator",
|
|
APIGroup: auth1alpha1.SchemeGroupVersion.Group,
|
|
},
|
|
struct{ authenticator.Token }{},
|
|
)
|
|
},
|
|
syncKey: controllerlib.Key{Name: "test-name"},
|
|
jwtAuthenticators: []runtime.Object{
|
|
&auth1alpha1.JWTAuthenticator{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-name",
|
|
},
|
|
Spec: *someJWTAuthenticatorSpec,
|
|
},
|
|
},
|
|
wantLogs: []string{
|
|
`jwtcachefiller-controller "level"=0 "msg"="wrong JWT authenticator type in cache" "actualType"="struct { authenticator.Token }"`,
|
|
`jwtcachefiller-controller "level"=0 "msg"="added new jwt authenticator" "issuer"="` + goodIssuer + `" "jwtAuthenticator"={"name":"test-name"}`,
|
|
},
|
|
wantCacheEntries: 1,
|
|
runTestsOnResultingAuthenticator: true,
|
|
},
|
|
{
|
|
name: "valid jwt authenticator without CA",
|
|
syncKey: controllerlib.Key{Name: "test-name"},
|
|
jwtAuthenticators: []runtime.Object{
|
|
&auth1alpha1.JWTAuthenticator{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-name",
|
|
},
|
|
Spec: *missingTLSJWTAuthenticatorSpec,
|
|
},
|
|
},
|
|
wantErr: testutil.WantX509UntrustedCertErrorString(`failed to build jwt authenticator: could not initialize provider: Get "`+goodIssuer+`/.well-known/openid-configuration": %s`, "Acme Co"),
|
|
},
|
|
{
|
|
name: "invalid jwt authenticator CA",
|
|
syncKey: controllerlib.Key{Name: "test-name"},
|
|
jwtAuthenticators: []runtime.Object{
|
|
&auth1alpha1.JWTAuthenticator{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-name",
|
|
},
|
|
Spec: *invalidTLSJWTAuthenticatorSpec,
|
|
},
|
|
},
|
|
wantErr: testutil.WantExactErrorString("failed to build jwt authenticator: invalid TLS configuration: illegal base64 data at input byte 7"),
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
tt := tt
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fakeClient := pinnipedfake.NewSimpleClientset(tt.jwtAuthenticators...)
|
|
informers := pinnipedinformers.NewSharedInformerFactory(fakeClient, 0)
|
|
cache := authncache.New()
|
|
testLog := testlogger.NewLegacy(t) //nolint:staticcheck // old test with lots of log statements
|
|
|
|
if tt.cache != nil {
|
|
tt.cache(t, cache, tt.wantClose)
|
|
}
|
|
|
|
controller := New(cache, informers.Authentication().V1alpha1().JWTAuthenticators(), testLog.Logger)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
informers.Start(ctx.Done())
|
|
controllerlib.TestRunSynchronously(t, controller)
|
|
|
|
syncCtx := controllerlib.Context{Context: ctx, Key: tt.syncKey}
|
|
|
|
if err := controllerlib.TestSync(t, controller, syncCtx); tt.wantErr != nil {
|
|
testutil.RequireErrorStringFromErr(t, err, tt.wantErr)
|
|
} else {
|
|
require.NoError(t, err)
|
|
}
|
|
require.Equal(t, tt.wantLogs, testLog.Lines())
|
|
require.Equal(t, tt.wantCacheEntries, len(cache.Keys()))
|
|
|
|
if !tt.runTestsOnResultingAuthenticator {
|
|
return // end of test unless we wanted to run tests on the resulting authenticator from the cache
|
|
}
|
|
|
|
// We expected the cache to have an entry, so pull that entry from the cache and test it.
|
|
expectedCacheKey := authncache.Key{
|
|
APIGroup: auth1alpha1.GroupName,
|
|
Kind: "JWTAuthenticator",
|
|
Name: syncCtx.Key.Name,
|
|
}
|
|
cachedAuthenticator := cache.Get(expectedCacheKey)
|
|
require.NotNil(t, cachedAuthenticator)
|
|
|
|
// Schedule it to be closed at the end of the test.
|
|
t.Cleanup(cachedAuthenticator.(*jwtAuthenticator).Close)
|
|
|
|
const (
|
|
goodSubject = "some-subject"
|
|
group0 = "some-group-0"
|
|
group1 = "some-group-1"
|
|
goodUsername = "pinny123"
|
|
)
|
|
|
|
if tt.wantUsernameClaim == "" {
|
|
tt.wantUsernameClaim = "username"
|
|
}
|
|
|
|
if tt.wantGroupsClaim == "" {
|
|
tt.wantGroupsClaim = "groups"
|
|
}
|
|
|
|
for _, test := range testTableForAuthenticateTokenTests(
|
|
t,
|
|
goodRSASigningKey,
|
|
goodRSASigningAlgo,
|
|
goodRSASigningKeyID,
|
|
group0,
|
|
group1,
|
|
goodUsername,
|
|
tt.wantUsernameClaim,
|
|
tt.wantGroupsClaim,
|
|
goodIssuer,
|
|
) {
|
|
test := test
|
|
t.Run(test.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
wellKnownClaims := jwt.Claims{
|
|
Issuer: goodIssuer,
|
|
Subject: goodSubject,
|
|
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)),
|
|
}
|
|
var groups interface{}
|
|
username := goodUsername
|
|
if test.jwtClaims != nil {
|
|
test.jwtClaims(&wellKnownClaims, &groups, &username)
|
|
}
|
|
|
|
var signingKey interface{} = goodECSigningKey
|
|
signingAlgo := goodECSigningAlgo
|
|
signingKID := goodECSigningKeyID
|
|
if test.jwtSignature != nil {
|
|
test.jwtSignature(&signingKey, &signingAlgo, &signingKID)
|
|
}
|
|
|
|
jwt := createJWT(
|
|
t,
|
|
signingKey,
|
|
signingAlgo,
|
|
signingKID,
|
|
&wellKnownClaims,
|
|
tt.wantGroupsClaim,
|
|
groups,
|
|
test.distributedGroupsClaimURL,
|
|
tt.wantUsernameClaim,
|
|
username,
|
|
)
|
|
|
|
// Loop for a while here to allow the underlying OIDC authenticator to initialize itself asynchronously.
|
|
var (
|
|
rsp *authenticator.Response
|
|
authenticated bool
|
|
err error
|
|
)
|
|
_ = wait.PollImmediate(10*time.Millisecond, 5*time.Second, func() (bool, error) {
|
|
rsp, authenticated, err = cachedAuthenticator.AuthenticateToken(context.Background(), jwt)
|
|
return !isNotInitialized(err), nil
|
|
})
|
|
if test.wantErr != nil {
|
|
testutil.RequireErrorStringFromErr(t, err, test.wantErr)
|
|
} else {
|
|
require.NoError(t, err)
|
|
require.Equal(t, test.wantResponse, rsp)
|
|
require.Equal(t, test.wantAuthenticated, authenticated)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// isNotInitialized checks if the error is the internally-defined "oidc: authenticator not initialized" error from
|
|
// the underlying OIDC authenticator or "verifier is not initialized" from verifying distributed claims,
|
|
// both of which are initialized asynchronously.
|
|
func isNotInitialized(err error) bool {
|
|
return err != nil && (strings.Contains(err.Error(), "authenticator not initialized") || strings.Contains(err.Error(), "verifier not initialized"))
|
|
}
|
|
|
|
func testTableForAuthenticateTokenTests(
|
|
t *testing.T,
|
|
goodRSASigningKey *rsa.PrivateKey,
|
|
goodRSASigningAlgo jose.SignatureAlgorithm,
|
|
goodRSASigningKeyID string,
|
|
group0 string,
|
|
group1 string,
|
|
goodUsername string,
|
|
expectedUsernameClaim string,
|
|
expectedGroupsClaim string,
|
|
issuer string,
|
|
) []struct {
|
|
name string
|
|
jwtClaims func(wellKnownClaims *jwt.Claims, groups *interface{}, username *string)
|
|
jwtSignature func(key *interface{}, algo *jose.SignatureAlgorithm, kid *string)
|
|
wantResponse *authenticator.Response
|
|
wantAuthenticated bool
|
|
wantErr testutil.RequireErrorStringFunc
|
|
distributedGroupsClaimURL string
|
|
} {
|
|
tests := []struct {
|
|
name string
|
|
jwtClaims func(wellKnownClaims *jwt.Claims, groups *interface{}, username *string)
|
|
jwtSignature func(key *interface{}, algo *jose.SignatureAlgorithm, kid *string)
|
|
wantResponse *authenticator.Response
|
|
wantAuthenticated bool
|
|
wantErr testutil.RequireErrorStringFunc
|
|
distributedGroupsClaimURL string
|
|
}{
|
|
{
|
|
name: "good token without groups and with EC signature",
|
|
wantResponse: &authenticator.Response{
|
|
User: &user.DefaultInfo{
|
|
Name: goodUsername,
|
|
},
|
|
},
|
|
wantAuthenticated: true,
|
|
},
|
|
{
|
|
name: "good token without groups and with RSA signature",
|
|
jwtSignature: func(key *interface{}, algo *jose.SignatureAlgorithm, kid *string) {
|
|
*key = goodRSASigningKey
|
|
*algo = goodRSASigningAlgo
|
|
*kid = goodRSASigningKeyID
|
|
},
|
|
wantResponse: &authenticator.Response{
|
|
User: &user.DefaultInfo{
|
|
Name: goodUsername,
|
|
},
|
|
},
|
|
wantAuthenticated: true,
|
|
},
|
|
{
|
|
name: "good token with groups as array",
|
|
jwtClaims: func(_ *jwt.Claims, groups *interface{}, username *string) {
|
|
*groups = []string{group0, group1}
|
|
},
|
|
wantResponse: &authenticator.Response{
|
|
User: &user.DefaultInfo{
|
|
Name: goodUsername,
|
|
Groups: []string{group0, group1},
|
|
},
|
|
},
|
|
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",
|
|
wantErr: testutil.WantMatchingErrorString(`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",
|
|
wantErr: testutil.WantMatchingErrorString(`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",
|
|
jwtClaims: func(_ *jwt.Claims, groups *interface{}, username *string) {
|
|
*groups = group0
|
|
},
|
|
wantResponse: &authenticator.Response{
|
|
User: &user.DefaultInfo{
|
|
Name: goodUsername,
|
|
Groups: []string{group0},
|
|
},
|
|
},
|
|
wantAuthenticated: true,
|
|
},
|
|
{
|
|
name: "good token with nbf unset",
|
|
jwtClaims: func(claims *jwt.Claims, _ *interface{}, username *string) {
|
|
claims.NotBefore = nil
|
|
},
|
|
wantResponse: &authenticator.Response{
|
|
User: &user.DefaultInfo{
|
|
Name: goodUsername,
|
|
},
|
|
},
|
|
wantAuthenticated: true,
|
|
},
|
|
{
|
|
name: "bad token with groups as map",
|
|
jwtClaims: func(_ *jwt.Claims, groups *interface{}, username *string) {
|
|
*groups = map[string]string{"not an array": "or a string"}
|
|
},
|
|
wantErr: testutil.WantMatchingErrorString("oidc: parse groups claim \"" + expectedGroupsClaim + "\": json: cannot unmarshal object into Go value of type string"),
|
|
},
|
|
{
|
|
name: "bad token with wrong issuer",
|
|
jwtClaims: func(claims *jwt.Claims, _ *interface{}, username *string) {
|
|
claims.Issuer = "wrong-issuer"
|
|
},
|
|
wantResponse: nil,
|
|
wantAuthenticated: false,
|
|
},
|
|
{
|
|
name: "bad token with no audience",
|
|
jwtClaims: func(claims *jwt.Claims, _ *interface{}, username *string) {
|
|
claims.Audience = nil
|
|
},
|
|
wantErr: testutil.WantMatchingErrorString(`oidc: verify token: oidc: expected audience "some-audience" got \[\]`),
|
|
},
|
|
{
|
|
name: "bad token with wrong audience",
|
|
jwtClaims: func(claims *jwt.Claims, _ *interface{}, username *string) {
|
|
claims.Audience = []string{"wrong-audience"}
|
|
},
|
|
wantErr: testutil.WantMatchingErrorString(`oidc: verify token: oidc: expected audience "some-audience" got \["wrong-audience"\]`),
|
|
},
|
|
{
|
|
name: "bad token with nbf in the future",
|
|
jwtClaims: func(claims *jwt.Claims, _ *interface{}, username *string) {
|
|
claims.NotBefore = jwt.NewNumericDate(time.Date(3020, 2, 3, 4, 5, 6, 7, time.UTC))
|
|
},
|
|
wantErr: testutil.WantMatchingErrorString(`oidc: verify token: oidc: current time .* before the nbf \(not before\) time: 3020-.*`),
|
|
},
|
|
{
|
|
name: "bad token with exp in past",
|
|
jwtClaims: func(claims *jwt.Claims, _ *interface{}, username *string) {
|
|
claims.Expiry = jwt.NewNumericDate(time.Date(1, 2, 3, 4, 5, 6, 7, time.UTC))
|
|
},
|
|
wantErr: testutil.WantMatchingErrorString(`oidc: verify token: oidc: token is expired \(Token Expiry: .+`),
|
|
},
|
|
{
|
|
name: "bad token without exp",
|
|
jwtClaims: func(claims *jwt.Claims, _ *interface{}, username *string) {
|
|
claims.Expiry = nil
|
|
},
|
|
wantErr: testutil.WantMatchingErrorString(`oidc: verify token: oidc: token is expired \(Token Expiry: .+`),
|
|
},
|
|
{
|
|
name: "token does not have username claim",
|
|
jwtClaims: func(claims *jwt.Claims, _ *interface{}, username *string) {
|
|
*username = ""
|
|
},
|
|
wantErr: testutil.WantMatchingErrorString(`oidc: parse username claims "` + expectedUsernameClaim + `": claim not present`),
|
|
},
|
|
{
|
|
name: "signing key is wrong",
|
|
jwtSignature: func(key *interface{}, algo *jose.SignatureAlgorithm, kid *string) {
|
|
var err error
|
|
*key, err = ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
require.NoError(t, err)
|
|
*algo = jose.ES256
|
|
},
|
|
wantErr: testutil.WantMatchingErrorString(`oidc: verify token: failed to verify signature: failed to verify id token signature`),
|
|
},
|
|
{
|
|
name: "signing algo is unsupported",
|
|
jwtSignature: func(key *interface{}, algo *jose.SignatureAlgorithm, kid *string) {
|
|
var err error
|
|
*key, err = ecdsa.GenerateKey(elliptic.P384(), rand.Reader)
|
|
require.NoError(t, err)
|
|
*algo = jose.ES384
|
|
},
|
|
wantErr: testutil.WantMatchingErrorString(`oidc: verify token: oidc: id token signed with unsupported algorithm, expected \["RS256" "ES256"\] got "ES384"`),
|
|
},
|
|
}
|
|
|
|
return tests
|
|
}
|
|
|
|
func tlsSpecFromTLSConfig(tls *tls.Config) *auth1alpha1.TLSSpec {
|
|
pemData := make([]byte, 0)
|
|
for _, certificate := range tls.Certificates {
|
|
for _, reallyCertificate := range certificate.Certificate {
|
|
pemData = append(pemData, pem.EncodeToMemory(&pem.Block{
|
|
Type: "CERTIFICATE",
|
|
Bytes: reallyCertificate,
|
|
})...)
|
|
}
|
|
}
|
|
return &auth1alpha1.TLSSpec{
|
|
CertificateAuthorityData: base64.StdEncoding.EncodeToString(pemData),
|
|
}
|
|
}
|
|
|
|
func createJWT(
|
|
t *testing.T,
|
|
signingKey interface{},
|
|
signingAlgo jose.SignatureAlgorithm,
|
|
kid string,
|
|
claims *jwt.Claims,
|
|
groupsClaim string,
|
|
groupsValue interface{},
|
|
distributedGroupsClaimURL string,
|
|
usernameClaim string,
|
|
usernameValue string,
|
|
) string {
|
|
t.Helper()
|
|
|
|
sig, err := jose.NewSigner(
|
|
jose.SigningKey{Algorithm: signingAlgo, Key: signingKey},
|
|
(&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", kid),
|
|
)
|
|
require.NoError(t, err)
|
|
|
|
builder := jwt.Signed(sig).Claims(claims)
|
|
if groupsValue != nil {
|
|
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 != "" {
|
|
builder = builder.Claims(map[string]interface{}{usernameClaim: usernameValue})
|
|
}
|
|
jwt, err := builder.CompactSerialize()
|
|
require.NoError(t, err)
|
|
|
|
return jwt
|
|
}
|
|
|
|
func newCacheValue(t *testing.T, spec auth1alpha1.JWTAuthenticatorSpec, wantClose bool) authncache.Value {
|
|
ctrl := gomock.NewController(t)
|
|
t.Cleanup(ctrl.Finish)
|
|
tokenAuthenticatorCloser := mocktokenauthenticatorcloser.NewMockTokenAuthenticatorCloser(ctrl)
|
|
|
|
wantCloses := 0
|
|
if wantClose {
|
|
wantCloses++
|
|
}
|
|
tokenAuthenticatorCloser.EXPECT().Close().Times(wantCloses)
|
|
|
|
return &jwtAuthenticator{
|
|
tokenAuthenticatorCloser: tokenAuthenticatorCloser,
|
|
spec: &spec,
|
|
}
|
|
}
|