From 51c828382f652e64be5c204bc9bca73614b744ca Mon Sep 17 00:00:00 2001 From: Ryan Richard Date: Wed, 9 Dec 2020 12:12:59 -0800 Subject: [PATCH] Supervisor token endpoint supports refresh grant type - This commit does not include the sad path tests for the refresh grant type, which will come in a future commit. --- internal/oidc/oidc.go | 4 +- internal/oidc/token/token_handler_test.go | 405 +++++++++++++++++----- 2 files changed, 316 insertions(+), 93 deletions(-) diff --git a/internal/oidc/oidc.go b/internal/oidc/oidc.go index 1e76858d..96c5e72d 100644 --- a/internal/oidc/oidc.go +++ b/internal/oidc/oidc.go @@ -127,9 +127,9 @@ func FositeOauth2Helper( }, nil, // hasher, defaults to using BCrypt when nil. Used for hashing client secrets. compose.OAuth2AuthorizeExplicitFactory, - // compose.OAuth2RefreshTokenGrantFactory, + compose.OAuth2RefreshTokenGrantFactory, compose.OpenIDConnectExplicitFactory, - // compose.OpenIDConnectRefreshFactory, + compose.OpenIDConnectRefreshFactory, compose.OAuth2PKCEFactory, ) } diff --git a/internal/oidc/token/token_handler_test.go b/internal/oidc/token/token_handler_test.go index 77577c1c..0f65aaca 100644 --- a/internal/oidc/token/token_handler_test.go +++ b/internal/oidc/token/token_handler_test.go @@ -8,6 +8,8 @@ import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" + "crypto/sha256" + "encoding/base64" "encoding/json" "io" "io/ioutil" @@ -117,6 +119,16 @@ var ( } `) + fositeInvalidRequestMissingGrantTypeErrorBody = here.Doc(` + { + "error": "invalid_request", + "error_description": "The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed\n\nRequest parameter \"grant_type\"\" is missing", + "error_hint": "Request parameter \"grant_type\"\" is missing", + "error_verbose": "The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed", + "status_code": 400 + } + `) + fositeMissingClientErrorBody = here.Doc(` { "error": "invalid_request", @@ -206,8 +218,27 @@ var ( "redirect_uri": {goodRedirectURI}, }, } + + happyRefreshRequest = func(refreshToken string) *http.Request { + return &http.Request{ + Form: url.Values{ + "grant_type": {"refresh_token"}, + "scope": {"openid"}, + "client_id": {goodClient}, + "refresh_token": {refreshToken}, + }, + } + } ) +type tokenEndpointResponseExpectedValues struct { + wantStatus int + wantSuccessBodyFields []string + wantErrorResponseBody string + wantRequestedScopes []string + wantGrantedScopes []string +} + type authcodeExchangeInputs struct { modifyAuthRequest func(authRequest *http.Request) modifyTokenRequest func(r *http.Request, authCode string) @@ -234,11 +265,7 @@ type authcodeExchangeInputs struct { }, ) (fosite.OAuth2Provider, string, *ecdsa.PrivateKey) - wantStatus int - wantSuccessBodyFields []string - wantErrorResponseBody string - wantRequestedScopes []string - wantGrantedScopes []string + want tokenEndpointResponseExpectedValues } func TestTokenEndpoint(t *testing.T) { @@ -250,10 +277,12 @@ func TestTokenEndpoint(t *testing.T) { { name: "request is valid and tokens are issued", authcodeExchange: authcodeExchangeInputs{ - wantStatus: http.StatusOK, - wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "scope", "expires_in"}, // no refresh token - wantRequestedScopes: []string{"openid", "profile", "email"}, - wantGrantedScopes: []string{"openid"}, + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusOK, + wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "scope", "expires_in"}, // no refresh token + wantRequestedScopes: []string{"openid", "profile", "email"}, + wantGrantedScopes: []string{"openid"}, + }, }, }, { @@ -262,10 +291,12 @@ func TestTokenEndpoint(t *testing.T) { modifyAuthRequest: func(authRequest *http.Request) { authRequest.Form.Set("scope", "profile email") }, - wantStatus: http.StatusOK, - wantSuccessBodyFields: []string{"access_token", "token_type", "scope", "expires_in"}, // no id or refresh tokens - wantRequestedScopes: []string{"profile", "email"}, - wantGrantedScopes: []string{}, + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusOK, + wantSuccessBodyFields: []string{"access_token", "token_type", "scope", "expires_in"}, // no id or refresh tokens + wantRequestedScopes: []string{"profile", "email"}, + wantGrantedScopes: []string{}, + }, }, }, { @@ -274,10 +305,12 @@ func TestTokenEndpoint(t *testing.T) { modifyAuthRequest: func(authRequest *http.Request) { authRequest.Form.Set("scope", "openid offline_access") }, - wantStatus: http.StatusOK, - wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "scope", "expires_in", "refresh_token"}, // all possible tokens - wantRequestedScopes: []string{"openid", "offline_access"}, - wantGrantedScopes: []string{"openid", "offline_access"}, + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusOK, + wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "scope", "expires_in", "refresh_token"}, // all possible tokens + wantRequestedScopes: []string{"openid", "offline_access"}, + wantGrantedScopes: []string{"openid", "offline_access"}, + }, }, }, { @@ -286,10 +319,12 @@ func TestTokenEndpoint(t *testing.T) { modifyAuthRequest: func(authRequest *http.Request) { authRequest.Form.Set("scope", "offline_access") }, - wantStatus: http.StatusOK, - wantSuccessBodyFields: []string{"access_token", "token_type", "scope", "expires_in", "refresh_token"}, // no id token - wantRequestedScopes: []string{"offline_access"}, - wantGrantedScopes: []string{"offline_access"}, + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusOK, + wantSuccessBodyFields: []string{"access_token", "token_type", "scope", "expires_in", "refresh_token"}, // no id token + wantRequestedScopes: []string{"offline_access"}, + wantGrantedScopes: []string{"offline_access"}, + }, }, }, @@ -297,41 +332,51 @@ func TestTokenEndpoint(t *testing.T) { { name: "GET method is wrong", authcodeExchange: authcodeExchangeInputs{ - modifyTokenRequest: func(r *http.Request, authCode string) { r.Method = http.MethodGet }, - wantStatus: http.StatusBadRequest, - wantErrorResponseBody: fositeInvalidMethodErrorBody("GET"), + modifyTokenRequest: func(r *http.Request, authCode string) { r.Method = http.MethodGet }, + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusBadRequest, + wantErrorResponseBody: fositeInvalidMethodErrorBody("GET"), + }, }, }, { name: "PUT method is wrong", authcodeExchange: authcodeExchangeInputs{ - modifyTokenRequest: func(r *http.Request, authCode string) { r.Method = http.MethodPut }, - wantStatus: http.StatusBadRequest, - wantErrorResponseBody: fositeInvalidMethodErrorBody("PUT"), + modifyTokenRequest: func(r *http.Request, authCode string) { r.Method = http.MethodPut }, + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusBadRequest, + wantErrorResponseBody: fositeInvalidMethodErrorBody("PUT"), + }, }, }, { name: "PATCH method is wrong", authcodeExchange: authcodeExchangeInputs{ - modifyTokenRequest: func(r *http.Request, authCode string) { r.Method = http.MethodPatch }, - wantStatus: http.StatusBadRequest, - wantErrorResponseBody: fositeInvalidMethodErrorBody("PATCH"), + modifyTokenRequest: func(r *http.Request, authCode string) { r.Method = http.MethodPatch }, + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusBadRequest, + wantErrorResponseBody: fositeInvalidMethodErrorBody("PATCH"), + }, }, }, { name: "DELETE method is wrong", authcodeExchange: authcodeExchangeInputs{ - modifyTokenRequest: func(r *http.Request, authCode string) { r.Method = http.MethodDelete }, - wantStatus: http.StatusBadRequest, - wantErrorResponseBody: fositeInvalidMethodErrorBody("DELETE"), + modifyTokenRequest: func(r *http.Request, authCode string) { r.Method = http.MethodDelete }, + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusBadRequest, + wantErrorResponseBody: fositeInvalidMethodErrorBody("DELETE"), + }, }, }, { name: "content type is invalid", authcodeExchange: authcodeExchangeInputs{ - modifyTokenRequest: func(r *http.Request, authCode string) { r.Header.Set("Content-Type", "text/plain") }, - wantStatus: http.StatusBadRequest, - wantErrorResponseBody: fositeEmptyPayloadErrorBody, + modifyTokenRequest: func(r *http.Request, authCode string) { r.Header.Set("Content-Type", "text/plain") }, + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusBadRequest, + wantErrorResponseBody: fositeEmptyPayloadErrorBody, + }, }, }, { @@ -340,16 +385,20 @@ func TestTokenEndpoint(t *testing.T) { modifyTokenRequest: func(r *http.Request, authCode string) { r.Body = ioutil.NopCloser(strings.NewReader("this newline character is not allowed in a form serialization: \n")) }, - wantStatus: http.StatusBadRequest, - wantErrorResponseBody: fositeMissingGrantTypeErrorBody, + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusBadRequest, + wantErrorResponseBody: fositeMissingGrantTypeErrorBody, + }, }, }, { name: "payload is empty", authcodeExchange: authcodeExchangeInputs{ - modifyTokenRequest: func(r *http.Request, authCode string) { r.Body = nil }, - wantStatus: http.StatusBadRequest, - wantErrorResponseBody: fositeInvalidPayloadErrorBody, + modifyTokenRequest: func(r *http.Request, authCode string) { r.Body = nil }, + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusBadRequest, + wantErrorResponseBody: fositeInvalidPayloadErrorBody, + }, }, }, { @@ -358,8 +407,10 @@ func TestTokenEndpoint(t *testing.T) { modifyTokenRequest: func(r *http.Request, authCode string) { r.Body = happyBody(authCode).WithGrantType("").ReadCloser() }, - wantStatus: http.StatusBadRequest, - wantErrorResponseBody: fositeMissingGrantTypeErrorBody, + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusBadRequest, + wantErrorResponseBody: fositeMissingGrantTypeErrorBody, + }, }, }, { @@ -368,8 +419,10 @@ func TestTokenEndpoint(t *testing.T) { modifyTokenRequest: func(r *http.Request, authCode string) { r.Body = happyBody(authCode).WithGrantType("bogus").ReadCloser() }, - wantStatus: http.StatusBadRequest, - wantErrorResponseBody: fositeInvalidRequestErrorBody, + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusBadRequest, + wantErrorResponseBody: fositeInvalidRequestErrorBody, + }, }, }, { @@ -378,8 +431,10 @@ func TestTokenEndpoint(t *testing.T) { modifyTokenRequest: func(r *http.Request, authCode string) { r.Body = happyBody(authCode).WithClientID("").ReadCloser() }, - wantStatus: http.StatusBadRequest, - wantErrorResponseBody: fositeMissingClientErrorBody, + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusBadRequest, + wantErrorResponseBody: fositeMissingClientErrorBody, + }, }, }, { @@ -388,8 +443,34 @@ func TestTokenEndpoint(t *testing.T) { modifyTokenRequest: func(r *http.Request, authCode string) { r.Body = happyBody(authCode).WithClientID("bogus").ReadCloser() }, - wantStatus: http.StatusUnauthorized, - wantErrorResponseBody: fositeInvalidClientErrorBody, + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusUnauthorized, + wantErrorResponseBody: fositeInvalidClientErrorBody, + }, + }, + }, + { + name: "grant type is missing", + authcodeExchange: authcodeExchangeInputs{ + modifyTokenRequest: func(r *http.Request, authCode string) { + r.Body = happyBody(authCode).with("grant_type", "").ReadCloser() + }, + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusBadRequest, + wantErrorResponseBody: fositeInvalidRequestMissingGrantTypeErrorBody, + }, + }, + }, + { + name: "grant type is wrong", + authcodeExchange: authcodeExchangeInputs{ + modifyTokenRequest: func(r *http.Request, authCode string) { + r.Body = happyBody(authCode).with("grant_type", "bogus").ReadCloser() + }, + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusBadRequest, + wantErrorResponseBody: fositeInvalidRequestErrorBody, + }, }, }, { @@ -398,8 +479,10 @@ func TestTokenEndpoint(t *testing.T) { modifyTokenRequest: func(r *http.Request, authCode string) { r.Body = happyBody(authCode).WithAuthCode("").ReadCloser() }, - wantStatus: http.StatusBadRequest, - wantErrorResponseBody: fositeInvalidAuthCodeErrorBody, + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusBadRequest, + wantErrorResponseBody: fositeInvalidAuthCodeErrorBody, + }, }, }, { @@ -408,8 +491,10 @@ func TestTokenEndpoint(t *testing.T) { modifyTokenRequest: func(r *http.Request, authCode string) { r.Body = happyBody(authCode).WithAuthCode("bogus").ReadCloser() }, - wantStatus: http.StatusBadRequest, - wantErrorResponseBody: fositeInvalidAuthCodeErrorBody, + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusBadRequest, + wantErrorResponseBody: fositeInvalidAuthCodeErrorBody, + }, }, }, { @@ -429,8 +514,10 @@ func TestTokenEndpoint(t *testing.T) { err := s.InvalidateAuthorizeCodeSession(context.Background(), getFositeDataSignature(t, authCode)) require.NoError(t, err) }, - wantStatus: http.StatusBadRequest, - wantErrorResponseBody: fositeReusedAuthCodeErrorBody, + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusBadRequest, + wantErrorResponseBody: fositeReusedAuthCodeErrorBody, + }, }, }, { @@ -439,8 +526,10 @@ func TestTokenEndpoint(t *testing.T) { modifyTokenRequest: func(r *http.Request, authCode string) { r.Body = happyBody(authCode).WithRedirectURI("").ReadCloser() }, - wantStatus: http.StatusBadRequest, - wantErrorResponseBody: fositeInvalidRedirectURIErrorBody, + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusBadRequest, + wantErrorResponseBody: fositeInvalidRedirectURIErrorBody, + }, }, }, { @@ -449,8 +538,10 @@ func TestTokenEndpoint(t *testing.T) { modifyTokenRequest: func(r *http.Request, authCode string) { r.Body = happyBody(authCode).WithRedirectURI("bogus").ReadCloser() }, - wantStatus: http.StatusBadRequest, - wantErrorResponseBody: fositeInvalidRedirectURIErrorBody, + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusBadRequest, + wantErrorResponseBody: fositeInvalidRedirectURIErrorBody, + }, }, }, { @@ -459,8 +550,10 @@ func TestTokenEndpoint(t *testing.T) { modifyTokenRequest: func(r *http.Request, authCode string) { r.Body = happyBody(authCode).WithPKCE("").ReadCloser() }, - wantStatus: http.StatusBadRequest, - wantErrorResponseBody: fositeMissingPKCEVerifierErrorBody, + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusBadRequest, + wantErrorResponseBody: fositeMissingPKCEVerifierErrorBody, + }, }, }, { @@ -471,16 +564,20 @@ func TestTokenEndpoint(t *testing.T) { "bogus-verifier-that-is-at-least-43-characters-for-the-sake-of-entropy", ).ReadCloser() }, - wantStatus: http.StatusBadRequest, - wantErrorResponseBody: fositeWrongPKCEVerifierErrorBody, + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusBadRequest, + wantErrorResponseBody: fositeWrongPKCEVerifierErrorBody, + }, }, }, { name: "private signing key for JWTs has not yet been provided by the controller who is responsible for dynamically providing it", authcodeExchange: authcodeExchangeInputs{ - makeOathHelper: makeOauthHelperWithNilPrivateJWTSigningKey, - wantStatus: http.StatusServiceUnavailable, - wantErrorResponseBody: fositeTemporarilyUnavailableErrorBody, + makeOathHelper: makeOauthHelperWithNilPrivateJWTSigningKey, + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusServiceUnavailable, + wantErrorResponseBody: fositeTemporarilyUnavailableErrorBody, + }, }, }, } @@ -503,10 +600,12 @@ func TestTokenEndpointWhenAuthcodeIsUsedTwice(t *testing.T) { modifyAuthRequest: func(authRequest *http.Request) { authRequest.Form.Set("scope", "openid offline_access profile email") }, - wantStatus: http.StatusOK, - wantSuccessBodyFields: []string{"id_token", "refresh_token", "access_token", "token_type", "expires_in", "scope"}, - wantRequestedScopes: []string{"openid", "offline_access", "profile", "email"}, - wantGrantedScopes: []string{"openid", "offline_access"}, + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusOK, + wantSuccessBodyFields: []string{"id_token", "refresh_token", "access_token", "token_type", "expires_in", "scope"}, + wantRequestedScopes: []string{"openid", "offline_access", "profile", "email"}, + wantGrantedScopes: []string{"openid", "offline_access"}, + }, }, }, } @@ -514,7 +613,7 @@ func TestTokenEndpointWhenAuthcodeIsUsedTwice(t *testing.T) { test := test t.Run(test.name, func(t *testing.T) { // First call - should be successful. - subject, rsp, authCode, secrets, oauthStore := exchangeAuthcodeForTokens(t, test.authcodeExchange) + subject, rsp, authCode, _, secrets, oauthStore := exchangeAuthcodeForTokens(t, test.authcodeExchange) var parsedResponseBody map[string]interface{} require.NoError(t, json.Unmarshal(rsp.Body.Bytes(), &parsedResponseBody)) @@ -524,13 +623,13 @@ func TestTokenEndpointWhenAuthcodeIsUsedTwice(t *testing.T) { // delete the OIDC storage...but we probably should. req := httptest.NewRequest("POST", "/path/shouldn't/matter", happyBody(authCode).ReadCloser()) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - rsp1 := httptest.NewRecorder() - subject.ServeHTTP(rsp1, req) - t.Logf("second response: %#v", rsp1) - t.Logf("second response body: %q", rsp1.Body.String()) - require.Equal(t, http.StatusBadRequest, rsp1.Code) - testutil.RequireEqualContentType(t, rsp1.Header().Get("Content-Type"), "application/json") - require.JSONEq(t, fositeReusedAuthCodeErrorBody, rsp1.Body.String()) + reusedAuthcodeResponse := httptest.NewRecorder() + subject.ServeHTTP(reusedAuthcodeResponse, req) + t.Logf("second response: %#v", reusedAuthcodeResponse) + t.Logf("second response body: %q", reusedAuthcodeResponse.Body.String()) + require.Equal(t, http.StatusBadRequest, reusedAuthcodeResponse.Code) + testutil.RequireEqualContentType(t, reusedAuthcodeResponse.Header().Get("Content-Type"), "application/json") + require.JSONEq(t, fositeReusedAuthCodeErrorBody, reusedAuthcodeResponse.Body.String()) // This was previously invalidated by the first request, so it remains invalidated requireInvalidAuthCodeStorage(t, authCode, oauthStore) @@ -539,7 +638,8 @@ func TestTokenEndpointWhenAuthcodeIsUsedTwice(t *testing.T) { // This was previously invalidated by the first request, so it remains invalidated requireInvalidPKCEStorage(t, authCode, oauthStore) // Fosite never cleans up OpenID Connect session storage, so it is still there - requireValidOIDCStorage(t, parsedResponseBody, authCode, oauthStore, test.authcodeExchange.wantRequestedScopes, test.authcodeExchange.wantGrantedScopes) + requireValidOIDCStorage(t, parsedResponseBody, authCode, oauthStore, + test.authcodeExchange.want.wantRequestedScopes, test.authcodeExchange.want.wantGrantedScopes) // Check that the access token and refresh token storage were both deleted, and the number of other storage objects did not change. testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: authorizationcode.TypeLabelValue}, 1) @@ -552,10 +652,85 @@ func TestTokenEndpointWhenAuthcodeIsUsedTwice(t *testing.T) { } } +type refreshRequestInputs struct { + want tokenEndpointResponseExpectedValues +} + +func TestRefreshGrant(t *testing.T) { + tests := []struct { + name string + authcodeExchange authcodeExchangeInputs + refreshRequest refreshRequestInputs + }{ + { + name: "happy path refresh grant", + authcodeExchange: authcodeExchangeInputs{ + modifyAuthRequest: func(authRequest *http.Request) { + authRequest.Form.Set("scope", "openid offline_access") + }, + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusOK, + wantSuccessBodyFields: []string{"id_token", "refresh_token", "access_token", "token_type", "expires_in", "scope"}, + wantRequestedScopes: []string{"openid", "offline_access"}, + wantGrantedScopes: []string{"openid", "offline_access"}, + }, + }, + refreshRequest: refreshRequestInputs{ + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusOK, + wantSuccessBodyFields: []string{"id_token", "refresh_token", "access_token", "token_type", "expires_in", "scope"}, + wantRequestedScopes: []string{"openid", "offline_access"}, + wantGrantedScopes: []string{"openid", "offline_access"}, + }}, + }, + // TODO lots of sad path tests + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + // First exchange the authcode for tokens, including a refresh token. + subject, rsp, authCode, jwtSigningKey, secrets, oauthStore := exchangeAuthcodeForTokens(t, test.authcodeExchange) + var parsedAuthcodeExchangeResponseBody map[string]interface{} + require.NoError(t, json.Unmarshal(rsp.Body.Bytes(), &parsedAuthcodeExchangeResponseBody)) + + // Send the refresh token back and preform a refresh. + req := httptest.NewRequest("POST", "/path/shouldn't/matter", + body(happyRefreshRequest(parsedAuthcodeExchangeResponseBody["refresh_token"].(string)).Form).ReadCloser()) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + refreshResponse := httptest.NewRecorder() + subject.ServeHTTP(refreshResponse, req) + t.Logf("second response: %#v", refreshResponse) + t.Logf("second response body: %q", refreshResponse.Body.String()) + + // The bug in fosite that prevents at_hash from appearing in the initial ID token does not impact the refreshed ID token + wantAtHashClaimInIDToken := true + // Refreshed ID tokens do not include the nonce from the original auth request + wantNonceValueInIDToken := false + requireTokenEndpointBehavior(t, test.refreshRequest.want, wantAtHashClaimInIDToken, wantNonceValueInIDToken, refreshResponse, authCode, oauthStore, jwtSigningKey, secrets) + + if test.refreshRequest.want.wantStatus == http.StatusOK { + var parsedRefreshResponseBody map[string]interface{} + require.NoError(t, json.Unmarshal(refreshResponse.Body.Bytes(), &parsedRefreshResponseBody)) + + // Check that we got back new tokens. + require.NotEqual(t, parsedAuthcodeExchangeResponseBody["access_token"].(string), parsedRefreshResponseBody["access_token"].(string)) + require.NotEqual(t, parsedAuthcodeExchangeResponseBody["refresh_token"].(string), parsedRefreshResponseBody["refresh_token"].(string)) + require.NotEqual(t, parsedAuthcodeExchangeResponseBody["id_token"].(string), parsedRefreshResponseBody["id_token"].(string)) + + // The other fields of the response should be the same as the original response. Note that expires_in is a number of seconds from now. + require.Equal(t, parsedAuthcodeExchangeResponseBody["token_type"].(string), parsedRefreshResponseBody["token_type"].(string)) + require.Equal(t, parsedAuthcodeExchangeResponseBody["expires_in"].(float64), parsedRefreshResponseBody["expires_in"].(float64)) + require.Equal(t, parsedAuthcodeExchangeResponseBody["scope"].(string), parsedRefreshResponseBody["scope"].(string)) + } + }) + } +} + func exchangeAuthcodeForTokens(t *testing.T, test authcodeExchangeInputs) ( subject http.Handler, rsp *httptest.ResponseRecorder, authCode string, + jwtSigningKey *ecdsa.PrivateKey, secrets v1.SecretInterface, oauthStore *oidc.KubeStorage, ) { @@ -568,7 +743,6 @@ func exchangeAuthcodeForTokens(t *testing.T, test authcodeExchangeInputs) ( secrets = client.CoreV1().Secrets("some-namespace") var oauthHelper fosite.OAuth2Provider - var jwtSigningKey *ecdsa.PrivateKey oauthStore = oidc.NewKubeStorage(secrets) if test.makeOathHelper != nil { @@ -604,14 +778,32 @@ func exchangeAuthcodeForTokens(t *testing.T, test authcodeExchangeInputs) ( t.Logf("response: %#v", rsp) t.Logf("response body: %q", rsp.Body.String()) - testutil.RequireEqualContentType(t, rsp.Header().Get("Content-Type"), "application/json") - require.Equal(t, test.wantStatus, rsp.Code) + wantAtHashClaimInIDToken := false // due to a bug in fosite, the at_hash claim is not filled in during authcode exchange + wantNonceValueInIDToken := true // ID tokens returned by the authcode exchange must include the nonce from the auth request (unliked refreshed ID tokens) + requireTokenEndpointBehavior(t, test.want, wantAtHashClaimInIDToken, wantNonceValueInIDToken, rsp, authCode, oauthStore, jwtSigningKey, secrets) + + return subject, rsp, authCode, jwtSigningKey, secrets, oauthStore +} + +func requireTokenEndpointBehavior( + t *testing.T, + test tokenEndpointResponseExpectedValues, + wantAtHashClaimInIDToken bool, + wantNonceValueInIDToken bool, + tokenEndpointResponse *httptest.ResponseRecorder, + authCode string, + oauthStore *oidc.KubeStorage, + jwtSigningKey *ecdsa.PrivateKey, + secrets v1.SecretInterface, +) { + testutil.RequireEqualContentType(t, tokenEndpointResponse.Header().Get("Content-Type"), "application/json") + require.Equal(t, test.wantStatus, tokenEndpointResponse.Code) if test.wantStatus == http.StatusOK { require.NotNil(t, test.wantSuccessBodyFields, "problem with test table setup: wanted success but did not specify expected response body") var parsedResponseBody map[string]interface{} - require.NoError(t, json.Unmarshal(rsp.Body.Bytes(), &parsedResponseBody)) + require.NoError(t, json.Unmarshal(tokenEndpointResponse.Body.Bytes(), &parsedResponseBody)) require.ElementsMatch(t, test.wantSuccessBodyFields, getMapKeys(parsedResponseBody)) wantIDToken := contains(test.wantSuccessBodyFields, "id_token") @@ -626,10 +818,10 @@ func exchangeAuthcodeForTokens(t *testing.T, test authcodeExchangeInputs) ( if wantRefreshToken { expectedNumberOfRefreshTokenSessionsStored = 1 } - expectedNumberOfIDSessionsStored = 0 + expectedNumberOfIDSessionsStored := 0 if wantIDToken { expectedNumberOfIDSessionsStored = 1 - requireValidIDToken(t, parsedResponseBody, jwtSigningKey) + requireValidIDToken(t, parsedResponseBody, jwtSigningKey, wantAtHashClaimInIDToken, wantNonceValueInIDToken, parsedResponseBody["access_token"].(string)) } if wantRefreshToken { requireValidRefreshTokenStorage(t, parsedResponseBody, oauthStore, test.wantRequestedScopes, test.wantGrantedScopes) @@ -644,10 +836,18 @@ func exchangeAuthcodeForTokens(t *testing.T, test authcodeExchangeInputs) ( } else { require.NotNil(t, test.wantErrorResponseBody, "problem with test table setup: wanted failure but did not specify failure response body") - require.JSONEq(t, test.wantErrorResponseBody, rsp.Body.String()) + require.JSONEq(t, test.wantErrorResponseBody, tokenEndpointResponse.Body.String()) } +} - return subject, rsp, authCode, secrets, oauthStore +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 + // the hash of the octets of the ASCII representation of the access_token value, where the + // hash algorithm used is the hash algorithm used in the alg Header Parameter of the ID + // Token's JOSE Header." + b := sha256.Sum256([]byte(accessToken)) + return base64.RawURLEncoding.EncodeToString(b[:len(b)/2]) } type body url.Values @@ -855,7 +1055,7 @@ func requireValidAccessTokenStorage( require.True(t, ok) expiresInNumber, ok := expiresIn.(float64) // Go unmarshals JSON numbers to float64, see `go doc encoding/json` require.Truef(t, ok, "wanted expires_in to be an float64, but got %T", expiresIn) - require.InDelta(t, accessTokenExpirationSeconds, expiresInNumber, timeComparisonFudgeSeconds) + require.InDelta(t, accessTokenExpirationSeconds, expiresInNumber, 2) // "expires_in" is a number of seconds, not a timestamp scopes, ok := body["scope"] require.True(t, ok) @@ -1029,7 +1229,14 @@ func requireValidStoredRequest( require.Equal(t, goodSubject, session.Subject) } -func requireValidIDToken(t *testing.T, body map[string]interface{}, jwtSigningKey *ecdsa.PrivateKey) { +func requireValidIDToken( + t *testing.T, + body map[string]interface{}, + jwtSigningKey *ecdsa.PrivateKey, + wantAtHashClaimInIDToken bool, + wantNonceValueInIDToken bool, + actualAccessToken string, +) { t.Helper() idToken, ok := body["id_token"] @@ -1053,9 +1260,13 @@ func requireValidIDToken(t *testing.T, body map[string]interface{}, jwtSigningKe AuthTime int64 `json:"auth_time"` } - // Note that there is a bug in fosite which prevents the `at_hash` claim from appearing in this ID token. + // Note that there is a bug in fosite which prevents the `at_hash` claim from appearing in this ID token + // during the initial authcode exchange, but does not prevent `at_hash` from appearing in the refreshed ID token. // We can add a workaround for this later. idTokenFields := []string{"sub", "aud", "iss", "jti", "nonce", "auth_time", "exp", "iat", "rat"} + if wantAtHashClaimInIDToken { + idTokenFields = append(idTokenFields, "at_hash") + } // make sure that these are the only fields in the token var m map[string]interface{} @@ -1070,7 +1281,12 @@ func requireValidIDToken(t *testing.T, body map[string]interface{}, jwtSigningKe require.Equal(t, goodClient, claims.Audience[0]) require.Equal(t, goodIssuer, claims.Issuer) require.NotEmpty(t, claims.JTI) - require.Equal(t, goodNonce, claims.Nonce) + + if wantNonceValueInIDToken { + require.Equal(t, goodNonce, claims.Nonce) + } else { + require.Empty(t, claims.Nonce) + } expiresAt := time.Unix(claims.ExpiresAt, 0) issuedAt := time.Unix(claims.IssuedAt, 0) @@ -1080,6 +1296,13 @@ func requireValidIDToken(t *testing.T, body map[string]interface{}, jwtSigningKe testutil.RequireTimeInDelta(t, time.Now().UTC(), issuedAt, timeComparisonFudgeSeconds*time.Second) testutil.RequireTimeInDelta(t, goodRequestedAtTime, requestedAt, timeComparisonFudgeSeconds*time.Second) testutil.RequireTimeInDelta(t, goodAuthTime, authTime, timeComparisonFudgeSeconds*time.Second) + + if wantAtHashClaimInIDToken { + require.NotEmpty(t, actualAccessToken) + require.Equal(t, hashAccessToken(actualAccessToken), claims.AccessTokenHash) + } else { + require.Empty(t, claims.AccessTokenHash) + } } func deepCopyRequestForm(r *http.Request) *http.Request {