Add JWTAuthenticator controller

See https://github.com/vmware-tanzu/pinniped/issues/260 for UX bummer.

Signed-off-by: Andrew Keesler <akeesler@vmware.com>
This commit is contained in:
Andrew Keesler
2020-12-08 15:41:48 -05:00
parent 946b0539d2
commit 57103e0a9f
15 changed files with 1174 additions and 331 deletions
+81 -66
View File
@@ -114,17 +114,93 @@ func TestCLILoginOIDC(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
// Start the browser driver.
page := browsertest.Open(t)
// Build pinniped CLI.
t.Logf("building CLI binary")
pinnipedExe := buildPinnipedCLI(t)
// Run "pinniped login oidc" to get an ExecCredential struct with an OIDC ID token.
credOutput, sessionCachePath := runPinniedLoginOIDC(ctx, t, pinnipedExe)
// Assert some properties of the ExecCredential.
t.Logf("validating ExecCredential")
require.NotNil(t, credOutput.Status)
require.Empty(t, credOutput.Status.ClientKeyData)
require.Empty(t, credOutput.Status.ClientCertificateData)
// There should be at least 1 minute of remaining expiration (probably more).
require.NotNil(t, credOutput.Status.ExpirationTimestamp)
ttl := time.Until(credOutput.Status.ExpirationTimestamp.Time)
require.Greater(t, ttl.Milliseconds(), (1 * time.Minute).Milliseconds())
// Assert some properties about the token, which should be a valid JWT.
require.NotEmpty(t, credOutput.Status.Token)
jws, err := jose.ParseSigned(credOutput.Status.Token)
require.NoError(t, err)
claims := map[string]interface{}{}
require.NoError(t, json.Unmarshal(jws.UnsafePayloadWithoutVerification(), &claims))
require.Equal(t, env.CLITestUpstream.Issuer, claims["iss"])
require.Equal(t, env.CLITestUpstream.ClientID, claims["aud"])
require.Equal(t, env.CLITestUpstream.Username, claims["email"])
require.NotEmpty(t, claims["nonce"])
// Run the CLI again with the same session cache and login parameters.
t.Logf("starting second CLI subprocess to test session caching")
cmd2Output, err := oidcLoginCommand(ctx, t, pinnipedExe, sessionCachePath).CombinedOutput()
require.NoError(t, err, string(cmd2Output))
// Expect the CLI to output the same ExecCredential in JSON format.
t.Logf("validating second ExecCredential")
var credOutput2 clientauthenticationv1beta1.ExecCredential
require.NoErrorf(t, json.Unmarshal(cmd2Output, &credOutput2),
"command returned something other than an ExecCredential:\n%s", string(cmd2Output))
require.Equal(t, credOutput, credOutput2)
// Overwrite the cache entry to remove the access and ID tokens.
t.Logf("overwriting cache to remove valid ID token")
cache := filesession.New(sessionCachePath)
cacheKey := oidcclient.SessionCacheKey{
Issuer: env.CLITestUpstream.Issuer,
ClientID: env.CLITestUpstream.ClientID,
Scopes: []string{"email", "offline_access", "openid", "profile"},
RedirectURI: strings.ReplaceAll(env.CLITestUpstream.CallbackURL, "127.0.0.1", "localhost"),
}
cached := cache.GetToken(cacheKey)
require.NotNil(t, cached)
require.NotNil(t, cached.RefreshToken)
require.NotEmpty(t, cached.RefreshToken.Token)
cached.IDToken = nil
cached.AccessToken = nil
cache.PutToken(cacheKey, cached)
// Run the CLI a third time with the same session cache and login parameters.
t.Logf("starting third CLI subprocess to test refresh flow")
cmd3Output, err := oidcLoginCommand(ctx, t, pinnipedExe, sessionCachePath).CombinedOutput()
require.NoError(t, err, string(cmd2Output))
// Expect the CLI to output a new ExecCredential in JSON format (different from the one returned the first two times).
t.Logf("validating third ExecCredential")
var credOutput3 clientauthenticationv1beta1.ExecCredential
require.NoErrorf(t, json.Unmarshal(cmd3Output, &credOutput3),
"command returned something other than an ExecCredential:\n%s", string(cmd2Output))
require.NotEqual(t, credOutput2.Status.Token, credOutput3.Status.Token)
}
func runPinniedLoginOIDC(
ctx context.Context,
t *testing.T,
pinnipedExe string,
) (clientauthenticationv1beta1.ExecCredential, string) {
t.Helper()
env := library.IntegrationEnv(t)
// Make a temp directory to hold the session cache for this test.
sessionCachePath := testutil.TempDir(t) + "/sessions.yaml"
// Start the CLI running the "alpha login oidc [...]" command with stdout/stderr connected to pipes.
// Start the browser driver.
page := browsertest.Open(t)
// Start the CLI running the "login oidc [...]" command with stdout/stderr connected to pipes.
cmd := oidcLoginCommand(ctx, t, pinnipedExe, sessionCachePath)
stderr, err := cmd.StderrPipe()
require.NoError(t, err)
@@ -221,68 +297,7 @@ func TestCLILoginOIDC(t *testing.T) {
case credOutput = <-credOutputChan:
}
// Assert some properties of the ExecCredential.
t.Logf("validating ExecCredential")
require.NotNil(t, credOutput.Status)
require.Empty(t, credOutput.Status.ClientKeyData)
require.Empty(t, credOutput.Status.ClientCertificateData)
// There should be at least 1 minute of remaining expiration (probably more).
require.NotNil(t, credOutput.Status.ExpirationTimestamp)
ttl := time.Until(credOutput.Status.ExpirationTimestamp.Time)
require.Greater(t, ttl.Milliseconds(), (1 * time.Minute).Milliseconds())
// Assert some properties about the token, which should be a valid JWT.
require.NotEmpty(t, credOutput.Status.Token)
jws, err := jose.ParseSigned(credOutput.Status.Token)
require.NoError(t, err)
claims := map[string]interface{}{}
require.NoError(t, json.Unmarshal(jws.UnsafePayloadWithoutVerification(), &claims))
require.Equal(t, env.CLITestUpstream.Issuer, claims["iss"])
require.Equal(t, env.CLITestUpstream.ClientID, claims["aud"])
require.Equal(t, env.CLITestUpstream.Username, claims["email"])
require.NotEmpty(t, claims["nonce"])
// Run the CLI again with the same session cache and login parameters.
t.Logf("starting second CLI subprocess to test session caching")
cmd2Output, err := oidcLoginCommand(ctx, t, pinnipedExe, sessionCachePath).CombinedOutput()
require.NoError(t, err, string(cmd2Output))
// Expect the CLI to output the same ExecCredential in JSON format.
t.Logf("validating second ExecCredential")
var credOutput2 clientauthenticationv1beta1.ExecCredential
require.NoErrorf(t, json.Unmarshal(cmd2Output, &credOutput2),
"command returned something other than an ExecCredential:\n%s", string(cmd2Output))
require.Equal(t, credOutput, credOutput2)
// Overwrite the cache entry to remove the access and ID tokens.
t.Logf("overwriting cache to remove valid ID token")
cache := filesession.New(sessionCachePath)
cacheKey := oidcclient.SessionCacheKey{
Issuer: env.CLITestUpstream.Issuer,
ClientID: env.CLITestUpstream.ClientID,
Scopes: []string{"email", "offline_access", "openid", "profile"},
RedirectURI: strings.ReplaceAll(env.CLITestUpstream.CallbackURL, "127.0.0.1", "localhost"),
}
cached := cache.GetToken(cacheKey)
require.NotNil(t, cached)
require.NotNil(t, cached.RefreshToken)
require.NotEmpty(t, cached.RefreshToken.Token)
cached.IDToken = nil
cached.AccessToken = nil
cache.PutToken(cacheKey, cached)
// Run the CLI a third time with the same session cache and login parameters.
t.Logf("starting third CLI subprocess to test refresh flow")
cmd3Output, err := oidcLoginCommand(ctx, t, pinnipedExe, sessionCachePath).CombinedOutput()
require.NoError(t, err, string(cmd2Output))
// Expect the CLI to output a new ExecCredential in JSON format (different from the one returned the first two times).
t.Logf("validating third ExecCredential")
var credOutput3 clientauthenticationv1beta1.ExecCredential
require.NoErrorf(t, json.Unmarshal(cmd3Output, &credOutput3),
"command returned something other than an ExecCredential:\n%s", string(cmd2Output))
require.NotEqual(t, credOutput2.Status.Token, credOutput3.Status.Token)
return credOutput, sessionCachePath
}
func readAndExpectEmpty(r io.Reader) (err error) {
@@ -12,6 +12,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
jwtpkg "gopkg.in/square/go-jose.v2/jwt"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -44,48 +45,89 @@ func TestSuccessfulCredentialRequest(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Minute)
defer cancel()
testWebhook := library.CreateTestWebhookAuthenticator(ctx, t)
tests := []struct {
name string
authenticator func(t *testing.T) corev1.TypedLocalObjectReference
token func(t *testing.T) (token string, username string, groups []string)
}{
{
name: "webhook",
authenticator: func(t *testing.T) corev1.TypedLocalObjectReference {
return library.CreateTestWebhookAuthenticator(ctx, t)
},
token: func(t *testing.T) (string, string, []string) {
return library.IntegrationEnv(t).TestUser.Token, env.TestUser.ExpectedUsername, env.TestUser.ExpectedGroups
},
},
{
name: "jwt authenticator",
authenticator: func(t *testing.T) corev1.TypedLocalObjectReference {
return library.CreateTestJWTAuthenticator(ctx, t)
},
token: func(t *testing.T) (string, string, []string) {
pinnipedExe := buildPinnipedCLI(t)
credOutput, _ := runPinniedLoginOIDC(ctx, t, pinnipedExe)
token := credOutput.Status.Token
var response *loginv1alpha1.TokenCredentialRequest
successfulResponse := func() bool {
var err error
response, err = makeRequest(ctx, t, validCredentialRequestSpecWithRealToken(t, testWebhook))
require.NoError(t, err, "the request should never fail at the HTTP level")
return response.Status.Credential != nil
// By default, the JWTAuthenticator expects the username to be in the "sub" claim and the
// groups to be in the "groups" claim.
username, groups := getJWTSubAndGroupsClaims(t, token)
return credOutput.Status.Token, username, groups
},
},
}
assert.Eventually(t, successfulResponse, 10*time.Second, 500*time.Millisecond)
require.NotNil(t, response)
require.NotNil(t, response.Status.Credential)
require.Empty(t, response.Status.Message)
require.Empty(t, response.Spec)
require.Empty(t, response.Status.Credential.Token)
require.NotEmpty(t, response.Status.Credential.ClientCertificateData)
require.Equal(t, env.TestUser.ExpectedUsername, getCommonName(t, response.Status.Credential.ClientCertificateData))
require.ElementsMatch(t, env.TestUser.ExpectedGroups, getOrganizations(t, response.Status.Credential.ClientCertificateData))
require.NotEmpty(t, response.Status.Credential.ClientKeyData)
require.NotNil(t, response.Status.Credential.ExpirationTimestamp)
require.InDelta(t, 5*time.Minute, time.Until(response.Status.Credential.ExpirationTimestamp.Time), float64(time.Minute))
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
authenticator := test.authenticator(t)
token, username, groups := test.token(t)
// Create a client using the admin kubeconfig.
adminClient := library.NewClientset(t)
var response *loginv1alpha1.TokenCredentialRequest
successfulResponse := func() bool {
var err error
response, err = makeRequest(ctx, t, loginv1alpha1.TokenCredentialRequestSpec{
Token: token,
Authenticator: authenticator,
})
require.NoError(t, err, "the request should never fail at the HTTP level")
return response.Status.Credential != nil
}
assert.Eventually(t, successfulResponse, 10*time.Second, 500*time.Millisecond)
require.NotNil(t, response)
require.Emptyf(t, response.Status.Message, "value is: %q", safeDerefStringPtr(response.Status.Message))
require.NotNil(t, response.Status.Credential)
require.Empty(t, response.Spec)
require.Empty(t, response.Status.Credential.Token)
require.NotEmpty(t, response.Status.Credential.ClientCertificateData)
require.Equal(t, username, getCommonName(t, response.Status.Credential.ClientCertificateData))
require.ElementsMatch(t, groups, getOrganizations(t, response.Status.Credential.ClientCertificateData))
require.NotEmpty(t, response.Status.Credential.ClientKeyData)
require.NotNil(t, response.Status.Credential.ExpirationTimestamp)
require.InDelta(t, 5*time.Minute, time.Until(response.Status.Credential.ExpirationTimestamp.Time), float64(time.Minute))
// Create a client using the certificate from the CredentialRequest.
clientWithCertFromCredentialRequest := library.NewClientsetWithCertAndKey(
t,
response.Status.Credential.ClientCertificateData,
response.Status.Credential.ClientKeyData,
)
// Create a client using the admin kubeconfig.
adminClient := library.NewClientset(t)
t.Run(
"access as user",
library.AccessAsUserTest(ctx, adminClient, env.TestUser.ExpectedUsername, clientWithCertFromCredentialRequest),
)
for _, group := range env.TestUser.ExpectedGroups {
group := group
t.Run(
"access as group "+group,
library.AccessAsGroupTest(ctx, adminClient, group, clientWithCertFromCredentialRequest),
)
// Create a client using the certificate from the CredentialRequest.
clientWithCertFromCredentialRequest := library.NewClientsetWithCertAndKey(
t,
response.Status.Credential.ClientCertificateData,
response.Status.Credential.ClientKeyData,
)
t.Run(
"access as user",
library.AccessAsUserTest(ctx, adminClient, username, clientWithCertFromCredentialRequest),
)
for _, group := range groups {
group := group
t.Run(
"access as group "+group,
library.AccessAsGroupTest(ctx, adminClient, group, clientWithCertFromCredentialRequest),
)
}
})
}
}
@@ -183,3 +225,26 @@ func getOrganizations(t *testing.T, certPEM string) []string {
return cert.Subject.Organization
}
func safeDerefStringPtr(s *string) string {
if s == nil {
return "<nil>"
}
return *s
}
func getJWTSubAndGroupsClaims(t *testing.T, jwt string) (string, []string) {
t.Helper()
token, err := jwtpkg.ParseSigned(jwt)
require.NoError(t, err)
var claims struct {
Sub string `json:"sub"`
Groups []string `json:"groups"`
}
err = token.UnsafeClaimsWithoutVerification(&claims)
require.NoError(t, err)
return claims.Sub, claims.Groups
}
+46
View File
@@ -6,6 +6,7 @@ package library
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"fmt"
"io"
@@ -163,6 +164,51 @@ func CreateTestWebhookAuthenticator(ctx context.Context, t *testing.T) corev1.Ty
}
}
// CreateTestJWTAuthenticator creates and returns a test JWTAuthenticator in
// $PINNIPED_TEST_CONCIERGE_NAMESPACE, which will be automatically deleted at the end of the current
// test's lifetime. It returns a corev1.TypedLocalObjectReference which describes the test JWT
// authenticator within the test namespace.
//
// CreateTestJWTAuthenticator gets the OIDC issuer info from IntegrationEnv().CLITestUpstream.
func CreateTestJWTAuthenticator(ctx context.Context, t *testing.T) corev1.TypedLocalObjectReference {
t.Helper()
testEnv := IntegrationEnv(t)
client := NewConciergeClientset(t)
jwtAuthenticators := client.AuthenticationV1alpha1().JWTAuthenticators(testEnv.ConciergeNamespace)
createContext, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
jwtAuthenticator, err := jwtAuthenticators.Create(createContext, &auth1alpha1.JWTAuthenticator{
ObjectMeta: testObjectMeta(t, "jwt-authenticator"),
Spec: auth1alpha1.JWTAuthenticatorSpec{
Issuer: testEnv.CLITestUpstream.Issuer,
Audience: testEnv.CLITestUpstream.ClientID,
TLS: &auth1alpha1.TLSSpec{
CertificateAuthorityData: base64.StdEncoding.EncodeToString([]byte(testEnv.CLITestUpstream.CABundle)),
},
},
}, metav1.CreateOptions{})
require.NoError(t, err, "could not create test JWTAuthenticator")
t.Logf("created test JWTAuthenticator %s/%s", jwtAuthenticator.Namespace, jwtAuthenticator.Name)
t.Cleanup(func() {
t.Helper()
t.Logf("cleaning up test JWTAuthenticator %s/%s", jwtAuthenticator.Namespace, jwtAuthenticator.Name)
deleteCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
err := jwtAuthenticators.Delete(deleteCtx, jwtAuthenticator.Name, metav1.DeleteOptions{})
require.NoErrorf(t, err, "could not cleanup test JWTAuthenticator %s/%s", jwtAuthenticator.Namespace, jwtAuthenticator.Name)
})
return corev1.TypedLocalObjectReference{
APIGroup: &auth1alpha1.SchemeGroupVersion.Group,
Kind: "JWTAuthenticator",
Name: jwtAuthenticator.Name,
}
}
// CreateTestOIDCProvider creates and returns a test OIDCProvider in
// $PINNIPED_TEST_SUPERVISOR_NAMESPACE, which will be automatically deleted at the end of the
// current test's lifetime. It generates a random, valid, issuer for the OIDCProvider.