mirror of
https://github.com/vmware-tanzu/pinniped.git
synced 2026-09-05 23:57:12 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user