diff --git a/internal/oidc/nullstorage_test.go b/internal/oidc/nullstorage_test.go index 4adbe807..62f25cab 100644 --- a/internal/oidc/nullstorage_test.go +++ b/internal/oidc/nullstorage_test.go @@ -27,7 +27,7 @@ func TestNullStorage_GetClient(t *testing.T) { Public: true, RedirectURIs: []string{"http://127.0.0.1/callback"}, ResponseTypes: []string{"code"}, - GrantTypes: []string{"authorization_code", "refresh_token"}, + GrantTypes: []string{"authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:token-exchange"}, Scopes: []string{"openid", "offline_access", "profile", "email"}, }, TokenEndpointAuthMethod: "none", diff --git a/internal/oidc/oidc.go b/internal/oidc/oidc.go index 0a239be5..b5a55a09 100644 --- a/internal/oidc/oidc.go +++ b/internal/oidc/oidc.go @@ -84,8 +84,8 @@ func PinnipedCLIOIDCClient() *fosite.DefaultOpenIDConnectClient { Public: true, RedirectURIs: []string{"http://127.0.0.1/callback"}, ResponseTypes: []string{"code"}, - GrantTypes: []string{"authorization_code", "urn:ietf:params:oauth:grant-type:token-exchange"}, - Scopes: []string{coreosoidc.ScopeOpenID, coreosoidc.ScopeOfflineAccess, "profile", "email"}, + GrantTypes: []string{"authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:token-exchange"}, + Scopes: []string{coreosoidc.ScopeOpenID, coreosoidc.ScopeOfflineAccess, "profile", "email", "pinniped.sts.unrestricted"}, }, TokenEndpointAuthMethod: "none", } @@ -126,12 +126,12 @@ func FositeOauth2Helper( OpenIDConnectTokenStrategy: newDynamicOpenIDConnectECDSAStrategy(oauthConfig, jwksProvider), }, nil, // hasher, defaults to using BCrypt when nil. Used for hashing client secrets. - TokenExchangeFactory, compose.OAuth2AuthorizeExplicitFactory, // compose.OAuth2RefreshTokenGrantFactory, compose.OpenIDConnectExplicitFactory, // compose.OpenIDConnectRefreshFactory, compose.OAuth2PKCEFactory, + TokenExchangeFactory, ) } diff --git a/internal/oidc/token/token_handler_test.go b/internal/oidc/token/token_handler_test.go index beb97660..4fa834b3 100644 --- a/internal/oidc/token/token_handler_test.go +++ b/internal/oidc/token/token_handler_test.go @@ -206,6 +206,19 @@ var ( "redirect_uri": {goodRedirectURI}, }, } + + happyTokenExchangeRequest = func(audience string, subjectToken string) *http.Request { + return &http.Request{ + Form: url.Values{ + "grant_type": {"urn:ietf:params:oauth:grant-type:token-exchange"}, + "audience": {audience}, + "subject_token": {subjectToken}, + "subject_token_type": {"urn:ietf:params:oauth:token-type:access_token"}, + "requested_token_type": {"urn:ietf:params:oauth:token-type:jwt"}, + "client_id": {goodClient}, + }, + } + } ) type authcodeExchangeInputs struct { @@ -550,6 +563,55 @@ func TestTokenEndpointWhenAuthcodeIsUsedTwice(t *testing.T) { } } +func TestTokenExchange(t *testing.T) { + // TODO write this test + t.Skip() + tests := []struct { + name string + authcodeExchange authcodeExchangeInputs + wantStatus int + requestedAudience string + modifyTokenExchangeRequest func(r *http.Request) + }{ + { + name: "token exchange happy path", + authcodeExchange: authcodeExchangeInputs{ + modifyAuthRequest: func(authRequest *http.Request) { + authRequest.Form.Set("scope", "openid pinniped.sts.unrestricted") + }, + wantStatus: http.StatusOK, + wantBodyFields: []string{"id_token", "access_token", "token_type", "expires_in", "scope"}, + wantRequestedScopes: []string{"openid", "pinniped.sts.unrestricted"}, + }, + wantStatus: http.StatusOK, + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + subject, rsp, _, _, _ := exchangeAuthcodeForTokens(t, test.authcodeExchange) + var parsedResponseBody map[string]interface{} + require.NoError(t, json.Unmarshal(rsp.Body.Bytes(), &parsedResponseBody)) + + request := happyTokenExchangeRequest("foo-cluster", parsedResponseBody["access_token"].(string)) + + req := httptest.NewRequest("POST", "/path/shouldn't/matter", body(request.Form).ReadCloser()) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + if test.modifyTokenExchangeRequest != nil { + test.modifyTokenExchangeRequest(req) + } + 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") + }) + } +} + func exchangeAuthcodeForTokens(t *testing.T, test authcodeExchangeInputs) ( subject http.Handler, rsp *httptest.ResponseRecorder, @@ -758,6 +820,9 @@ func simulateAuthEndpointHavingAlreadyRun(t *testing.T, authRequest *http.Reques if strings.Contains(authRequest.Form.Get("scope"), "offline_access") { authRequester.GrantScope("offline_access") } + if strings.Contains(authRequest.Form.Get("scope"), "pinniped.sts.unrestricted") { + authRequester.GrantScope("pinniped.sts.unrestricted") + } authResponder, err := oauthHelper.NewAuthorizeResponse(ctx, authRequester, session) require.NoError(t, err) return authResponder diff --git a/internal/oidc/token_exchange.go b/internal/oidc/token_exchange.go index c4fbc708..92b07efe 100644 --- a/internal/oidc/token_exchange.go +++ b/internal/oidc/token_exchange.go @@ -1,3 +1,6 @@ +// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + package oidc import ( @@ -35,6 +38,9 @@ func (t *TokenExchangeHandler) HandleTokenEndpointRequest(ctx context.Context, r } func (t *TokenExchangeHandler) PopulateTokenEndpointResponse(ctx context.Context, requester fosite.AccessRequester, responder fosite.AccessResponder) error { + if !(requester.GetGrantTypes().ExactOne("urn:ietf:params:oauth:grant-type:token-exchange")) { + return errors.WithStack(fosite.ErrUnknownRequest) + } params := requester.GetRequestForm() accessToken := params.Get("subject_token") if err := t.accessTokenStrategy.ValidateAccessToken(ctx, requester, accessToken); err != nil { @@ -49,7 +55,7 @@ func (t *TokenExchangeHandler) PopulateTokenEndpointResponse(ctx context.Context return errors.WithStack(fosite.ErrScopeNotGranted) } // TODO check the other requester fields - scopedDownRequester := fosite.NewAccessRequest(requester.GetSession()) + scopedDownRequester := fosite.NewAccessRequest(accessTokenSession.GetSession()) scopedDownRequester.GrantedAudience = []string{params.Get("audience")} newToken, err := t.idTokenStrategy.GenerateIDToken(ctx, scopedDownRequester) if err != nil {