Merge remote-tracking branch 'origin/token-refresh' into token-exchange-endpoint

This commit is contained in:
Margo Crawford 2020-12-08 16:58:35 -08:00
commit ef3f837800

View File

@ -28,6 +28,7 @@ import (
"gopkg.in/square/go-jose.v2" "gopkg.in/square/go-jose.v2"
"k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/labels"
"k8s.io/client-go/kubernetes/fake" "k8s.io/client-go/kubernetes/fake"
v1 "k8s.io/client-go/kubernetes/typed/core/v1"
"go.pinniped.dev/internal/crud" "go.pinniped.dev/internal/crud"
"go.pinniped.dev/internal/fositestorage/accesstoken" "go.pinniped.dev/internal/fositestorage/accesstoken"
@ -192,10 +193,8 @@ var (
"status_code": 503 "status_code": 503
} }
`) `)
)
func TestTokenEndpoint(t *testing.T) { happyAuthRequest = &http.Request{
happyAuthRequest := &http.Request{
Form: url.Values{ Form: url.Values{
"response_type": {"code"}, "response_type": {"code"},
"scope": {"openid profile email"}, "scope": {"openid profile email"},
@ -207,399 +206,442 @@ func TestTokenEndpoint(t *testing.T) {
"redirect_uri": {goodRedirectURI}, "redirect_uri": {goodRedirectURI},
}, },
} }
)
type authcodeExchangeInputs struct {
modifyAuthRequest func(authRequest *http.Request)
modifyTokenRequest func(r *http.Request, authCode string)
modifyStorage func(
t *testing.T,
s interface {
oauth2.TokenRevocationStorage
oauth2.CoreStorage
openid.OpenIDConnectRequestStorage
pkce.PKCERequestStorage
fosite.ClientManager
},
authCode string,
)
makeOathHelper func(
t *testing.T,
authRequest *http.Request,
store interface {
oauth2.TokenRevocationStorage
oauth2.CoreStorage
openid.OpenIDConnectRequestStorage
pkce.PKCERequestStorage
fosite.ClientManager
},
) (fosite.OAuth2Provider, string, *ecdsa.PrivateKey)
wantStatus int
wantBodyFields []string
wantRequestedScopes []string
wantExactBody string
}
func TestTokenEndpoint(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
authcodeExchange authcodeExchangeInputs
modifyAuthRequest func(authRequest *http.Request)
modifyTokenRequest func(r *http.Request, authCode string)
modifyStorage func(
t *testing.T,
s interface {
oauth2.TokenRevocationStorage
oauth2.CoreStorage
openid.OpenIDConnectRequestStorage
pkce.PKCERequestStorage
fosite.ClientManager
},
authCode string,
)
makeOathHelper func(
t *testing.T,
authRequest *http.Request,
store interface {
oauth2.TokenRevocationStorage
oauth2.CoreStorage
openid.OpenIDConnectRequestStorage
pkce.PKCERequestStorage
fosite.ClientManager
},
) (fosite.OAuth2Provider, string, *ecdsa.PrivateKey)
wantStatus int
wantBodyFields []string
wantExactBody string
wantRequestedScopes []string
}{ }{
// happy path // happy path
{ {
name: "request is valid and tokens are issued", name: "request is valid and tokens are issued",
wantStatus: http.StatusOK, authcodeExchange: authcodeExchangeInputs{
wantBodyFields: []string{"id_token", "access_token", "token_type", "scope", "expires_in"}, // no refresh token wantStatus: http.StatusOK,
wantRequestedScopes: []string{"openid", "profile", "email"}, wantBodyFields: []string{"id_token", "access_token", "token_type", "scope", "expires_in"}, // no refresh token
wantRequestedScopes: []string{"openid", "profile", "email"},
},
}, },
{ {
name: "openid scope was not requested from authorize endpoint", name: "openid scope was not requested from authorize endpoint",
modifyAuthRequest: func(authRequest *http.Request) { authcodeExchange: authcodeExchangeInputs{
authRequest.Form.Set("scope", "profile email") modifyAuthRequest: func(authRequest *http.Request) {
authRequest.Form.Set("scope", "profile email")
},
wantStatus: http.StatusOK,
wantBodyFields: []string{"access_token", "token_type", "scope", "expires_in"}, // no id or refresh tokens
wantRequestedScopes: []string{"profile", "email"},
}, },
wantStatus: http.StatusOK,
wantBodyFields: []string{"access_token", "token_type", "scope", "expires_in"}, // no id or refresh tokens
wantRequestedScopes: []string{"profile", "email"},
}, },
{ {
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",
modifyAuthRequest: func(authRequest *http.Request) { authcodeExchange: authcodeExchangeInputs{
authRequest.Form.Set("scope", "openid offline_access") modifyAuthRequest: func(authRequest *http.Request) {
authRequest.Form.Set("scope", "openid offline_access")
},
wantStatus: http.StatusOK,
wantBodyFields: []string{"id_token", "access_token", "token_type", "scope", "expires_in", "refresh_token"}, // all possible tokens
wantRequestedScopes: []string{"openid", "offline_access"},
}, },
wantStatus: http.StatusOK,
wantBodyFields: []string{"id_token", "access_token", "token_type", "scope", "expires_in", "refresh_token"}, // all possible tokens
wantRequestedScopes: []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",
modifyAuthRequest: func(authRequest *http.Request) { authcodeExchange: authcodeExchangeInputs{
authRequest.Form.Set("scope", "offline_access") modifyAuthRequest: func(authRequest *http.Request) {
authRequest.Form.Set("scope", "offline_access")
},
wantStatus: http.StatusOK,
wantBodyFields: []string{"access_token", "token_type", "scope", "expires_in", "refresh_token"}, // no id token
wantRequestedScopes: []string{"offline_access"},
}, },
wantStatus: http.StatusOK,
wantBodyFields: []string{"access_token", "token_type", "scope", "expires_in", "refresh_token"}, // no id token
wantRequestedScopes: []string{"offline_access"},
}, },
// sad path // sad path
{ {
name: "GET method is wrong", name: "GET method is wrong",
modifyTokenRequest: func(r *http.Request, authCode string) { r.Method = http.MethodGet }, authcodeExchange: authcodeExchangeInputs{
wantStatus: http.StatusBadRequest, modifyTokenRequest: func(r *http.Request, authCode string) { r.Method = http.MethodGet },
wantExactBody: fositeInvalidMethodErrorBody("GET"), wantStatus: http.StatusBadRequest,
wantExactBody: fositeInvalidMethodErrorBody("GET"),
},
}, },
{ {
name: "PUT method is wrong", name: "PUT method is wrong",
modifyTokenRequest: func(r *http.Request, authCode string) { r.Method = http.MethodPut }, authcodeExchange: authcodeExchangeInputs{
wantStatus: http.StatusBadRequest, modifyTokenRequest: func(r *http.Request, authCode string) { r.Method = http.MethodPut },
wantExactBody: fositeInvalidMethodErrorBody("PUT"), wantStatus: http.StatusBadRequest,
wantExactBody: fositeInvalidMethodErrorBody("PUT"),
},
}, },
{ {
name: "PATCH method is wrong", name: "PATCH method is wrong",
modifyTokenRequest: func(r *http.Request, authCode string) { r.Method = http.MethodPatch }, authcodeExchange: authcodeExchangeInputs{
wantStatus: http.StatusBadRequest, modifyTokenRequest: func(r *http.Request, authCode string) { r.Method = http.MethodPatch },
wantExactBody: fositeInvalidMethodErrorBody("PATCH"), wantStatus: http.StatusBadRequest,
wantExactBody: fositeInvalidMethodErrorBody("PATCH"),
},
}, },
{ {
name: "DELETE method is wrong", name: "DELETE method is wrong",
modifyTokenRequest: func(r *http.Request, authCode string) { r.Method = http.MethodDelete }, authcodeExchange: authcodeExchangeInputs{
wantStatus: http.StatusBadRequest, modifyTokenRequest: func(r *http.Request, authCode string) { r.Method = http.MethodDelete },
wantExactBody: fositeInvalidMethodErrorBody("DELETE"), wantStatus: http.StatusBadRequest,
wantExactBody: fositeInvalidMethodErrorBody("DELETE"),
},
}, },
{ {
name: "content type is invalid", name: "content type is invalid",
modifyTokenRequest: func(r *http.Request, authCode string) { r.Header.Set("Content-Type", "text/plain") }, authcodeExchange: authcodeExchangeInputs{
wantStatus: http.StatusBadRequest, modifyTokenRequest: func(r *http.Request, authCode string) { r.Header.Set("Content-Type", "text/plain") },
wantExactBody: fositeEmptyPayloadErrorBody, wantStatus: http.StatusBadRequest,
wantExactBody: fositeEmptyPayloadErrorBody,
},
}, },
{ {
name: "payload is not valid form serialization", name: "payload is not valid form serialization",
modifyTokenRequest: func(r *http.Request, authCode string) { authcodeExchange: authcodeExchangeInputs{
r.Body = ioutil.NopCloser(strings.NewReader("this newline character is not allowed in a form serialization: \n")) 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,
wantExactBody: fositeMissingGrantTypeErrorBody,
}, },
wantStatus: http.StatusBadRequest,
wantExactBody: fositeMissingGrantTypeErrorBody,
}, },
{ {
name: "payload is empty", name: "payload is empty",
modifyTokenRequest: func(r *http.Request, authCode string) { r.Body = nil }, authcodeExchange: authcodeExchangeInputs{
wantStatus: http.StatusBadRequest, modifyTokenRequest: func(r *http.Request, authCode string) { r.Body = nil },
wantExactBody: fositeInvalidPayloadErrorBody, wantStatus: http.StatusBadRequest,
wantExactBody: fositeInvalidPayloadErrorBody,
},
}, },
{ {
name: "grant type is missing in request", name: "grant type is missing in request",
modifyTokenRequest: func(r *http.Request, authCode string) { authcodeExchange: authcodeExchangeInputs{
r.Body = happyBody(authCode).WithGrantType("").ReadCloser() modifyTokenRequest: func(r *http.Request, authCode string) {
r.Body = happyBody(authCode).WithGrantType("").ReadCloser()
},
wantStatus: http.StatusBadRequest,
wantExactBody: fositeMissingGrantTypeErrorBody,
}, },
wantStatus: http.StatusBadRequest,
wantExactBody: fositeMissingGrantTypeErrorBody,
}, },
{ {
name: "grant type is not authorization_code", name: "grant type is not authorization_code",
modifyTokenRequest: func(r *http.Request, authCode string) { authcodeExchange: authcodeExchangeInputs{
r.Body = happyBody(authCode).WithGrantType("bogus").ReadCloser() modifyTokenRequest: func(r *http.Request, authCode string) {
r.Body = happyBody(authCode).WithGrantType("bogus").ReadCloser()
},
wantStatus: http.StatusBadRequest,
wantExactBody: fositeInvalidRequestErrorBody,
}, },
wantStatus: http.StatusBadRequest,
wantExactBody: fositeInvalidRequestErrorBody,
}, },
{ {
name: "client id is missing in request", name: "client id is missing in request",
modifyTokenRequest: func(r *http.Request, authCode string) { authcodeExchange: authcodeExchangeInputs{
r.Body = happyBody(authCode).WithClientID("").ReadCloser() modifyTokenRequest: func(r *http.Request, authCode string) {
r.Body = happyBody(authCode).WithClientID("").ReadCloser()
},
wantStatus: http.StatusBadRequest,
wantExactBody: fositeMissingClientErrorBody,
}, },
wantStatus: http.StatusBadRequest,
wantExactBody: fositeMissingClientErrorBody,
}, },
{ {
name: "client id is wrong", name: "client id is wrong",
modifyTokenRequest: func(r *http.Request, authCode string) { authcodeExchange: authcodeExchangeInputs{
r.Body = happyBody(authCode).WithClientID("bogus").ReadCloser() modifyTokenRequest: func(r *http.Request, authCode string) {
r.Body = happyBody(authCode).WithClientID("bogus").ReadCloser()
},
wantStatus: http.StatusUnauthorized,
wantExactBody: fositeInvalidClientErrorBody,
}, },
wantStatus: http.StatusUnauthorized,
wantExactBody: fositeInvalidClientErrorBody,
}, },
{ {
name: "auth code is missing in request", name: "auth code is missing in request",
modifyTokenRequest: func(r *http.Request, authCode string) { authcodeExchange: authcodeExchangeInputs{
r.Body = happyBody(authCode).WithAuthCode("").ReadCloser() modifyTokenRequest: func(r *http.Request, authCode string) {
r.Body = happyBody(authCode).WithAuthCode("").ReadCloser()
},
wantStatus: http.StatusBadRequest,
wantExactBody: fositeInvalidAuthCodeErrorBody,
}, },
wantStatus: http.StatusBadRequest,
wantExactBody: fositeInvalidAuthCodeErrorBody,
}, },
{ {
name: "auth code has never been valid", name: "auth code has never been valid",
modifyTokenRequest: func(r *http.Request, authCode string) { authcodeExchange: authcodeExchangeInputs{
r.Body = happyBody(authCode).WithAuthCode("bogus").ReadCloser() modifyTokenRequest: func(r *http.Request, authCode string) {
r.Body = happyBody(authCode).WithAuthCode("bogus").ReadCloser()
},
wantStatus: http.StatusBadRequest,
wantExactBody: fositeInvalidAuthCodeErrorBody,
}, },
wantStatus: http.StatusBadRequest,
wantExactBody: fositeInvalidAuthCodeErrorBody,
}, },
{ {
name: "auth code is invalidated", name: "auth code is invalidated",
modifyStorage: func( authcodeExchange: authcodeExchangeInputs{
t *testing.T, modifyStorage: func(
s interface { t *testing.T,
oauth2.TokenRevocationStorage s interface {
oauth2.CoreStorage oauth2.TokenRevocationStorage
openid.OpenIDConnectRequestStorage oauth2.CoreStorage
pkce.PKCERequestStorage openid.OpenIDConnectRequestStorage
fosite.ClientManager pkce.PKCERequestStorage
fosite.ClientManager
},
authCode string,
) {
err := s.InvalidateAuthorizeCodeSession(context.Background(), getFositeDataSignature(t, authCode))
require.NoError(t, err)
}, },
authCode string, wantStatus: http.StatusBadRequest,
) { wantExactBody: fositeReusedAuthCodeErrorBody,
err := s.InvalidateAuthorizeCodeSession(context.Background(), getFositeDataSignature(t, authCode))
require.NoError(t, err)
}, },
wantStatus: http.StatusBadRequest,
wantExactBody: fositeReusedAuthCodeErrorBody,
}, },
{ {
name: "redirect uri is missing in request", name: "redirect uri is missing in request",
modifyTokenRequest: func(r *http.Request, authCode string) { authcodeExchange: authcodeExchangeInputs{
r.Body = happyBody(authCode).WithRedirectURI("").ReadCloser() modifyTokenRequest: func(r *http.Request, authCode string) {
r.Body = happyBody(authCode).WithRedirectURI("").ReadCloser()
},
wantStatus: http.StatusBadRequest,
wantExactBody: fositeInvalidRedirectURIErrorBody,
}, },
wantStatus: http.StatusBadRequest,
wantExactBody: fositeInvalidRedirectURIErrorBody,
}, },
{ {
name: "redirect uri is wrong", name: "redirect uri is wrong",
modifyTokenRequest: func(r *http.Request, authCode string) { authcodeExchange: authcodeExchangeInputs{
r.Body = happyBody(authCode).WithRedirectURI("bogus").ReadCloser() modifyTokenRequest: func(r *http.Request, authCode string) {
r.Body = happyBody(authCode).WithRedirectURI("bogus").ReadCloser()
},
wantStatus: http.StatusBadRequest,
wantExactBody: fositeInvalidRedirectURIErrorBody,
}, },
wantStatus: http.StatusBadRequest,
wantExactBody: fositeInvalidRedirectURIErrorBody,
}, },
{ {
name: "pkce is missing in request", name: "pkce is missing in request",
modifyTokenRequest: func(r *http.Request, authCode string) { authcodeExchange: authcodeExchangeInputs{
r.Body = happyBody(authCode).WithPKCE("").ReadCloser() modifyTokenRequest: func(r *http.Request, authCode string) {
r.Body = happyBody(authCode).WithPKCE("").ReadCloser()
},
wantStatus: http.StatusBadRequest,
wantExactBody: fositeMissingPKCEVerifierErrorBody,
}, },
wantStatus: http.StatusBadRequest,
wantExactBody: fositeMissingPKCEVerifierErrorBody,
}, },
{ {
name: "pkce is wrong", name: "pkce is wrong",
modifyTokenRequest: func(r *http.Request, authCode string) { authcodeExchange: authcodeExchangeInputs{
r.Body = happyBody(authCode).WithPKCE( modifyTokenRequest: func(r *http.Request, authCode string) {
"bogus-verifier-that-is-at-least-43-characters-for-the-sake-of-entropy", r.Body = happyBody(authCode).WithPKCE(
).ReadCloser() "bogus-verifier-that-is-at-least-43-characters-for-the-sake-of-entropy",
).ReadCloser()
},
wantStatus: http.StatusBadRequest,
wantExactBody: fositeWrongPKCEVerifierErrorBody,
}, },
wantStatus: http.StatusBadRequest,
wantExactBody: 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",
makeOathHelper: makeOauthHelperWithNilPrivateJWTSigningKey, authcodeExchange: authcodeExchangeInputs{
wantStatus: http.StatusServiceUnavailable, makeOathHelper: makeOauthHelperWithNilPrivateJWTSigningKey,
wantExactBody: fositeTemporarilyUnavailableErrorBody, wantStatus: http.StatusServiceUnavailable,
wantExactBody: fositeTemporarilyUnavailableErrorBody,
},
}, },
} }
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) {
authRequest := deepCopyRequestForm(happyAuthRequest) exchangeAuthcodeForTokens(t, test.authcodeExchange)
if test.modifyAuthRequest != nil {
test.modifyAuthRequest(authRequest)
}
client := fake.NewSimpleClientset()
secrets := client.CoreV1().Secrets("some-namespace")
var oauthHelper fosite.OAuth2Provider
var authCode string
var jwtSigningKey *ecdsa.PrivateKey
oauthStore := oidc.NewKubeStorage(secrets)
if test.makeOathHelper != nil {
oauthHelper, authCode, jwtSigningKey = test.makeOathHelper(t, authRequest, oauthStore)
} else {
oauthHelper, authCode, jwtSigningKey = makeHappyOauthHelper(t, authRequest, oauthStore)
}
if test.modifyStorage != nil {
test.modifyStorage(t, oauthStore, authCode)
}
subject := NewHandler(oauthHelper)
authorizeEndpointGrantedOpenIDScope := strings.Contains(authRequest.Form.Get("scope"), "openid")
expectedNumberOfIDSessionsStored := 0
if authorizeEndpointGrantedOpenIDScope {
expectedNumberOfIDSessionsStored = 1
}
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: authorizationcode.TypeLabelValue}, 1)
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: storagepkce.TypeLabelValue}, 1)
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: openidconnect.TypeLabelValue}, expectedNumberOfIDSessionsStored)
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{}, 2+expectedNumberOfIDSessionsStored)
req := httptest.NewRequest("POST", "/path/shouldn't/matter", happyBody(authCode).ReadCloser())
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if test.modifyTokenRequest != nil {
test.modifyTokenRequest(req, authCode)
}
rsp := httptest.NewRecorder()
subject.ServeHTTP(rsp, req)
t.Logf("response: %#v", rsp)
t.Logf("response body: %q", rsp.Body.String())
require.Equal(t, test.wantStatus, rsp.Code)
testutil.RequireEqualContentType(t, rsp.Header().Get("Content-Type"), "application/json")
if test.wantBodyFields != nil {
var parsedResponseBody map[string]interface{}
require.NoError(t, json.Unmarshal(rsp.Body.Bytes(), &parsedResponseBody))
require.ElementsMatch(t, test.wantBodyFields, getMapKeys(parsedResponseBody))
wantIDToken := contains(test.wantBodyFields, "id_token")
wantRefreshToken := contains(test.wantBodyFields, "refresh_token")
code := req.PostForm.Get("code")
requireInvalidAuthCodeStorage(t, code, oauthStore)
requireValidAccessTokenStorage(t, parsedResponseBody, oauthStore, test.wantRequestedScopes, wantIDToken, wantRefreshToken)
requireInvalidPKCEStorage(t, code, oauthStore)
requireValidOIDCStorage(t, parsedResponseBody, code, oauthStore, test.wantRequestedScopes, wantIDToken, wantRefreshToken)
expectedNumberOfRefreshTokenSessionsStored := 0
if wantRefreshToken {
expectedNumberOfRefreshTokenSessionsStored = 1
}
expectedNumberOfIDSessionsStored = 0
if wantIDToken {
expectedNumberOfIDSessionsStored = 1
requireValidIDToken(t, parsedResponseBody, jwtSigningKey)
}
if wantRefreshToken {
requireValidRefreshTokenStorage(t, parsedResponseBody, oauthStore, test.wantRequestedScopes, wantIDToken, wantRefreshToken)
}
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: authorizationcode.TypeLabelValue}, 1)
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: accesstoken.TypeLabelValue}, 1)
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: storagepkce.TypeLabelValue}, 0)
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: refreshtoken.TypeLabelValue}, expectedNumberOfRefreshTokenSessionsStored)
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: openidconnect.TypeLabelValue}, expectedNumberOfIDSessionsStored)
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{}, 2+expectedNumberOfRefreshTokenSessionsStored+expectedNumberOfIDSessionsStored)
} else {
require.JSONEq(t, test.wantExactBody, rsp.Body.String())
}
}) })
} }
}
t.Run("auth code is used twice", func(t *testing.T) { func TestTokenEndpointWhenAuthcodeIsUsedTwice(t *testing.T) {
authRequest := deepCopyRequestForm(happyAuthRequest) tests := []struct {
authRequest.Form.Set("scope", "openid offline_access profile email") name string
authcodeExchange authcodeExchangeInputs
wantGrantedOpenidScope bool
wantGrantedOfflineAccessScope bool
}{
{
name: "authcode exchange succeeds once and then fails when the same authcode is used again",
authcodeExchange: authcodeExchangeInputs{
modifyAuthRequest: func(authRequest *http.Request) {
authRequest.Form.Set("scope", "openid offline_access profile email")
},
wantStatus: http.StatusOK,
wantBodyFields: []string{"id_token", "refresh_token", "access_token", "token_type", "expires_in", "scope"},
wantRequestedScopes: []string{"openid", "offline_access", "profile", "email"},
},
wantGrantedOpenidScope: true,
wantGrantedOfflineAccessScope: true,
},
}
for _, test := range tests {
test := test
t.Run(test.name, func(t *testing.T) {
// First call - should be successful.
subject, rsp, authCode, secrets, oauthStore := exchangeAuthcodeForTokens(t, test.authcodeExchange)
var parsedResponseBody map[string]interface{}
require.NoError(t, json.Unmarshal(rsp.Body.Bytes(), &parsedResponseBody))
wantRequestedScopes := []string{"openid", "offline_access", "profile", "email"} // Second call - should be unsuccessful since auth code was already used.
wantBodyFields := []string{"id_token", "refresh_token", "access_token", "token_type", "expires_in", "scope"} //
wantGrantedOpenidScope := true // Fosite will also revoke the access token as is recommended by the OIDC spec. Currently, we don't
wantGrantedOfflineAccessScope := true // 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())
client := fake.NewSimpleClientset() // This was previously invalidated by the first request, so it remains invalidated
secrets := client.CoreV1().Secrets("some-namespace") requireInvalidAuthCodeStorage(t, authCode, oauthStore)
// 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
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.wantGrantedOpenidScope, test.wantGrantedOfflineAccessScope)
oauthStore := oidc.NewKubeStorage(secrets) // Check that the access token and refresh token storage were both deleted, and the number of other storage objects did not change.
oauthHelper, authCode, jwtSigningKey := makeHappyOauthHelper(t, authRequest, oauthStore) testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: authorizationcode.TypeLabelValue}, 1)
subject := NewHandler(oauthHelper) testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: openidconnect.TypeLabelValue}, 1)
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: accesstoken.TypeLabelValue}, 0)
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: refreshtoken.TypeLabelValue}, 0)
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: storagepkce.TypeLabelValue}, 0)
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{}, 2)
})
}
}
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: authorizationcode.TypeLabelValue}, 1) func exchangeAuthcodeForTokens(t *testing.T, test authcodeExchangeInputs) (
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: storagepkce.TypeLabelValue}, 1) subject http.Handler,
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: openidconnect.TypeLabelValue}, 1) rsp *httptest.ResponseRecorder,
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{}, 3) authCode string,
secrets v1.SecretInterface,
oauthStore *oidc.KubeStorage,
) {
authRequest := deepCopyRequestForm(happyAuthRequest)
if test.modifyAuthRequest != nil {
test.modifyAuthRequest(authRequest)
}
req := httptest.NewRequest("POST", "/path/shouldn't/matter", happyBody(authCode).ReadCloser()) client := fake.NewSimpleClientset()
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") secrets = client.CoreV1().Secrets("some-namespace")
// First call - should be successful. var oauthHelper fosite.OAuth2Provider
rsp0 := httptest.NewRecorder() var jwtSigningKey *ecdsa.PrivateKey
subject.ServeHTTP(rsp0, req)
t.Logf("response 0: %#v", rsp0)
t.Logf("response 0 body: %q", rsp0.Body.String())
testutil.RequireEqualContentType(t, rsp0.Header().Get("Content-Type"), "application/json")
require.Equal(t, http.StatusOK, rsp0.Code)
oauthStore = oidc.NewKubeStorage(secrets)
if test.makeOathHelper != nil {
oauthHelper, authCode, jwtSigningKey = test.makeOathHelper(t, authRequest, oauthStore)
} else {
oauthHelper, authCode, jwtSigningKey = makeHappyOauthHelper(t, authRequest, oauthStore)
}
if test.modifyStorage != nil {
test.modifyStorage(t, oauthStore, authCode)
}
subject = NewHandler(oauthHelper)
authorizeEndpointGrantedOpenIDScope := strings.Contains(authRequest.Form.Get("scope"), "openid")
expectedNumberOfIDSessionsStored := 0
if authorizeEndpointGrantedOpenIDScope {
expectedNumberOfIDSessionsStored = 1
}
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: authorizationcode.TypeLabelValue}, 1)
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: storagepkce.TypeLabelValue}, 1)
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: openidconnect.TypeLabelValue}, expectedNumberOfIDSessionsStored)
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{}, 2+expectedNumberOfIDSessionsStored)
req := httptest.NewRequest("POST", "/path/shouldn't/matter", happyBody(authCode).ReadCloser())
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if test.modifyTokenRequest != nil {
test.modifyTokenRequest(req, authCode)
}
rsp = httptest.NewRecorder()
subject.ServeHTTP(rsp, req)
t.Logf("response: %#v", rsp)
t.Logf("response body: %q", rsp.Body.String())
require.Equal(t, test.wantStatus, rsp.Code)
testutil.RequireEqualContentType(t, rsp.Header().Get("Content-Type"), "application/json")
if test.wantBodyFields != nil {
var parsedResponseBody map[string]interface{} var parsedResponseBody map[string]interface{}
require.NoError(t, json.Unmarshal(rsp0.Body.Bytes(), &parsedResponseBody)) require.NoError(t, json.Unmarshal(rsp.Body.Bytes(), &parsedResponseBody))
require.ElementsMatch(t, test.wantBodyFields, getMapKeys(parsedResponseBody))
require.ElementsMatch(t, wantBodyFields, getMapKeys(parsedResponseBody)) wantIDToken := contains(test.wantBodyFields, "id_token")
wantRefreshToken := contains(test.wantBodyFields, "refresh_token")
requireValidIDToken(t, parsedResponseBody, jwtSigningKey)
code := req.PostForm.Get("code") code := req.PostForm.Get("code")
requireInvalidAuthCodeStorage(t, code, oauthStore) requireInvalidAuthCodeStorage(t, code, oauthStore)
requireValidAccessTokenStorage(t, parsedResponseBody, oauthStore, wantRequestedScopes, wantGrantedOpenidScope, wantGrantedOfflineAccessScope) requireValidAccessTokenStorage(t, parsedResponseBody, oauthStore, test.wantRequestedScopes, wantIDToken, wantRefreshToken)
requireInvalidPKCEStorage(t, code, oauthStore) requireInvalidPKCEStorage(t, code, oauthStore)
requireValidOIDCStorage(t, parsedResponseBody, code, oauthStore, wantRequestedScopes, wantGrantedOpenidScope, wantGrantedOfflineAccessScope) requireValidOIDCStorage(t, parsedResponseBody, code, oauthStore, test.wantRequestedScopes, wantIDToken, wantRefreshToken)
expectedNumberOfRefreshTokenSessionsStored := 0
if wantRefreshToken {
expectedNumberOfRefreshTokenSessionsStored = 1
}
expectedNumberOfIDSessionsStored = 0
if wantIDToken {
expectedNumberOfIDSessionsStored = 1
requireValidIDToken(t, parsedResponseBody, jwtSigningKey)
}
if wantRefreshToken {
requireValidRefreshTokenStorage(t, parsedResponseBody, oauthStore, test.wantRequestedScopes, wantIDToken, wantRefreshToken)
}
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: authorizationcode.TypeLabelValue}, 1) testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: authorizationcode.TypeLabelValue}, 1)
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: openidconnect.TypeLabelValue}, 1)
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: accesstoken.TypeLabelValue}, 1) testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: accesstoken.TypeLabelValue}, 1)
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: refreshtoken.TypeLabelValue}, 1)
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: storagepkce.TypeLabelValue}, 0) testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: storagepkce.TypeLabelValue}, 0)
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{}, 4) testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: refreshtoken.TypeLabelValue}, expectedNumberOfRefreshTokenSessionsStored)
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: openidconnect.TypeLabelValue}, expectedNumberOfIDSessionsStored)
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{}, 2+expectedNumberOfRefreshTokenSessionsStored+expectedNumberOfIDSessionsStored)
} else {
require.JSONEq(t, test.wantExactBody, rsp.Body.String())
}
// Second call - should be unsuccessful since auth code was already used. return subject, rsp, authCode, secrets, oauthStore
//
// Fosite will also revoke the access token as is recommended by the OIDC spec. Currently, we don't
// delete the OIDC storage...but we probably should.
rsp1 := httptest.NewRecorder()
subject.ServeHTTP(rsp1, req)
t.Logf("response 1: %#v", rsp1)
t.Logf("response 1 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())
// This was previously invalidated by the first request, so it remains invalidated
requireInvalidAuthCodeStorage(t, code, oauthStore)
// 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
requireInvalidPKCEStorage(t, code, oauthStore)
// Fosite never cleans up OpenID Connect session storage, so it is still there
requireValidOIDCStorage(t, parsedResponseBody, code, oauthStore, wantRequestedScopes, wantGrantedOpenidScope, wantGrantedOfflineAccessScope)
// 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: openidconnect.TypeLabelValue}, 1)
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: accesstoken.TypeLabelValue}, 0)
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: refreshtoken.TypeLabelValue}, 0)
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{crud.SecretLabelKey: storagepkce.TypeLabelValue}, 0)
testutil.RequireNumberOfSecretsMatchingLabelSelector(t, secrets, labels.Set{}, 2)
})
} }
type body url.Values type body url.Values