From b31a893caf4ecda9e82b47dc655474630d475ee5 Mon Sep 17 00:00:00 2001 From: Joshua Casey Date: Thu, 28 Mar 2024 10:33:53 -0500 Subject: [PATCH] Add integration test and fix totalExpectedAPIFields --- test/integration/kube_api_discovery_test.go | 2 +- test/integration/supervisor_login_test.go | 84 +++++++++++++++++++-- test/integration/supervisor_storage_test.go | 8 +- 3 files changed, 85 insertions(+), 9 deletions(-) diff --git a/test/integration/kube_api_discovery_test.go b/test/integration/kube_api_discovery_test.go index 327c31a45..dbf9e2155 100644 --- a/test/integration/kube_api_discovery_test.go +++ b/test/integration/kube_api_discovery_test.go @@ -438,7 +438,7 @@ func TestGetAPIResourceList(t *testing.T) { //nolint:gocyclo // each t.Run is pr } // manually update this value whenever you add additional fields to an API resource and then run the generator - totalExpectedAPIFields := 261 + totalExpectedAPIFields := 263 // Because we are parsing text from `kubectl explain` and because the format of that text can change // over time, make a rudimentary assertion that this test exercised the whole tree of all fields of all diff --git a/test/integration/supervisor_login_test.go b/test/integration/supervisor_login_test.go index dd373ad45..5443acac3 100644 --- a/test/integration/supervisor_login_test.go +++ b/test/integration/supervisor_login_test.go @@ -281,6 +281,10 @@ func TestSupervisorLogin_Browser(t *testing.T) { // The expected ID token additional claims, which will be nested under claim "additionalClaims", // for the original ID token and the refreshed ID token. wantDownstreamIDTokenAdditionalClaims map[string]interface{} + // The expected ID token lifetime, as calculated by token claim 'exp' subtracting token claim 'iat'. + // ID tokens issued through a token refresh should have the configured lifetime (or default if not configured). + // ID tokens issued through a token exchange should have the default lifetime. + wantDownstreamIDTokenLifetime *time.Duration // Want the authorization endpoint to redirect to the callback with this error type. // The rest of the flow will be skipped since the initial authorization failed. @@ -1423,12 +1427,16 @@ func TestSupervisorLogin_Browser(t *testing.T) { AllowedRedirectURIs: []configv1alpha1.RedirectURI{configv1alpha1.RedirectURI(callbackURL)}, AllowedGrantTypes: []configv1alpha1.GrantType{"authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange", "refresh_token"}, AllowedScopes: []configv1alpha1.Scope{"openid", "offline_access", "pinniped:request-audience", "username", "groups"}, + TokenLifetimes: configv1alpha1.OIDCClientTokenLifetimes{ + IDTokenSeconds: ptr.To[int32](1234), + }, }, configv1alpha1.OIDCClientPhaseReady) }, requestAuthorization: requestAuthorizationUsingBrowserAuthcodeFlowOIDC, wantDownstreamIDTokenSubjectToMatch: expectedIDTokenSubjectRegexForUpstreamOIDC, wantDownstreamIDTokenUsernameToMatch: func(_ string) string { return "^" + regexp.QuoteMeta(env.SupervisorUpstreamOIDC.Username) + "$" }, wantDownstreamIDTokenGroups: env.SupervisorUpstreamOIDC.ExpectedGroups, + wantDownstreamIDTokenLifetime: ptr.To(1234 * time.Second), }, { name: "oidc upstream with downstream dynamic client happy path, requesting all scopes, using the IDP chooser page", @@ -2162,6 +2170,7 @@ func TestSupervisorLogin_Browser(t *testing.T) { tt.wantDownstreamIDTokenUsernameToMatch, tt.wantDownstreamIDTokenGroups, tt.wantDownstreamIDTokenAdditionalClaims, + tt.wantDownstreamIDTokenLifetime, tt.wantAuthorizationErrorType, tt.wantAuthorizationErrorDescription, tt.wantAuthcodeExchangeError, @@ -2317,6 +2326,7 @@ func testSupervisorLogin( wantDownstreamIDTokenUsernameToMatch func(username string) string, wantDownstreamIDTokenGroups []string, wantDownstreamIDTokenAdditionalClaims map[string]interface{}, + wantDownstreamIDTokenLifetime *time.Duration, wantAuthorizationErrorType string, wantAuthorizationErrorDescription string, wantAuthcodeExchangeError string, @@ -2402,7 +2412,7 @@ func testSupervisorLogin( configv1alpha1.FederationDomainPhaseReady, ) - // Ensure the the JWKS data is created and ready for the new FederationDomain by waiting for + // Ensure that the JWKS data is created and ready for the new FederationDomain by waiting for // the `/jwks.json` endpoint to succeed, because there is no point in proceeding and eventually // calling the token endpoint from this test until the JWKS data has been loaded into // the server's in-memory JWKS cache for the token endpoint to use. @@ -2545,6 +2555,11 @@ func testSupervisorLogin( if len(wantDownstreamIDTokenAdditionalClaims) > 0 { expectedIDTokenClaims = append(expectedIDTokenClaims, "additionalClaims") } + defaultIDTokenLifetime := oidc.DefaultOIDCTimeoutsConfiguration().IDTokenLifespan + if wantDownstreamIDTokenLifetime == nil { + wantDownstreamIDTokenLifetime = ptr.To(defaultIDTokenLifetime) + } + initialIDTokenClaims := verifyTokenResponse( t, tokenResponse, @@ -2556,13 +2571,24 @@ func testSupervisorLogin( wantDownstreamIDTokenUsernameToMatch(username), wantDownstreamIDTokenGroups, wantDownstreamIDTokenAdditionalClaims, + *wantDownstreamIDTokenLifetime, ) // token exchange on the original token if requestTokenExchangeAud == "" { requestTokenExchangeAud = "some-cluster-123" // use a default test value } - doTokenExchange(t, requestTokenExchangeAud, &downstreamOAuth2Config, tokenResponse, httpClient, discovery, wantTokenExchangeResponse, initialIDTokenClaims) + doTokenExchange( + t, + requestTokenExchangeAud, + &downstreamOAuth2Config, + tokenResponse, + httpClient, + discovery, + wantTokenExchangeResponse, + initialIDTokenClaims, + defaultIDTokenLifetime, + ) wantRefreshedGroups := wantDownstreamIDTokenGroups if editRefreshSessionDataWithoutBreaking != nil { @@ -2616,6 +2642,7 @@ func testSupervisorLogin( wantDownstreamIDTokenUsernameToMatch(username), wantRefreshedGroups, wantDownstreamIDTokenAdditionalClaims, + *wantDownstreamIDTokenLifetime, ) require.NotEqual(t, tokenResponse.AccessToken, refreshedTokenResponse.AccessToken) @@ -2623,7 +2650,17 @@ func testSupervisorLogin( require.NotEqual(t, tokenResponse.Extra("id_token"), refreshedTokenResponse.Extra("id_token")) // token exchange on the refreshed token - doTokenExchange(t, requestTokenExchangeAud, &downstreamOAuth2Config, refreshedTokenResponse, httpClient, discovery, wantTokenExchangeResponse, refreshedIDTokenClaims) + doTokenExchange( + t, + requestTokenExchangeAud, + &downstreamOAuth2Config, + refreshedTokenResponse, + httpClient, + discovery, + wantTokenExchangeResponse, + refreshedIDTokenClaims, + defaultIDTokenLifetime, + ) // Now that we have successfully performed a refresh, let's test what happens when an // upstream refresh fails during the next downstream refresh. @@ -2675,6 +2712,7 @@ func verifyTokenResponse( wantDownstreamIDTokenSubjectToMatch, wantDownstreamIDTokenUsernameToMatch string, wantDownstreamIDTokenGroups []string, wantDownstreamIDTokenAdditionalClaims map[string]interface{}, + wantDownstreamIDTokenLifetime time.Duration, ) map[string]interface{} { ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() @@ -2693,8 +2731,7 @@ func verifyTokenResponse( require.NoError(t, nonceParam.Validate(idToken)) // Check the exp claim of the ID token. - expectedIDTokenLifetime := oidc.DefaultOIDCTimeoutsConfiguration().IDTokenLifespan - testutil.RequireTimeInDelta(t, time.Now().UTC().Add(expectedIDTokenLifetime), idToken.Expiry, time.Second*30) + testutil.RequireTimeInDelta(t, time.Now().UTC().Add(wantDownstreamIDTokenLifetime), idToken.Expiry, time.Second*30) // Check the full list of claim names of the ID token. idTokenClaims := map[string]interface{}{} @@ -2726,6 +2763,19 @@ func verifyTokenResponse( require.NotContains(t, idTokenClaims, "additionalClaims", "additionalClaims claim should not be present when no sub claims are expected") } + // Check the token lifetime by calculating the delta between "exp" and "iat" + iat := getFloat64Claim(t, idTokenClaims, "iat") + exp := getFloat64Claim(t, idTokenClaims, "exp") + require.InDeltaf( + t, + wantDownstreamIDTokenLifetime.Seconds(), + exp-iat, + 10.0, + "actual token lifetime (%f) must be within 10 seconds of expectation (%f)", + exp-iat, + wantDownstreamIDTokenLifetime.Seconds(), + ) + // Some light verification of the other tokens that were returned. require.NotEmpty(t, tokenResponse.AccessToken) require.Equal(t, "bearer", tokenResponse.TokenType) @@ -2747,6 +2797,16 @@ func verifyTokenResponse( return idTokenClaims } +func getFloat64Claim(t *testing.T, claims map[string]interface{}, claim string) float64 { + t.Helper() + + v, ok := claims[claim] + require.True(t, ok, "claim %s must be present", claim) + f, ok := v.(float64) + require.True(t, ok, "claim %s must be a float64", claim) + return f +} + func hashAccessToken(accessToken string) string { // See https://openid.net/specs/openid-connect-core-1_0.html#CodeIDToken. // "Access Token hash value. Its value is the base64url encoding of the left-most half of @@ -3023,6 +3083,7 @@ func doTokenExchange( provider *coreosoidc.Provider, wantTokenExchangeResponse func(t *testing.T, status int, body string), previousIDTokenClaims map[string]interface{}, + wantIDTokenLifetime time.Duration, ) { ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() @@ -3089,6 +3150,19 @@ func doTokenExchange( require.Contains(t, claims, "exp") // expires at require.Contains(t, claims, "jti") // JWT ID + // Check the token lifetime by calculating the delta between "exp" and "iat" + iat := getFloat64Claim(t, claims, "iat") + exp := getFloat64Claim(t, claims, "exp") + require.InDeltaf( + t, + wantIDTokenLifetime.Seconds(), + exp-iat, + 10.0, + "actual token lifetime (%f) must be within 10 seconds of expectation (%f)", + exp-iat, + wantIDTokenLifetime.Seconds(), + ) + // The original client ID should be preserved in the azp claim, therefore preserving this information // about the original source of the authorization for tracing/auditing purposes, since the "aud" claim // has been updated to have a new value. diff --git a/test/integration/supervisor_storage_test.go b/test/integration/supervisor_storage_test.go index 2bfd91928..b0826e035 100644 --- a/test/integration/supervisor_storage_test.go +++ b/test/integration/supervisor_storage_test.go @@ -1,4 +1,4 @@ -// Copyright 2020-2023 the Pinniped contributors. All Rights Reserved. +// Copyright 2020-2024 the Pinniped contributors. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 package integration @@ -52,7 +52,9 @@ func TestAuthorizeCodeStorage(t *testing.T) { defer cancel() sessionStorageLifetime := 5 * time.Minute - storage := authorizationcode.New(secrets, time.Now, sessionStorageLifetime) + storage := authorizationcode.New(secrets, time.Now, func(requester fosite.Requester) time.Duration { + return sessionStorageLifetime + }) // the session for this signature should not exist yet notFoundRequest, err := storage.GetAuthorizeCodeSession(ctx, signature, nil) @@ -129,7 +131,7 @@ func TestAuthorizeCodeStorage(t *testing.T) { require.Error(t, err) require.True(t, stderrors.Is(err, fosite.ErrInvalidatedAuthorizeCode)) - // the data stored in Kube should be exactly the same but it should be marked as used + // the data stored in Kube should be exactly the same, but it should be marked as used invalidatedSecret, err := secrets.Get(ctx, name, metav1.GetOptions{}) require.NoError(t, err) // InvalidateAuthorizeCodeSession() sets Active to false, so update the expected value accordingly.