diff --git a/internal/oidc/token/token_handler_test.go b/internal/oidc/token/token_handler_test.go index 61006f846..0f64ae432 100644 --- a/internal/oidc/token/token_handler_test.go +++ b/internal/oidc/token/token_handler_test.go @@ -9,8 +9,10 @@ import ( "crypto/elliptic" "crypto/rand" "crypto/sha256" + "encoding/base32" "encoding/base64" "encoding/json" + "fmt" "io" "io/ioutil" "net/http" @@ -564,7 +566,7 @@ func TestTokenEndpointWhenAuthcodeIsUsedTwice(t *testing.T) { require.JSONEq(t, fositeReusedAuthCodeErrorBody, reusedAuthcodeResponse.Body.String()) // This was previously invalidated by the first request, so it remains invalidated - requireInvalidAuthCodeStorage(t, authCode, oauthStore) + requireInvalidAuthCodeStorage(t, authCode, oauthStore, secrets) // Has now invalidated the access token that was previously handed out by the first request requireInvalidAccessTokenStorage(t, parsedResponseBody, oauthStore) // This was previously invalidated by the first request, so it remains invalidated @@ -1189,8 +1191,8 @@ func requireTokenEndpointBehavior( wantIDToken := contains(test.wantSuccessBodyFields, "id_token") wantRefreshToken := contains(test.wantSuccessBodyFields, "refresh_token") - requireInvalidAuthCodeStorage(t, authCode, oauthStore) - requireValidAccessTokenStorage(t, parsedResponseBody, oauthStore, test.wantRequestedScopes, test.wantGrantedScopes) + requireInvalidAuthCodeStorage(t, authCode, oauthStore, secrets) + requireValidAccessTokenStorage(t, parsedResponseBody, oauthStore, test.wantRequestedScopes, test.wantGrantedScopes, secrets) requireInvalidPKCEStorage(t, authCode, oauthStore) requireValidOIDCStorage(t, parsedResponseBody, authCode, oauthStore, test.wantRequestedScopes, test.wantGrantedScopes) @@ -1204,7 +1206,7 @@ func requireTokenEndpointBehavior( requireValidIDToken(t, parsedResponseBody, jwtSigningKey, wantAtHashClaimInIDToken, wantNonceValueInIDToken, parsedResponseBody["access_token"].(string)) } if wantRefreshToken { - requireValidRefreshTokenStorage(t, parsedResponseBody, oauthStore, test.wantRequestedScopes, test.wantGrantedScopes) + requireValidRefreshTokenStorage(t, parsedResponseBody, oauthStore, test.wantRequestedScopes, test.wantGrantedScopes, secrets) } testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: authorizationcode.TypeLabelValue}, 1) @@ -1407,12 +1409,15 @@ func requireInvalidAuthCodeStorage( t *testing.T, code string, storage oauth2.CoreStorage, + secrets v1.SecretInterface, ) { t.Helper() // Make sure we have invalidated this auth code. _, err := storage.GetAuthorizeCodeSession(context.Background(), getFositeDataSignature(t, code), nil) require.True(t, errors.Is(err, fosite.ErrInvalidatedAuthorizeCode)) + // make sure that its still around in storage so if someone tries to use it again we invalidate everything + requireGarbageCollectTimeInDelta(t, code, "authcode", secrets, time.Now().Add(9*time.Hour).Add(10*time.Minute), 30*time.Second) } func requireValidRefreshTokenStorage( @@ -1421,6 +1426,7 @@ func requireValidRefreshTokenStorage( storage oauth2.CoreStorage, wantRequestedScopes []string, wantGrantedScopes []string, + secrets v1.SecretInterface, ) { t.Helper() @@ -1442,6 +1448,8 @@ func requireValidRefreshTokenStorage( wantGrantedScopes, true, ) + + requireGarbageCollectTimeInDelta(t, refreshTokenString, "refresh-token", secrets, time.Now().Add(9*time.Hour).Add(2*time.Minute), 1*time.Minute) } func requireValidAccessTokenStorage( @@ -1450,6 +1458,7 @@ func requireValidAccessTokenStorage( storage oauth2.CoreStorage, wantRequestedScopes []string, wantGrantedScopes []string, + secrets v1.SecretInterface, ) { t.Helper() @@ -1490,6 +1499,8 @@ func requireValidAccessTokenStorage( wantGrantedScopes, true, ) + + requireGarbageCollectTimeInDelta(t, accessTokenString, "access-token", secrets, time.Now().Add(9*time.Hour).Add(2*time.Minute), 1*time.Minute) } func requireInvalidAccessTokenStorage( @@ -1652,6 +1663,24 @@ func requireValidStoredRequest( require.Empty(t, session.Subject) } +func requireGarbageCollectTimeInDelta(t *testing.T, tokenString string, typeLabel string, secrets v1.SecretInterface, wantExpirationTime time.Time, deltaTime time.Duration) { + t.Helper() + signature := getFositeDataSignature(t, tokenString) + signatureBytes, err := base64.RawURLEncoding.DecodeString(signature) + require.NoError(t, err) + // lower case base32 encoding insures that our secret name is valid per ValidateSecretName in k/k + var b32 = base32.StdEncoding.WithPadding(base32.NoPadding) + signatureAsValidName := strings.ToLower(b32.EncodeToString(signatureBytes)) + secretName := fmt.Sprintf("pinniped-storage-%s-%s", typeLabel, signatureAsValidName) + secret, err := secrets.Get(context.Background(), secretName, metav1.GetOptions{}) + require.NoError(t, err) + refreshTokenGCTimeString := secret.Annotations["storage.pinniped.dev/garbage-collect-after"] + refreshTokenGCTime, err := time.Parse(crud.SecretLifetimeAnnotationDateFormat, refreshTokenGCTimeString) + require.NoError(t, err) + + testutil.RequireTimeInDelta(t, refreshTokenGCTime, wantExpirationTime, deltaTime) +} + func requireValidIDToken( t *testing.T, body map[string]interface{}, diff --git a/test/integration/e2e_test.go b/test/integration/e2e_test.go index b7f387e50..4794e39bd 100644 --- a/test/integration/e2e_test.go +++ b/test/integration/e2e_test.go @@ -6,6 +6,7 @@ import ( "bufio" "bytes" "context" + "encoding/base32" "encoding/base64" "errors" "fmt" @@ -21,17 +22,21 @@ import ( "testing" "time" + "go.pinniped.dev/pkg/oidcclient/oidctypes" + coreosoidc "github.com/coreos/go-oidc/v3/oidc" "github.com/creack/pty" "github.com/stretchr/testify/require" authorizationv1 "k8s.io/api/authorization/v1" corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" authv1alpha "go.pinniped.dev/generated/latest/apis/concierge/authentication/v1alpha1" configv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/config/v1alpha1" idpv1alpha1 "go.pinniped.dev/generated/latest/apis/supervisor/idp/v1alpha1" "go.pinniped.dev/internal/certauthority" + "go.pinniped.dev/internal/crud" "go.pinniped.dev/internal/here" "go.pinniped.dev/internal/oidc" "go.pinniped.dev/internal/testutil" @@ -45,7 +50,7 @@ import ( func TestE2EFullIntegration(t *testing.T) { env := library.IntegrationEnv(t) - ctx, cancelFunc := context.WithTimeout(context.Background(), 5*time.Minute) + ctx, cancelFunc := context.WithTimeout(context.Background(), 15*time.Minute) defer cancelFunc() // Build pinniped CLI. @@ -422,6 +427,8 @@ func requireUserCanUseKubectlWithoutAuthenticatingAgain( }) require.NotNil(t, token) + requireGCAnnotationsOnSessionStorage(ctx, t, env.SupervisorNamespace, startTime, token) + idTokenClaims := token.IDToken.Claims require.Equal(t, expectedUsername, idTokenClaims[oidc.DownstreamUsernameClaim]) @@ -482,6 +489,40 @@ func requireUserCanUseKubectlWithoutAuthenticatingAgain( ) } +func requireGCAnnotationsOnSessionStorage(ctx context.Context, t *testing.T, supervisorNamespace string, startTime time.Time, token *oidctypes.Token) { + // check that the access token is new (since it's just been refreshed) and has close to two minutes left. + testutil.RequireTimeInDelta(t, startTime.Add(2*time.Minute), token.AccessToken.Expiry.Time, 15*time.Second) + + kubeClient := library.NewKubernetesClientset(t).CoreV1() + + // get the access token secret that matches the signature from the cache + accessTokenSignature := strings.Split(token.AccessToken.Token, ".")[1] + accessSecretName := getSecretNameFromSignature(t, accessTokenSignature, "access-token") + accessTokenSecret, err := kubeClient.Secrets(supervisorNamespace).Get(ctx, accessSecretName, metav1.GetOptions{}) + require.NoError(t, err) + + // Check that the access token garbage-collect-after value is 9 hours from now + accessTokenGCTimeString := accessTokenSecret.Annotations["storage.pinniped.dev/garbage-collect-after"] + accessTokenGCTime, err := time.Parse(crud.SecretLifetimeAnnotationDateFormat, accessTokenGCTimeString) + require.NoError(t, err) + require.True(t, accessTokenGCTime.After(time.Now().Add(9*time.Hour))) + + // get the refresh token secret that matches the signature from the cache + refreshTokenSignature := strings.Split(token.RefreshToken.Token, ".")[1] + refreshSecretName := getSecretNameFromSignature(t, refreshTokenSignature, "refresh-token") + refreshTokenSecret, err := kubeClient.Secrets(supervisorNamespace).Get(ctx, refreshSecretName, metav1.GetOptions{}) + require.NoError(t, err) + + // Check that the refresh token garbage-collect-after value is 9 hours + refreshTokenGCTimeString := refreshTokenSecret.Annotations["storage.pinniped.dev/garbage-collect-after"] + refreshTokenGCTime, err := time.Parse(crud.SecretLifetimeAnnotationDateFormat, refreshTokenGCTimeString) + require.NoError(t, err) + require.True(t, refreshTokenGCTime.After(time.Now().Add(9*time.Hour))) + + // the access token and the refresh token should be garbage collected at essentially the same time + testutil.RequireTimeInDelta(t, accessTokenGCTime, refreshTokenGCTime, 1*time.Minute) +} + func runPinnipedGetKubeconfig(t *testing.T, env *library.TestEnv, pinnipedExe string, tempDir string, pinnipedCLICommand []string) string { // Run "pinniped get kubeconfig" to get a kubeconfig YAML. envVarsWithProxy := append(os.Environ(), env.ProxyEnv()...) @@ -498,3 +539,14 @@ func runPinnipedGetKubeconfig(t *testing.T, env *library.TestEnv, pinnipedExe st return kubeconfigPath } + +func getSecretNameFromSignature(t *testing.T, signature string, typeLabel string) string { + t.Helper() + // try to decode base64 signatures to prevent double encoding of binary data + signatureBytes, err := base64.RawURLEncoding.DecodeString(signature) + require.NoError(t, err) + // lower case base32 encoding insures that our secret name is valid per ValidateSecretName in k/k + var b32 = base32.StdEncoding.WithPadding(base32.NoPadding) + signatureAsValidName := strings.ToLower(b32.EncodeToString(signatureBytes)) + return fmt.Sprintf("pinniped-storage-%s-%s", typeLabel, signatureAsValidName) +}