Merge branch 'token-refresh' of github.com:vmware-tanzu/pinniped into token-exchange-endpoint

This commit is contained in:
Matt Moyer 2020-12-09 14:46:57 -06:00
commit f90b5d48de
No known key found for this signature in database
GPG Key ID: EAE88AD172C5AE2D
2 changed files with 378 additions and 135 deletions

View File

@ -127,9 +127,9 @@ func FositeOauth2Helper(
}, },
nil, // hasher, defaults to using BCrypt when nil. Used for hashing client secrets. nil, // hasher, defaults to using BCrypt when nil. Used for hashing client secrets.
compose.OAuth2AuthorizeExplicitFactory, compose.OAuth2AuthorizeExplicitFactory,
// compose.OAuth2RefreshTokenGrantFactory, compose.OAuth2RefreshTokenGrantFactory,
compose.OpenIDConnectExplicitFactory, compose.OpenIDConnectExplicitFactory,
// compose.OpenIDConnectRefreshFactory, compose.OpenIDConnectRefreshFactory,
compose.OAuth2PKCEFactory, compose.OAuth2PKCEFactory,
TokenExchangeFactory, TokenExchangeFactory,
) )

View File

@ -8,6 +8,8 @@ import (
"crypto/ecdsa" "crypto/ecdsa"
"crypto/elliptic" "crypto/elliptic"
"crypto/rand" "crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json" "encoding/json"
"io" "io"
"io/ioutil" "io/ioutil"
@ -118,6 +120,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(` fositeMissingClientErrorBody = here.Doc(`
{ {
"error": "invalid_request", "error": "invalid_request",
@ -220,8 +232,27 @@ var (
}, },
} }
} }
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 { type authcodeExchangeInputs struct {
modifyAuthRequest func(authRequest *http.Request) modifyAuthRequest func(authRequest *http.Request)
modifyTokenRequest func(r *http.Request, authCode string) modifyTokenRequest func(r *http.Request, authCode string)
@ -248,11 +279,7 @@ type authcodeExchangeInputs struct {
}, },
) (fosite.OAuth2Provider, string, *ecdsa.PrivateKey) ) (fosite.OAuth2Provider, string, *ecdsa.PrivateKey)
wantStatus int want tokenEndpointResponseExpectedValues
wantSuccessBodyFields []string
wantErrorResponseBody string
wantRequestedScopes []string
wantGrantedScopes []string
} }
func TestTokenEndpoint(t *testing.T) { func TestTokenEndpoint(t *testing.T) {
@ -264,168 +291,226 @@ func TestTokenEndpoint(t *testing.T) {
{ {
name: "request is valid and tokens are issued", name: "request is valid and tokens are issued",
authcodeExchange: authcodeExchangeInputs{ authcodeExchange: authcodeExchangeInputs{
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusOK, wantStatus: http.StatusOK,
wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "scope", "expires_in"}, // no refresh token wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "scope", "expires_in"}, // no refresh token
wantRequestedScopes: []string{"openid", "profile", "email"}, wantRequestedScopes: []string{"openid", "profile", "email"},
wantGrantedScopes: []string{"openid"}, wantGrantedScopes: []string{"openid"},
}, },
}, },
},
{ {
name: "openid scope was not requested from authorize endpoint", name: "openid scope was not requested from authorize endpoint",
authcodeExchange: authcodeExchangeInputs{ authcodeExchange: authcodeExchangeInputs{
modifyAuthRequest: func(authRequest *http.Request) { modifyAuthRequest: func(authRequest *http.Request) {
authRequest.Form.Set("scope", "profile email") authRequest.Form.Set("scope", "profile email")
}, },
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusOK, wantStatus: http.StatusOK,
wantSuccessBodyFields: []string{"access_token", "token_type", "scope", "expires_in"}, // no id or refresh tokens wantSuccessBodyFields: []string{"access_token", "token_type", "scope", "expires_in"}, // no id or refresh tokens
wantRequestedScopes: []string{"profile", "email"}, wantRequestedScopes: []string{"profile", "email"},
wantGrantedScopes: []string{}, wantGrantedScopes: []string{},
}, },
}, },
},
{ {
name: "offline_access and openid scopes were requested and granted from authorize endpoint", name: "offline_access and openid scopes were requested and granted from authorize endpoint",
authcodeExchange: authcodeExchangeInputs{ authcodeExchange: authcodeExchangeInputs{
modifyAuthRequest: func(authRequest *http.Request) { modifyAuthRequest: func(authRequest *http.Request) {
authRequest.Form.Set("scope", "openid offline_access") authRequest.Form.Set("scope", "openid offline_access")
}, },
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusOK, wantStatus: http.StatusOK,
wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "scope", "expires_in", "refresh_token"}, // all possible tokens wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "scope", "expires_in", "refresh_token"}, // all possible tokens
wantRequestedScopes: []string{"openid", "offline_access"}, wantRequestedScopes: []string{"openid", "offline_access"},
wantGrantedScopes: []string{"openid", "offline_access"}, wantGrantedScopes: []string{"openid", "offline_access"},
}, },
}, },
},
{ {
name: "offline_access (without openid scope) was requested and granted from authorize endpoint", name: "offline_access (without openid scope) was requested and granted from authorize endpoint",
authcodeExchange: authcodeExchangeInputs{ authcodeExchange: authcodeExchangeInputs{
modifyAuthRequest: func(authRequest *http.Request) { modifyAuthRequest: func(authRequest *http.Request) {
authRequest.Form.Set("scope", "offline_access") authRequest.Form.Set("scope", "offline_access")
}, },
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusOK, wantStatus: http.StatusOK,
wantSuccessBodyFields: []string{"access_token", "token_type", "scope", "expires_in", "refresh_token"}, // no id token wantSuccessBodyFields: []string{"access_token", "token_type", "scope", "expires_in", "refresh_token"}, // no id token
wantRequestedScopes: []string{"offline_access"}, wantRequestedScopes: []string{"offline_access"},
wantGrantedScopes: []string{"offline_access"}, wantGrantedScopes: []string{"offline_access"},
}, },
}, },
},
// sad path // sad path
{ {
name: "GET method is wrong", name: "GET method is wrong",
authcodeExchange: authcodeExchangeInputs{ authcodeExchange: authcodeExchangeInputs{
modifyTokenRequest: func(r *http.Request, authCode string) { r.Method = http.MethodGet }, modifyTokenRequest: func(r *http.Request, authCode string) { r.Method = http.MethodGet },
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusBadRequest, wantStatus: http.StatusBadRequest,
wantErrorResponseBody: fositeInvalidMethodErrorBody("GET"), wantErrorResponseBody: fositeInvalidMethodErrorBody("GET"),
}, },
}, },
},
{ {
name: "PUT method is wrong", name: "PUT method is wrong",
authcodeExchange: authcodeExchangeInputs{ authcodeExchange: authcodeExchangeInputs{
modifyTokenRequest: func(r *http.Request, authCode string) { r.Method = http.MethodPut }, modifyTokenRequest: func(r *http.Request, authCode string) { r.Method = http.MethodPut },
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusBadRequest, wantStatus: http.StatusBadRequest,
wantErrorResponseBody: fositeInvalidMethodErrorBody("PUT"), wantErrorResponseBody: fositeInvalidMethodErrorBody("PUT"),
}, },
}, },
},
{ {
name: "PATCH method is wrong", name: "PATCH method is wrong",
authcodeExchange: authcodeExchangeInputs{ authcodeExchange: authcodeExchangeInputs{
modifyTokenRequest: func(r *http.Request, authCode string) { r.Method = http.MethodPatch }, modifyTokenRequest: func(r *http.Request, authCode string) { r.Method = http.MethodPatch },
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusBadRequest, wantStatus: http.StatusBadRequest,
wantErrorResponseBody: fositeInvalidMethodErrorBody("PATCH"), wantErrorResponseBody: fositeInvalidMethodErrorBody("PATCH"),
}, },
}, },
},
{ {
name: "DELETE method is wrong", name: "DELETE method is wrong",
authcodeExchange: authcodeExchangeInputs{ authcodeExchange: authcodeExchangeInputs{
modifyTokenRequest: func(r *http.Request, authCode string) { r.Method = http.MethodDelete }, modifyTokenRequest: func(r *http.Request, authCode string) { r.Method = http.MethodDelete },
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusBadRequest, wantStatus: http.StatusBadRequest,
wantErrorResponseBody: fositeInvalidMethodErrorBody("DELETE"), wantErrorResponseBody: fositeInvalidMethodErrorBody("DELETE"),
}, },
}, },
},
{ {
name: "content type is invalid", name: "content type is invalid",
authcodeExchange: authcodeExchangeInputs{ authcodeExchange: authcodeExchangeInputs{
modifyTokenRequest: func(r *http.Request, authCode string) { r.Header.Set("Content-Type", "text/plain") }, modifyTokenRequest: func(r *http.Request, authCode string) { r.Header.Set("Content-Type", "text/plain") },
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusBadRequest, wantStatus: http.StatusBadRequest,
wantErrorResponseBody: fositeEmptyPayloadErrorBody, wantErrorResponseBody: fositeEmptyPayloadErrorBody,
}, },
}, },
},
{ {
name: "payload is not valid form serialization", name: "payload is not valid form serialization",
authcodeExchange: authcodeExchangeInputs{ authcodeExchange: authcodeExchangeInputs{
modifyTokenRequest: func(r *http.Request, authCode string) { modifyTokenRequest: func(r *http.Request, authCode string) {
r.Body = ioutil.NopCloser(strings.NewReader("this newline character is not allowed in a form serialization: \n")) r.Body = ioutil.NopCloser(strings.NewReader("this newline character is not allowed in a form serialization: \n"))
}, },
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusBadRequest, wantStatus: http.StatusBadRequest,
wantErrorResponseBody: fositeMissingGrantTypeErrorBody, wantErrorResponseBody: fositeMissingGrantTypeErrorBody,
}, },
}, },
},
{ {
name: "payload is empty", name: "payload is empty",
authcodeExchange: authcodeExchangeInputs{ authcodeExchange: authcodeExchangeInputs{
modifyTokenRequest: func(r *http.Request, authCode string) { r.Body = nil }, modifyTokenRequest: func(r *http.Request, authCode string) { r.Body = nil },
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusBadRequest, wantStatus: http.StatusBadRequest,
wantErrorResponseBody: fositeInvalidPayloadErrorBody, wantErrorResponseBody: fositeInvalidPayloadErrorBody,
}, },
}, },
},
{ {
name: "grant type is missing in request", name: "grant type is missing in request",
authcodeExchange: authcodeExchangeInputs{ authcodeExchange: authcodeExchangeInputs{
modifyTokenRequest: func(r *http.Request, authCode string) { modifyTokenRequest: func(r *http.Request, authCode string) {
r.Body = happyBody(authCode).WithGrantType("").ReadCloser() r.Body = happyBody(authCode).WithGrantType("").ReadCloser()
}, },
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusBadRequest, wantStatus: http.StatusBadRequest,
wantErrorResponseBody: fositeMissingGrantTypeErrorBody, wantErrorResponseBody: fositeMissingGrantTypeErrorBody,
}, },
}, },
},
{ {
name: "grant type is not authorization_code", name: "grant type is not authorization_code",
authcodeExchange: authcodeExchangeInputs{ authcodeExchange: authcodeExchangeInputs{
modifyTokenRequest: func(r *http.Request, authCode string) { modifyTokenRequest: func(r *http.Request, authCode string) {
r.Body = happyBody(authCode).WithGrantType("bogus").ReadCloser() r.Body = happyBody(authCode).WithGrantType("bogus").ReadCloser()
}, },
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusBadRequest, wantStatus: http.StatusBadRequest,
wantErrorResponseBody: fositeInvalidRequestErrorBody, wantErrorResponseBody: fositeInvalidRequestErrorBody,
}, },
}, },
},
{ {
name: "client id is missing in request", name: "client id is missing in request",
authcodeExchange: authcodeExchangeInputs{ authcodeExchange: authcodeExchangeInputs{
modifyTokenRequest: func(r *http.Request, authCode string) { modifyTokenRequest: func(r *http.Request, authCode string) {
r.Body = happyBody(authCode).WithClientID("").ReadCloser() r.Body = happyBody(authCode).WithClientID("").ReadCloser()
}, },
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusBadRequest, wantStatus: http.StatusBadRequest,
wantErrorResponseBody: fositeMissingClientErrorBody, wantErrorResponseBody: fositeMissingClientErrorBody,
}, },
}, },
},
{ {
name: "client id is wrong", name: "client id is wrong",
authcodeExchange: authcodeExchangeInputs{ authcodeExchange: authcodeExchangeInputs{
modifyTokenRequest: func(r *http.Request, authCode string) { modifyTokenRequest: func(r *http.Request, authCode string) {
r.Body = happyBody(authCode).WithClientID("bogus").ReadCloser() r.Body = happyBody(authCode).WithClientID("bogus").ReadCloser()
}, },
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusUnauthorized, wantStatus: http.StatusUnauthorized,
wantErrorResponseBody: fositeInvalidClientErrorBody, 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,
},
},
},
{ {
name: "auth code is missing in request", name: "auth code is missing in request",
authcodeExchange: authcodeExchangeInputs{ authcodeExchange: authcodeExchangeInputs{
modifyTokenRequest: func(r *http.Request, authCode string) { modifyTokenRequest: func(r *http.Request, authCode string) {
r.Body = happyBody(authCode).WithAuthCode("").ReadCloser() r.Body = happyBody(authCode).WithAuthCode("").ReadCloser()
}, },
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusBadRequest, wantStatus: http.StatusBadRequest,
wantErrorResponseBody: fositeInvalidAuthCodeErrorBody, wantErrorResponseBody: fositeInvalidAuthCodeErrorBody,
}, },
}, },
},
{ {
name: "auth code has never been valid", name: "auth code has never been valid",
authcodeExchange: authcodeExchangeInputs{ authcodeExchange: authcodeExchangeInputs{
modifyTokenRequest: func(r *http.Request, authCode string) { modifyTokenRequest: func(r *http.Request, authCode string) {
r.Body = happyBody(authCode).WithAuthCode("bogus").ReadCloser() r.Body = happyBody(authCode).WithAuthCode("bogus").ReadCloser()
}, },
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusBadRequest, wantStatus: http.StatusBadRequest,
wantErrorResponseBody: fositeInvalidAuthCodeErrorBody, wantErrorResponseBody: fositeInvalidAuthCodeErrorBody,
}, },
}, },
},
{ {
name: "auth code is invalidated", name: "auth code is invalidated",
authcodeExchange: authcodeExchangeInputs{ authcodeExchange: authcodeExchangeInputs{
@ -443,40 +528,48 @@ func TestTokenEndpoint(t *testing.T) {
err := s.InvalidateAuthorizeCodeSession(context.Background(), getFositeDataSignature(t, authCode)) err := s.InvalidateAuthorizeCodeSession(context.Background(), getFositeDataSignature(t, authCode))
require.NoError(t, err) require.NoError(t, err)
}, },
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusBadRequest, wantStatus: http.StatusBadRequest,
wantErrorResponseBody: fositeReusedAuthCodeErrorBody, wantErrorResponseBody: fositeReusedAuthCodeErrorBody,
}, },
}, },
},
{ {
name: "redirect uri is missing in request", name: "redirect uri is missing in request",
authcodeExchange: authcodeExchangeInputs{ authcodeExchange: authcodeExchangeInputs{
modifyTokenRequest: func(r *http.Request, authCode string) { modifyTokenRequest: func(r *http.Request, authCode string) {
r.Body = happyBody(authCode).WithRedirectURI("").ReadCloser() r.Body = happyBody(authCode).WithRedirectURI("").ReadCloser()
}, },
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusBadRequest, wantStatus: http.StatusBadRequest,
wantErrorResponseBody: fositeInvalidRedirectURIErrorBody, wantErrorResponseBody: fositeInvalidRedirectURIErrorBody,
}, },
}, },
},
{ {
name: "redirect uri is wrong", name: "redirect uri is wrong",
authcodeExchange: authcodeExchangeInputs{ authcodeExchange: authcodeExchangeInputs{
modifyTokenRequest: func(r *http.Request, authCode string) { modifyTokenRequest: func(r *http.Request, authCode string) {
r.Body = happyBody(authCode).WithRedirectURI("bogus").ReadCloser() r.Body = happyBody(authCode).WithRedirectURI("bogus").ReadCloser()
}, },
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusBadRequest, wantStatus: http.StatusBadRequest,
wantErrorResponseBody: fositeInvalidRedirectURIErrorBody, wantErrorResponseBody: fositeInvalidRedirectURIErrorBody,
}, },
}, },
},
{ {
name: "pkce is missing in request", name: "pkce is missing in request",
authcodeExchange: authcodeExchangeInputs{ authcodeExchange: authcodeExchangeInputs{
modifyTokenRequest: func(r *http.Request, authCode string) { modifyTokenRequest: func(r *http.Request, authCode string) {
r.Body = happyBody(authCode).WithPKCE("").ReadCloser() r.Body = happyBody(authCode).WithPKCE("").ReadCloser()
}, },
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusBadRequest, wantStatus: http.StatusBadRequest,
wantErrorResponseBody: fositeMissingPKCEVerifierErrorBody, wantErrorResponseBody: fositeMissingPKCEVerifierErrorBody,
}, },
}, },
},
{ {
name: "pkce is wrong", name: "pkce is wrong",
authcodeExchange: authcodeExchangeInputs{ authcodeExchange: authcodeExchangeInputs{
@ -485,18 +578,22 @@ func TestTokenEndpoint(t *testing.T) {
"bogus-verifier-that-is-at-least-43-characters-for-the-sake-of-entropy", "bogus-verifier-that-is-at-least-43-characters-for-the-sake-of-entropy",
).ReadCloser() ).ReadCloser()
}, },
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusBadRequest, wantStatus: http.StatusBadRequest,
wantErrorResponseBody: fositeWrongPKCEVerifierErrorBody, wantErrorResponseBody: fositeWrongPKCEVerifierErrorBody,
}, },
}, },
},
{ {
name: "private signing key for JWTs has not yet been provided by the controller who is responsible for dynamically providing it", name: "private signing key for JWTs has not yet been provided by the controller who is responsible for dynamically providing it",
authcodeExchange: authcodeExchangeInputs{ authcodeExchange: authcodeExchangeInputs{
makeOathHelper: makeOauthHelperWithNilPrivateJWTSigningKey, makeOathHelper: makeOauthHelperWithNilPrivateJWTSigningKey,
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusServiceUnavailable, wantStatus: http.StatusServiceUnavailable,
wantErrorResponseBody: fositeTemporarilyUnavailableErrorBody, wantErrorResponseBody: fositeTemporarilyUnavailableErrorBody,
}, },
}, },
},
} }
for _, test := range tests { for _, test := range tests {
test := test test := test
@ -517,18 +614,20 @@ func TestTokenEndpointWhenAuthcodeIsUsedTwice(t *testing.T) {
modifyAuthRequest: func(authRequest *http.Request) { modifyAuthRequest: func(authRequest *http.Request) {
authRequest.Form.Set("scope", "openid offline_access profile email") authRequest.Form.Set("scope", "openid offline_access profile email")
}, },
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusOK, wantStatus: http.StatusOK,
wantSuccessBodyFields: []string{"id_token", "refresh_token", "access_token", "token_type", "expires_in", "scope"}, wantSuccessBodyFields: []string{"id_token", "refresh_token", "access_token", "token_type", "expires_in", "scope"},
wantRequestedScopes: []string{"openid", "offline_access", "profile", "email"}, wantRequestedScopes: []string{"openid", "offline_access", "profile", "email"},
wantGrantedScopes: []string{"openid", "offline_access"}, wantGrantedScopes: []string{"openid", "offline_access"},
}, },
}, },
},
} }
for _, test := range tests { for _, test := range tests {
test := test test := test
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
// First call - should be successful. // 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{} var parsedResponseBody map[string]interface{}
require.NoError(t, json.Unmarshal(rsp.Body.Bytes(), &parsedResponseBody)) require.NoError(t, json.Unmarshal(rsp.Body.Bytes(), &parsedResponseBody))
@ -538,13 +637,13 @@ func TestTokenEndpointWhenAuthcodeIsUsedTwice(t *testing.T) {
// delete the OIDC storage...but we probably should. // delete the OIDC storage...but we probably should.
req := httptest.NewRequest("POST", "/path/shouldn't/matter", happyBody(authCode).ReadCloser()) req := httptest.NewRequest("POST", "/path/shouldn't/matter", happyBody(authCode).ReadCloser())
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rsp1 := httptest.NewRecorder() reusedAuthcodeResponse := httptest.NewRecorder()
subject.ServeHTTP(rsp1, req) subject.ServeHTTP(reusedAuthcodeResponse, req)
t.Logf("second response: %#v", rsp1) t.Logf("second response: %#v", reusedAuthcodeResponse)
t.Logf("second response body: %q", rsp1.Body.String()) t.Logf("second response body: %q", reusedAuthcodeResponse.Body.String())
require.Equal(t, http.StatusBadRequest, rsp1.Code) require.Equal(t, http.StatusBadRequest, reusedAuthcodeResponse.Code)
testutil.RequireEqualContentType(t, rsp1.Header().Get("Content-Type"), "application/json") testutil.RequireEqualContentType(t, reusedAuthcodeResponse.Header().Get("Content-Type"), "application/json")
require.JSONEq(t, fositeReusedAuthCodeErrorBody, rsp1.Body.String()) require.JSONEq(t, fositeReusedAuthCodeErrorBody, reusedAuthcodeResponse.Body.String())
// This was previously invalidated by the first request, so it remains invalidated // This was previously invalidated by the first request, so it remains invalidated
requireInvalidAuthCodeStorage(t, authCode, oauthStore) requireInvalidAuthCodeStorage(t, authCode, oauthStore)
@ -553,7 +652,8 @@ func TestTokenEndpointWhenAuthcodeIsUsedTwice(t *testing.T) {
// This was previously invalidated by the first request, so it remains invalidated // This was previously invalidated by the first request, so it remains invalidated
requireInvalidPKCEStorage(t, authCode, oauthStore) requireInvalidPKCEStorage(t, authCode, oauthStore)
// Fosite never cleans up OpenID Connect session storage, so it is still there // 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. // 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) testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: authorizationcode.TypeLabelValue}, 1)
@ -584,11 +684,13 @@ func TestTokenExchange(t *testing.T) {
modifyAuthRequest: func(authRequest *http.Request) { modifyAuthRequest: func(authRequest *http.Request) {
authRequest.Form.Set("scope", "openid pinniped.sts.unrestricted") authRequest.Form.Set("scope", "openid pinniped.sts.unrestricted")
}, },
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusOK, wantStatus: http.StatusOK,
wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "expires_in", "scope"}, wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "expires_in", "scope"},
wantRequestedScopes: []string{"openid", "pinniped.sts.unrestricted"}, wantRequestedScopes: []string{"openid", "pinniped.sts.unrestricted"},
wantGrantedScopes: []string{"openid", "pinniped.sts.unrestricted"}, wantGrantedScopes: []string{"openid", "pinniped.sts.unrestricted"},
}, },
},
requestedAudience: "some-workload-cluster", requestedAudience: "some-workload-cluster",
wantStatus: http.StatusOK, wantStatus: http.StatusOK,
}, },
@ -598,11 +700,13 @@ func TestTokenExchange(t *testing.T) {
modifyAuthRequest: func(authRequest *http.Request) { modifyAuthRequest: func(authRequest *http.Request) {
authRequest.Form.Set("scope", "openid pinniped.sts.unrestricted") authRequest.Form.Set("scope", "openid pinniped.sts.unrestricted")
}, },
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusOK, wantStatus: http.StatusOK,
wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "expires_in", "scope"}, wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "expires_in", "scope"},
wantRequestedScopes: []string{"openid", "pinniped.sts.unrestricted"}, wantRequestedScopes: []string{"openid", "pinniped.sts.unrestricted"},
wantGrantedScopes: []string{"openid", "pinniped.sts.unrestricted"}, wantGrantedScopes: []string{"openid", "pinniped.sts.unrestricted"},
}, },
},
requestedAudience: "", requestedAudience: "",
wantStatus: http.StatusBadRequest, wantStatus: http.StatusBadRequest,
wantResponseBodyContains: "missing audience parameter", wantResponseBodyContains: "missing audience parameter",
@ -613,11 +717,13 @@ func TestTokenExchange(t *testing.T) {
modifyAuthRequest: func(authRequest *http.Request) { modifyAuthRequest: func(authRequest *http.Request) {
authRequest.Form.Set("scope", "openid pinniped.sts.unrestricted") authRequest.Form.Set("scope", "openid pinniped.sts.unrestricted")
}, },
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusOK, wantStatus: http.StatusOK,
wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "expires_in", "scope"}, wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "expires_in", "scope"},
wantRequestedScopes: []string{"openid", "pinniped.sts.unrestricted"}, wantRequestedScopes: []string{"openid", "pinniped.sts.unrestricted"},
wantGrantedScopes: []string{"openid", "pinniped.sts.unrestricted"}, wantGrantedScopes: []string{"openid", "pinniped.sts.unrestricted"},
}, },
},
requestedAudience: "some-workload-cluster", requestedAudience: "some-workload-cluster",
modifyRequestParams: func(t *testing.T, params url.Values) { modifyRequestParams: func(t *testing.T, params url.Values) {
params.Del("subject_token") params.Del("subject_token")
@ -631,11 +737,13 @@ func TestTokenExchange(t *testing.T) {
modifyAuthRequest: func(authRequest *http.Request) { modifyAuthRequest: func(authRequest *http.Request) {
authRequest.Form.Set("scope", "openid pinniped.sts.unrestricted") authRequest.Form.Set("scope", "openid pinniped.sts.unrestricted")
}, },
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusOK, wantStatus: http.StatusOK,
wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "expires_in", "scope"}, wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "expires_in", "scope"},
wantRequestedScopes: []string{"openid", "pinniped.sts.unrestricted"}, wantRequestedScopes: []string{"openid", "pinniped.sts.unrestricted"},
wantGrantedScopes: []string{"openid", "pinniped.sts.unrestricted"}, wantGrantedScopes: []string{"openid", "pinniped.sts.unrestricted"},
}, },
},
requestedAudience: "some-workload-cluster", requestedAudience: "some-workload-cluster",
modifyRequestParams: func(t *testing.T, params url.Values) { modifyRequestParams: func(t *testing.T, params url.Values) {
params.Set("subject_token_type", "invalid") params.Set("subject_token_type", "invalid")
@ -649,11 +757,13 @@ func TestTokenExchange(t *testing.T) {
modifyAuthRequest: func(authRequest *http.Request) { modifyAuthRequest: func(authRequest *http.Request) {
authRequest.Form.Set("scope", "openid pinniped.sts.unrestricted") authRequest.Form.Set("scope", "openid pinniped.sts.unrestricted")
}, },
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusOK, wantStatus: http.StatusOK,
wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "expires_in", "scope"}, wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "expires_in", "scope"},
wantRequestedScopes: []string{"openid", "pinniped.sts.unrestricted"}, wantRequestedScopes: []string{"openid", "pinniped.sts.unrestricted"},
wantGrantedScopes: []string{"openid", "pinniped.sts.unrestricted"}, wantGrantedScopes: []string{"openid", "pinniped.sts.unrestricted"},
}, },
},
requestedAudience: "some-workload-cluster", requestedAudience: "some-workload-cluster",
modifyRequestParams: func(t *testing.T, params url.Values) { modifyRequestParams: func(t *testing.T, params url.Values) {
params.Set("requested_token_type", "invalid") params.Set("requested_token_type", "invalid")
@ -667,11 +777,13 @@ func TestTokenExchange(t *testing.T) {
modifyAuthRequest: func(authRequest *http.Request) { modifyAuthRequest: func(authRequest *http.Request) {
authRequest.Form.Set("scope", "openid pinniped.sts.unrestricted") authRequest.Form.Set("scope", "openid pinniped.sts.unrestricted")
}, },
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusOK, wantStatus: http.StatusOK,
wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "expires_in", "scope"}, wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "expires_in", "scope"},
wantRequestedScopes: []string{"openid", "pinniped.sts.unrestricted"}, wantRequestedScopes: []string{"openid", "pinniped.sts.unrestricted"},
wantGrantedScopes: []string{"openid", "pinniped.sts.unrestricted"}, wantGrantedScopes: []string{"openid", "pinniped.sts.unrestricted"},
}, },
},
requestedAudience: "some-workload-cluster", requestedAudience: "some-workload-cluster",
modifyRequestParams: func(t *testing.T, params url.Values) { modifyRequestParams: func(t *testing.T, params url.Values) {
params.Set("resource", "some-resource-parameter-value") params.Set("resource", "some-resource-parameter-value")
@ -685,11 +797,13 @@ func TestTokenExchange(t *testing.T) {
modifyAuthRequest: func(authRequest *http.Request) { modifyAuthRequest: func(authRequest *http.Request) {
authRequest.Form.Set("scope", "openid pinniped.sts.unrestricted") authRequest.Form.Set("scope", "openid pinniped.sts.unrestricted")
}, },
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusOK, wantStatus: http.StatusOK,
wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "expires_in", "scope"}, wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "expires_in", "scope"},
wantRequestedScopes: []string{"openid", "pinniped.sts.unrestricted"}, wantRequestedScopes: []string{"openid", "pinniped.sts.unrestricted"},
wantGrantedScopes: []string{"openid", "pinniped.sts.unrestricted"}, wantGrantedScopes: []string{"openid", "pinniped.sts.unrestricted"},
}, },
},
requestedAudience: "some-workload-cluster", requestedAudience: "some-workload-cluster",
modifyRequestParams: func(t *testing.T, params url.Values) { modifyRequestParams: func(t *testing.T, params url.Values) {
params.Set("subject_token", "some-bogus-value") params.Set("subject_token", "some-bogus-value")
@ -703,11 +817,13 @@ func TestTokenExchange(t *testing.T) {
modifyAuthRequest: func(authRequest *http.Request) { modifyAuthRequest: func(authRequest *http.Request) {
authRequest.Form.Set("scope", "openid pinniped.sts.unrestricted") authRequest.Form.Set("scope", "openid pinniped.sts.unrestricted")
}, },
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusOK, wantStatus: http.StatusOK,
wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "expires_in", "scope"}, wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "expires_in", "scope"},
wantRequestedScopes: []string{"openid", "pinniped.sts.unrestricted"}, wantRequestedScopes: []string{"openid", "pinniped.sts.unrestricted"},
wantGrantedScopes: []string{"openid", "pinniped.sts.unrestricted"}, wantGrantedScopes: []string{"openid", "pinniped.sts.unrestricted"},
}, },
},
requestedAudience: "some-workload-cluster", requestedAudience: "some-workload-cluster",
modifyStorage: func(t *testing.T, storage *oidc.KubeStorage, pendingRequest *http.Request) { modifyStorage: func(t *testing.T, storage *oidc.KubeStorage, pendingRequest *http.Request) {
parts := strings.Split(pendingRequest.Form.Get("subject_token"), ".") parts := strings.Split(pendingRequest.Form.Get("subject_token"), ".")
@ -723,11 +839,13 @@ func TestTokenExchange(t *testing.T) {
modifyAuthRequest: func(authRequest *http.Request) { modifyAuthRequest: func(authRequest *http.Request) {
authRequest.Form.Set("scope", "openid") authRequest.Form.Set("scope", "openid")
}, },
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusOK, wantStatus: http.StatusOK,
wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "expires_in", "scope"}, wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "expires_in", "scope"},
wantRequestedScopes: []string{"openid"}, wantRequestedScopes: []string{"openid"},
wantGrantedScopes: []string{"openid"}, wantGrantedScopes: []string{"openid"},
}, },
},
requestedAudience: "some-workload-cluster", requestedAudience: "some-workload-cluster",
wantStatus: http.StatusForbidden, wantStatus: http.StatusForbidden,
wantResponseBodyContains: `missing the \"pinniped.sts.unrestricted\" scope`, wantResponseBodyContains: `missing the \"pinniped.sts.unrestricted\" scope`,
@ -740,11 +858,13 @@ func TestTokenExchange(t *testing.T) {
}, },
// Fail to fetch a JWK signing key after the authcode exchange has happened. // Fail to fetch a JWK signing key after the authcode exchange has happened.
makeOathHelper: makeOauthHelperWithJWTKeyThatWorksOnlyOnce, makeOathHelper: makeOauthHelperWithJWTKeyThatWorksOnlyOnce,
want: tokenEndpointResponseExpectedValues{
wantStatus: http.StatusOK, wantStatus: http.StatusOK,
wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "expires_in", "scope"}, wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "expires_in", "scope"},
wantRequestedScopes: []string{"openid", "pinniped.sts.unrestricted"}, wantRequestedScopes: []string{"openid", "pinniped.sts.unrestricted"},
wantGrantedScopes: []string{"openid", "pinniped.sts.unrestricted"}, wantGrantedScopes: []string{"openid", "pinniped.sts.unrestricted"},
}, },
},
requestedAudience: "some-workload-cluster", requestedAudience: "some-workload-cluster",
wantStatus: http.StatusServiceUnavailable, wantStatus: http.StatusServiceUnavailable,
wantResponseBodyContains: `The authorization server is currently unable to handle the request`, wantResponseBodyContains: `The authorization server is currently unable to handle the request`,
@ -753,7 +873,7 @@ func TestTokenExchange(t *testing.T) {
for _, test := range tests { for _, test := range tests {
test := test test := test
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
subject, rsp, _, secrets, storage := exchangeAuthcodeForTokens(t, test.authcodeExchange) subject, rsp, _, _, secrets, storage := exchangeAuthcodeForTokens(t, test.authcodeExchange)
var parsedResponseBody map[string]interface{} var parsedResponseBody map[string]interface{}
require.NoError(t, json.Unmarshal(rsp.Body.Bytes(), &parsedResponseBody)) require.NoError(t, json.Unmarshal(rsp.Body.Bytes(), &parsedResponseBody))
@ -816,10 +936,85 @@ func TestTokenExchange(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) ( func exchangeAuthcodeForTokens(t *testing.T, test authcodeExchangeInputs) (
subject http.Handler, subject http.Handler,
rsp *httptest.ResponseRecorder, rsp *httptest.ResponseRecorder,
authCode string, authCode string,
jwtSigningKey *ecdsa.PrivateKey,
secrets v1.SecretInterface, secrets v1.SecretInterface,
oauthStore *oidc.KubeStorage, oauthStore *oidc.KubeStorage,
) { ) {
@ -832,7 +1027,6 @@ func exchangeAuthcodeForTokens(t *testing.T, test authcodeExchangeInputs) (
secrets = client.CoreV1().Secrets("some-namespace") secrets = client.CoreV1().Secrets("some-namespace")
var oauthHelper fosite.OAuth2Provider var oauthHelper fosite.OAuth2Provider
var jwtSigningKey *ecdsa.PrivateKey
oauthStore = oidc.NewKubeStorage(secrets) oauthStore = oidc.NewKubeStorage(secrets)
if test.makeOathHelper != nil { if test.makeOathHelper != nil {
@ -868,14 +1062,32 @@ func exchangeAuthcodeForTokens(t *testing.T, test authcodeExchangeInputs) (
t.Logf("response: %#v", rsp) t.Logf("response: %#v", rsp)
t.Logf("response body: %q", rsp.Body.String()) t.Logf("response body: %q", rsp.Body.String())
testutil.RequireEqualContentType(t, rsp.Header().Get("Content-Type"), "application/json") wantAtHashClaimInIDToken := false // due to a bug in fosite, the at_hash claim is not filled in during authcode exchange
require.Equal(t, test.wantStatus, rsp.Code) 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 { if test.wantStatus == http.StatusOK {
require.NotNil(t, test.wantSuccessBodyFields, "problem with test table setup: wanted success but did not specify expected response body") require.NotNil(t, test.wantSuccessBodyFields, "problem with test table setup: wanted success but did not specify expected response body")
var parsedResponseBody map[string]interface{} 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)) require.ElementsMatch(t, test.wantSuccessBodyFields, getMapKeys(parsedResponseBody))
wantIDToken := contains(test.wantSuccessBodyFields, "id_token") wantIDToken := contains(test.wantSuccessBodyFields, "id_token")
@ -890,10 +1102,10 @@ func exchangeAuthcodeForTokens(t *testing.T, test authcodeExchangeInputs) (
if wantRefreshToken { if wantRefreshToken {
expectedNumberOfRefreshTokenSessionsStored = 1 expectedNumberOfRefreshTokenSessionsStored = 1
} }
expectedNumberOfIDSessionsStored = 0 expectedNumberOfIDSessionsStored := 0
if wantIDToken { if wantIDToken {
expectedNumberOfIDSessionsStored = 1 expectedNumberOfIDSessionsStored = 1
requireValidIDToken(t, parsedResponseBody, jwtSigningKey) requireValidIDToken(t, parsedResponseBody, jwtSigningKey, wantAtHashClaimInIDToken, wantNonceValueInIDToken, parsedResponseBody["access_token"].(string))
} }
if wantRefreshToken { if wantRefreshToken {
requireValidRefreshTokenStorage(t, parsedResponseBody, oauthStore, test.wantRequestedScopes, test.wantGrantedScopes) requireValidRefreshTokenStorage(t, parsedResponseBody, oauthStore, test.wantRequestedScopes, test.wantGrantedScopes)
@ -908,10 +1120,18 @@ func exchangeAuthcodeForTokens(t *testing.T, test authcodeExchangeInputs) (
} else { } else {
require.NotNil(t, test.wantErrorResponseBody, "problem with test table setup: wanted failure but did not specify failure response body") 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 type body url.Values
@ -1154,7 +1374,7 @@ func requireValidAccessTokenStorage(
require.True(t, ok) require.True(t, ok)
expiresInNumber, ok := expiresIn.(float64) // Go unmarshals JSON numbers to float64, see `go doc encoding/json` 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.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"] scopes, ok := body["scope"]
require.True(t, ok) require.True(t, ok)
@ -1328,7 +1548,14 @@ func requireValidStoredRequest(
require.Equal(t, goodSubject, session.Subject) 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() t.Helper()
idToken, ok := body["id_token"] idToken, ok := body["id_token"]
@ -1352,9 +1579,13 @@ func requireValidIDToken(t *testing.T, body map[string]interface{}, jwtSigningKe
AuthTime int64 `json:"auth_time"` 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. // We can add a workaround for this later.
idTokenFields := []string{"sub", "aud", "iss", "jti", "nonce", "auth_time", "exp", "iat", "rat"} 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 // make sure that these are the only fields in the token
var m map[string]interface{} var m map[string]interface{}
@ -1369,7 +1600,12 @@ func requireValidIDToken(t *testing.T, body map[string]interface{}, jwtSigningKe
require.Equal(t, goodClient, claims.Audience[0]) require.Equal(t, goodClient, claims.Audience[0])
require.Equal(t, goodIssuer, claims.Issuer) require.Equal(t, goodIssuer, claims.Issuer)
require.NotEmpty(t, claims.JTI) require.NotEmpty(t, claims.JTI)
if wantNonceValueInIDToken {
require.Equal(t, goodNonce, claims.Nonce) require.Equal(t, goodNonce, claims.Nonce)
} else {
require.Empty(t, claims.Nonce)
}
expiresAt := time.Unix(claims.ExpiresAt, 0) expiresAt := time.Unix(claims.ExpiresAt, 0)
issuedAt := time.Unix(claims.IssuedAt, 0) issuedAt := time.Unix(claims.IssuedAt, 0)
@ -1379,6 +1615,13 @@ func requireValidIDToken(t *testing.T, body map[string]interface{}, jwtSigningKe
testutil.RequireTimeInDelta(t, time.Now().UTC(), issuedAt, timeComparisonFudgeSeconds*time.Second) testutil.RequireTimeInDelta(t, time.Now().UTC(), issuedAt, timeComparisonFudgeSeconds*time.Second)
testutil.RequireTimeInDelta(t, goodRequestedAtTime, requestedAt, timeComparisonFudgeSeconds*time.Second) testutil.RequireTimeInDelta(t, goodRequestedAtTime, requestedAt, timeComparisonFudgeSeconds*time.Second)
testutil.RequireTimeInDelta(t, goodAuthTime, authTime, 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 { func deepCopyRequestForm(r *http.Request) *http.Request {