diff --git a/internal/crud/crud.go b/internal/crud/crud.go index 77160e52..dee3f49b 100644 --- a/internal/crud/crud.go +++ b/internal/crud/crud.go @@ -127,14 +127,12 @@ func (s *secretsStorage) DeleteByLabel(ctx context.Context, labelName string, la }.String(), }) if err != nil { - //nolint:err113 // there's nothing wrong with this error return fmt.Errorf(`failed to list secrets for resource "%s" matching label "%s=%s": %w`, s.resource, labelName, labelValue, err) } // TODO try to delete all of the items and consolidate all of the errors and return them all for _, secret := range list.Items { err = s.secrets.Delete(ctx, secret.Name, metav1.DeleteOptions{}) if err != nil { - //nolint:err113 // there's nothing wrong with this error return fmt.Errorf(`failed to delete secrets for resource "%s" matching label "%s=%s" with name %s: %w`, s.resource, labelName, labelValue, secret.Name, err) } } diff --git a/internal/oidc/auth/auth_handler.go b/internal/oidc/auth/auth_handler.go index 1998b72a..61ab7529 100644 --- a/internal/oidc/auth/auth_handler.go +++ b/internal/oidc/auth/auth_handler.go @@ -63,6 +63,9 @@ func NewHandler( // at this time, however we will temporarily grant the scope just in case that changes in a future release of fosite. oidc.GrantScopeIfRequested(authorizeRequester, coreosoidc.ScopeOfflineAccess) + // Grant the Pinniped STS scope if requested. + oidc.GrantScopeIfRequested(authorizeRequester, "pinniped.sts.unrestricted") + now := time.Now() _, err = oauthHelper.NewAuthorizeResponse(r.Context(), authorizeRequester, &openid.DefaultSession{ Claims: &jwt.IDTokenClaims{ diff --git a/internal/oidc/callback/callback_handler.go b/internal/oidc/callback/callback_handler.go index 3c85ee4c..2cea6c2e 100644 --- a/internal/oidc/callback/callback_handler.go +++ b/internal/oidc/callback/callback_handler.go @@ -71,9 +71,10 @@ func NewHandler( return httperr.New(http.StatusBadRequest, "error using state downstream auth params") } - // Automatically grant the openid and offline_access scopes, but only if they were requested. + // Automatically grant the openid, offline_access, and Pinniped STS scopes, but only if they were requested. oidc.GrantScopeIfRequested(authorizeRequester, coreosoidc.ScopeOpenID) oidc.GrantScopeIfRequested(authorizeRequester, coreosoidc.ScopeOfflineAccess) + oidc.GrantScopeIfRequested(authorizeRequester, "pinniped.sts.unrestricted") token, err := upstreamIDPConfig.ExchangeAuthcodeAndValidateTokens( r.Context(), diff --git a/internal/oidc/nullstorage_test.go b/internal/oidc/nullstorage_test.go index 4adbe807..523aafb5 100644 --- a/internal/oidc/nullstorage_test.go +++ b/internal/oidc/nullstorage_test.go @@ -27,8 +27,8 @@ 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"}, - Scopes: []string{"openid", "offline_access", "profile", "email"}, + GrantTypes: []string{"authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:token-exchange"}, + Scopes: []string{"openid", "offline_access", "profile", "email", "pinniped.sts.unrestricted"}, }, TokenEndpointAuthMethod: "none", }, diff --git a/internal/oidc/oidc.go b/internal/oidc/oidc.go index 96c5e72d..762fc842 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", "refresh_token"}, - 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", } @@ -131,6 +131,7 @@ func FositeOauth2Helper( 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 cd2d0d28..b5e51018 100644 --- a/internal/oidc/token/token_handler_test.go +++ b/internal/oidc/token/token_handler_test.go @@ -29,6 +29,7 @@ import ( "github.com/stretchr/testify/require" "gopkg.in/square/go-jose.v2" josejwt "gopkg.in/square/go-jose.v2/jwt" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" "k8s.io/client-go/kubernetes/fake" v1 "k8s.io/client-go/kubernetes/typed/core/v1" @@ -219,6 +220,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 tokenEndpointResponseExpectedValues struct { @@ -638,6 +652,221 @@ func TestTokenEndpointWhenAuthcodeIsUsedTwice(t *testing.T) { } } +func TestTokenExchange(t *testing.T) { + successfulAuthCodeExchange := tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusOK, + wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "expires_in", "scope"}, + wantRequestedScopes: []string{"openid", "pinniped.sts.unrestricted"}, + wantGrantedScopes: []string{"openid", "pinniped.sts.unrestricted"}, + } + + doValidAuthCodeExchange := authcodeExchangeInputs{ + modifyAuthRequest: func(authRequest *http.Request) { + authRequest.Form.Set("scope", "openid pinniped.sts.unrestricted") + }, + want: successfulAuthCodeExchange, + } + tests := []struct { + name string + + authcodeExchange authcodeExchangeInputs + modifyRequestParams func(t *testing.T, params url.Values) + modifyStorage func(t *testing.T, storage *oidc.KubeStorage, pendingRequest *http.Request) + requestedAudience string + + wantStatus int + wantResponseBodyContains string + }{ + { + name: "happy path", + authcodeExchange: doValidAuthCodeExchange, + requestedAudience: "some-workload-cluster", + wantStatus: http.StatusOK, + }, + { + name: "missing audience", + authcodeExchange: doValidAuthCodeExchange, + requestedAudience: "", + wantStatus: http.StatusBadRequest, + wantResponseBodyContains: "missing audience parameter", + }, + { + name: "missing subject_token", + authcodeExchange: doValidAuthCodeExchange, + requestedAudience: "some-workload-cluster", + modifyRequestParams: func(t *testing.T, params url.Values) { + params.Del("subject_token") + }, + wantStatus: http.StatusBadRequest, + wantResponseBodyContains: "missing subject_token parameter", + }, + { + name: "wrong subject_token_type", + authcodeExchange: doValidAuthCodeExchange, + requestedAudience: "some-workload-cluster", + modifyRequestParams: func(t *testing.T, params url.Values) { + params.Set("subject_token_type", "invalid") + }, + wantStatus: http.StatusBadRequest, + wantResponseBodyContains: `unsupported subject_token_type parameter value`, + }, + { + name: "wrong requested_token_type", + authcodeExchange: doValidAuthCodeExchange, + requestedAudience: "some-workload-cluster", + modifyRequestParams: func(t *testing.T, params url.Values) { + params.Set("requested_token_type", "invalid") + }, + wantStatus: http.StatusBadRequest, + wantResponseBodyContains: `unsupported requested_token_type parameter value`, + }, + { + name: "unsupported RFC8693 parameter", + authcodeExchange: doValidAuthCodeExchange, + requestedAudience: "some-workload-cluster", + modifyRequestParams: func(t *testing.T, params url.Values) { + params.Set("resource", "some-resource-parameter-value") + }, + wantStatus: http.StatusBadRequest, + wantResponseBodyContains: `unsupported parameter resource`, + }, + { + name: "bogus access token", + authcodeExchange: doValidAuthCodeExchange, + requestedAudience: "some-workload-cluster", + modifyRequestParams: func(t *testing.T, params url.Values) { + params.Set("subject_token", "some-bogus-value") + }, + wantStatus: http.StatusBadRequest, + wantResponseBodyContains: `Invalid token format`, + }, + { + name: "valid access token, but deleted from storage", + authcodeExchange: doValidAuthCodeExchange, + requestedAudience: "some-workload-cluster", + modifyStorage: func(t *testing.T, storage *oidc.KubeStorage, pendingRequest *http.Request) { + parts := strings.Split(pendingRequest.Form.Get("subject_token"), ".") + require.Len(t, parts, 2) + require.NoError(t, storage.DeleteAccessTokenSession(context.Background(), parts[1])) + }, + wantStatus: http.StatusUnauthorized, + wantResponseBodyContains: `invalid subject_token`, + }, + { + name: "access token missing pinniped.sts.unrestricted scope", + authcodeExchange: authcodeExchangeInputs{ + modifyAuthRequest: func(authRequest *http.Request) { + authRequest.Form.Set("scope", "openid") + }, + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusOK, + wantSuccessBodyFields: []string{"id_token", "access_token", "token_type", "expires_in", "scope"}, + wantRequestedScopes: []string{"openid"}, + wantGrantedScopes: []string{"openid"}, + }, + }, + requestedAudience: "some-workload-cluster", + wantStatus: http.StatusForbidden, + wantResponseBodyContains: `missing the \"pinniped.sts.unrestricted\" scope`, + }, + { + name: "access token missing openid scope", + authcodeExchange: authcodeExchangeInputs{ + modifyAuthRequest: func(authRequest *http.Request) { + authRequest.Form.Set("scope", "pinniped.sts.unrestricted") + }, + want: tokenEndpointResponseExpectedValues{ + wantStatus: http.StatusOK, + wantSuccessBodyFields: []string{"access_token", "token_type", "expires_in", "scope"}, + wantRequestedScopes: []string{"pinniped.sts.unrestricted"}, + wantGrantedScopes: []string{"pinniped.sts.unrestricted"}, + }, + }, + requestedAudience: "some-workload-cluster", + wantStatus: http.StatusForbidden, + wantResponseBodyContains: `missing the \"openid\" scope`, + }, + { + name: "token minting failure", + authcodeExchange: authcodeExchangeInputs{ + modifyAuthRequest: func(authRequest *http.Request) { + authRequest.Form.Set("scope", "openid pinniped.sts.unrestricted") + }, + // Fail to fetch a JWK signing key after the authcode exchange has happened. + makeOathHelper: makeOauthHelperWithJWTKeyThatWorksOnlyOnce, + want: successfulAuthCodeExchange, + }, + requestedAudience: "some-workload-cluster", + wantStatus: http.StatusServiceUnavailable, + wantResponseBodyContains: `The authorization server is currently unable to handle the request`, + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + subject, rsp, _, _, secrets, storage := exchangeAuthcodeForTokens(t, test.authcodeExchange) + var parsedResponseBody map[string]interface{} + require.NoError(t, json.Unmarshal(rsp.Body.Bytes(), &parsedResponseBody)) + + request := happyTokenExchangeRequest(test.requestedAudience, parsedResponseBody["access_token"].(string)) + if test.modifyStorage != nil { + test.modifyStorage(t, storage, request) + } + if test.modifyRequestParams != nil { + test.modifyRequestParams(t, request.Form) + } + + req := httptest.NewRequest("POST", "/path/shouldn't/matter", body(request.Form).ReadCloser()) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rsp = httptest.NewRecorder() + + // Measure the secrets in storage after the auth code flow. + existingSecrets, err := secrets.List(context.Background(), metav1.ListOptions{}) + require.NoError(t, err) + + 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.wantResponseBodyContains != "" { + require.Contains(t, rsp.Body.String(), test.wantResponseBodyContains) + } + + // The remaining assertions apply only the the happy path. + if rsp.Code != http.StatusOK { + return + } + + var responseBody map[string]interface{} + require.NoError(t, json.Unmarshal(rsp.Body.Bytes(), &responseBody)) + + require.Contains(t, responseBody, "access_token") + require.Equal(t, "N_A", responseBody["token_type"]) + require.Equal(t, "urn:ietf:params:oauth:token-type:jwt", responseBody["issued_token_type"]) + + // Assert that the returned token has expected claims. + parsedJWT, err := jose.ParseSigned(responseBody["access_token"].(string)) + require.NoError(t, err) + var tokenClaims map[string]interface{} + require.NoError(t, json.Unmarshal(parsedJWT.UnsafePayloadWithoutVerification(), &tokenClaims)) + require.Contains(t, tokenClaims, "iat") + require.Contains(t, tokenClaims, "rat") + require.Contains(t, tokenClaims, "jti") + require.Len(t, tokenClaims["aud"], 1) + require.Contains(t, tokenClaims["aud"], test.requestedAudience) + require.Equal(t, goodSubject, tokenClaims["sub"]) + require.Equal(t, goodIssuer, tokenClaims["iss"]) + + // Assert that nothing in storage has been modified. + newSecrets, err := secrets.List(context.Background(), metav1.ListOptions{}) + require.NoError(t, err) + require.ElementsMatch(t, existingSecrets.Items, newSecrets.Items) + }) + } +} + type refreshRequestInputs struct { modifyTokenRequest func(tokenRequest *http.Request, refreshToken string, accessToken string) want tokenEndpointResponseExpectedValues @@ -710,25 +939,25 @@ func TestRefreshGrant(t *testing.T) { }}, }, { - name: "when the refresh request removes a scope which was originally granted from the list of requested scopes then it is ignored", + name: "when the refresh request removes a scope which was originally granted from the list of requested scopes then it is granted anyway", authcodeExchange: authcodeExchangeInputs{ - modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access") }, + modifyAuthRequest: func(r *http.Request) { r.Form.Set("scope", "openid offline_access pinniped.sts.unrestricted") }, 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"}, + wantRequestedScopes: []string{"openid", "offline_access", "pinniped.sts.unrestricted"}, + wantGrantedScopes: []string{"openid", "offline_access", "pinniped.sts.unrestricted"}, }, }, refreshRequest: refreshRequestInputs{ modifyTokenRequest: func(r *http.Request, refreshToken string, accessToken string) { - r.Body = happyRefreshRequestBody(refreshToken).WithScope("").ReadCloser() // TODO FIX ME. WE NEED ANOTHER VALID SCOPE ON THIS CLIENT TO WRITE THIS TEST. + r.Body = happyRefreshRequestBody(refreshToken).WithScope("openid").ReadCloser() // do not ask for "pinniped.sts.unrestricted" again }, 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"}, + wantRequestedScopes: []string{"openid", "offline_access", "pinniped.sts.unrestricted"}, + wantGrantedScopes: []string{"openid", "offline_access", "pinniped.sts.unrestricted"}, }}, }, { @@ -1122,6 +1351,38 @@ func makeHappyOauthHelper( return oauthHelper, authResponder.GetCode(), jwtSigningKey } +type singleUseJWKProvider struct { + jwks.DynamicJWKSProvider + calls int +} + +func (s *singleUseJWKProvider) GetJWKS(issuerName string) (jwks *jose.JSONWebKeySet, activeJWK *jose.JSONWebKey) { + s.calls++ + if s.calls > 1 { + return nil, nil + } + return s.DynamicJWKSProvider.GetJWKS(issuerName) +} + +func makeOauthHelperWithJWTKeyThatWorksOnlyOnce( + t *testing.T, + authRequest *http.Request, + store interface { + oauth2.TokenRevocationStorage + oauth2.CoreStorage + openid.OpenIDConnectRequestStorage + pkce.PKCERequestStorage + fosite.ClientManager + }, +) (fosite.OAuth2Provider, string, *ecdsa.PrivateKey) { + t.Helper() + + jwtSigningKey, jwkProvider := generateJWTSigningKeyAndJWKSProvider(t, goodIssuer) + oauthHelper := oidc.FositeOauth2Helper(store, goodIssuer, []byte(hmacSecret), &singleUseJWKProvider{DynamicJWKSProvider: jwkProvider}) + authResponder := simulateAuthEndpointHavingAlreadyRun(t, authRequest, oauthHelper) + return oauthHelper, authResponder.GetCode(), jwtSigningKey +} + func makeOauthHelperWithNilPrivateJWTSigningKey( t *testing.T, authRequest *http.Request, @@ -1162,6 +1423,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 new file mode 100644 index 00000000..36ddfb83 --- /dev/null +++ b/internal/oidc/token_exchange.go @@ -0,0 +1,141 @@ +// Copyright 2020 the Pinniped contributors. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "context" + "net/url" + + "github.com/coreos/go-oidc" + "github.com/ory/fosite" + "github.com/ory/fosite/compose" + "github.com/ory/fosite/handler/oauth2" + "github.com/ory/fosite/handler/openid" + "github.com/pkg/errors" +) + +const ( + tokenTypeAccessToken = "urn:ietf:params:oauth:token-type:access_token" //nolint: gosec + tokenTypeJWT = "urn:ietf:params:oauth:token-type:jwt" //nolint: gosec + pinnipedTokenExchangeScope = "pinniped.sts.unrestricted" //nolint: gosec +) + +type stsParams struct { + subjectAccessToken string + requestedAudience string +} + +func TokenExchangeFactory(config *compose.Config, storage interface{}, strategy interface{}) interface{} { + return &TokenExchangeHandler{ + idTokenStrategy: strategy.(openid.OpenIDConnectTokenStrategy), + accessTokenStrategy: strategy.(oauth2.AccessTokenStrategy), + accessTokenStorage: storage.(oauth2.AccessTokenStorage), + } +} + +type TokenExchangeHandler struct { + idTokenStrategy openid.OpenIDConnectTokenStrategy + accessTokenStrategy oauth2.AccessTokenStrategy + accessTokenStorage oauth2.AccessTokenStorage +} + +func (t *TokenExchangeHandler) HandleTokenEndpointRequest(ctx context.Context, requester fosite.AccessRequester) error { + if !(requester.GetGrantTypes().ExactOne("urn:ietf:params:oauth:grant-type:token-exchange")) { + return errors.WithStack(fosite.ErrUnknownRequest) + } + return nil +} + +func (t *TokenExchangeHandler) PopulateTokenEndpointResponse(ctx context.Context, requester fosite.AccessRequester, responder fosite.AccessResponder) error { + // Skip this request if it's for a different grant type. + if err := t.HandleTokenEndpointRequest(ctx, requester); err != nil { + return errors.WithStack(err) + } + + // Validate the basic RFC8693 parameters we support. + params, err := t.validateParams(requester.GetRequestForm()) + if err != nil { + return errors.WithStack(err) + } + + // Validate the incoming access token and lookup the information about the original authorize request. + originalRequester, err := t.validateAccessToken(ctx, requester, params.subjectAccessToken) + if err != nil { + return errors.WithStack(err) + } + + // Require that the incoming access token has the STS and OpenID scopes. + if !originalRequester.GetGrantedScopes().Has(pinnipedTokenExchangeScope) { + return errors.WithStack(fosite.ErrAccessDenied.WithHintf("missing the %q scope", pinnipedTokenExchangeScope)) + } + if !originalRequester.GetGrantedScopes().Has(oidc.ScopeOpenID) { + return errors.WithStack(fosite.ErrAccessDenied.WithHintf("missing the %q scope", oidc.ScopeOpenID)) + } + + // Use the original authorize request information, along with the requested audience, to mint a new JWT. + responseToken, err := t.mintJWT(ctx, originalRequester, params.requestedAudience) + if err != nil { + return errors.WithStack(err) + } + + // Format the response parameters according to RFC8693. + responder.SetAccessToken(responseToken) + responder.SetTokenType("N_A") + responder.SetExtra("issued_token_type", tokenTypeJWT) + return nil +} + +func (t *TokenExchangeHandler) mintJWT(ctx context.Context, requester fosite.Requester, audience string) (string, error) { + downscoped := fosite.NewAccessRequest(requester.GetSession()) + downscoped.Client.(*fosite.DefaultClient).ID = audience + return t.idTokenStrategy.GenerateIDToken(ctx, downscoped) +} + +func (t *TokenExchangeHandler) validateParams(params url.Values) (*stsParams, error) { + var result stsParams + + // Validate some required parameters. + result.requestedAudience = params.Get("audience") + if result.requestedAudience == "" { + return nil, fosite.ErrInvalidRequest.WithHint("missing audience parameter") + } + result.subjectAccessToken = params.Get("subject_token") + if result.subjectAccessToken == "" { + return nil, fosite.ErrInvalidRequest.WithHint("missing subject_token parameter") + } + + // Validate some parameters with hardcoded values we support. + if params.Get("subject_token_type") != tokenTypeAccessToken { + return nil, fosite.ErrInvalidRequest.WithHintf("unsupported subject_token_type parameter value, must be %q", tokenTypeAccessToken) + } + if params.Get("requested_token_type") != tokenTypeJWT { + return nil, fosite.ErrInvalidRequest.WithHintf("unsupported requested_token_type parameter value, must be %q", tokenTypeJWT) + } + + // Validate that none of these unsupported parameters were sent. These are optional and we do not currently support them. + for _, param := range []string{ + "resource", + "scope", + "actor_token", + "actor_token_type", + } { + if params.Get(param) != "" { + return nil, fosite.ErrInvalidRequest.WithHintf("unsupported parameter %s", param) + } + } + + return &result, nil +} + +func (t *TokenExchangeHandler) validateAccessToken(ctx context.Context, requester fosite.AccessRequester, accessToken string) (fosite.Requester, error) { + if err := t.accessTokenStrategy.ValidateAccessToken(ctx, requester, accessToken); err != nil { + return nil, errors.WithStack(err) + } + signature := t.accessTokenStrategy.AccessTokenSignature(accessToken) + originalRequester, err := t.accessTokenStorage.GetAccessTokenSession(ctx, signature, requester.GetSession()) + if err != nil { + return nil, fosite.ErrRequestUnauthorized.WithCause(err).WithHint("invalid subject_token") + } + return originalRequester, nil +} diff --git a/pkg/oidcclient/login.go b/pkg/oidcclient/login.go index 62c380c9..98146fa5 100644 --- a/pkg/oidcclient/login.go +++ b/pkg/oidcclient/login.go @@ -338,11 +338,9 @@ func (h *handlerState) tokenExchangeRFC8693(baseToken *oidctypes.Token) (*oidcty return nil, err } - // Use the base access token to authenticate our request. This will populate the "authorization" header. - client := oauth2.NewClient(h.ctx, oauth2.StaticTokenSource(&oauth2.Token{AccessToken: baseToken.AccessToken.Token})) - // Form the HTTP POST request with the parameters specified by RFC8693. reqBody := strings.NewReader(url.Values{ + "client_id": []string{h.clientID}, "grant_type": []string{"urn:ietf:params:oauth:grant-type:token-exchange"}, "audience": []string{h.requestedAudience}, "subject_token": []string{baseToken.AccessToken.Token}, @@ -356,7 +354,7 @@ func (h *handlerState) tokenExchangeRFC8693(baseToken *oidctypes.Token) (*oidcty req.Header.Set("content-type", "application/x-www-form-urlencoded") // Perform the request. - resp, err := client.Do(req) + resp, err := h.httpClient.Do(req) if err != nil { return nil, err } diff --git a/pkg/oidcclient/login_test.go b/pkg/oidcclient/login_test.go index 04c0b4b2..10daf6ab 100644 --- a/pkg/oidcclient/login_test.go +++ b/pkg/oidcclient/login_test.go @@ -152,6 +152,11 @@ func TestLogin(t *testing.T) { } case "urn:ietf:params:oauth:grant-type:token-exchange": + if r.Form.Get("client_id") != "test-client-id" { + http.Error(w, "bad client_id", http.StatusBadRequest) + return + } + switch r.Form.Get("audience") { case "test-audience-produce-invalid-http-response": http.Redirect(w, r, "%", http.StatusTemporaryRedirect) diff --git a/test/integration/supervisor_login_test.go b/test/integration/supervisor_login_test.go index 65e90791..221978d4 100644 --- a/test/integration/supervisor_login_test.go +++ b/test/integration/supervisor_login_test.go @@ -8,6 +8,7 @@ import ( "crypto/tls" "crypto/x509/pkix" "encoding/base64" + "encoding/json" "net/http" "net/http/httptest" "net/url" @@ -125,7 +126,7 @@ func TestSupervisorLogin(t *testing.T) { ClientID: "pinniped-cli", Endpoint: discovery.Endpoint(), RedirectURL: localCallbackServer.URL, - Scopes: []string{"openid"}, + Scopes: []string{"openid", "pinniped.sts.unrestricted", "offline_access"}, } // Build a valid downstream authorize URL for the supervisor. @@ -159,7 +160,7 @@ func TestSupervisorLogin(t *testing.T) { callback := localCallbackServer.waitForCallback(10 * time.Second) t.Logf("got callback request: %s", library.MaskTokens(callback.URL.String())) require.Equal(t, stateParam.String(), callback.URL.Query().Get("state")) - require.Equal(t, "openid", callback.URL.Query().Get("scope")) + require.ElementsMatch(t, []string{"openid", "pinniped.sts.unrestricted", "offline_access"}, strings.Split(callback.URL.Query().Get("scope"), " ")) authcode := callback.URL.Query().Get("code") require.NotEmpty(t, authcode) @@ -167,6 +168,40 @@ func TestSupervisorLogin(t *testing.T) { tokenResponse, err := downstreamOAuth2Config.Exchange(oidcHTTPClientContext, authcode, pkceParam.Verifier()) require.NoError(t, err) + expectedIDTokenClaims := []string{"iss", "exp", "sub", "aud", "auth_time", "iat", "jti", "nonce", "rat"} + verifyTokenResponse(t, tokenResponse, discovery, downstreamOAuth2Config, env.SupervisorTestUpstream.Issuer, nonceParam, expectedIDTokenClaims) + + // token exchange on the original token + doTokenExchange(t, &downstreamOAuth2Config, tokenResponse, httpClient, discovery) + + // Use the refresh token to get new tokens + refreshSource := downstreamOAuth2Config.TokenSource(oidcHTTPClientContext, &oauth2.Token{RefreshToken: tokenResponse.RefreshToken}) + refreshedTokenResponse, err := refreshSource.Token() + require.NoError(t, err) + + expectedIDTokenClaims = append(expectedIDTokenClaims, "at_hash") + verifyTokenResponse(t, refreshedTokenResponse, discovery, downstreamOAuth2Config, env.SupervisorTestUpstream.Issuer, "", expectedIDTokenClaims) + + require.NotEqual(t, tokenResponse.AccessToken, refreshedTokenResponse.AccessToken) + require.NotEqual(t, tokenResponse.RefreshToken, refreshedTokenResponse.RefreshToken) + require.NotEqual(t, tokenResponse.Extra("id_token"), refreshedTokenResponse.Extra("id_token")) + + // token exchange on the refreshed token + doTokenExchange(t, &downstreamOAuth2Config, refreshedTokenResponse, httpClient, discovery) +} + +func verifyTokenResponse( + t *testing.T, + tokenResponse *oauth2.Token, + discovery *oidc.Provider, + downstreamOAuth2Config oauth2.Config, + upstreamIssuerName string, + nonceParam nonce.Nonce, + expectedIDTokenClaims []string, +) { + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) + defer cancel() + // Verify the ID Token. rawIDToken, ok := tokenResponse.Extra("id_token").(string) require.True(t, ok, "expected to get an ID token but did not") @@ -175,7 +210,7 @@ func TestSupervisorLogin(t *testing.T) { require.NoError(t, err) // Check the claims of the ID token. - expectedSubjectPrefix := env.SupervisorTestUpstream.Issuer + "?sub=" + expectedSubjectPrefix := upstreamIssuerName + "?sub=" require.True(t, strings.HasPrefix(idToken.Subject, expectedSubjectPrefix)) require.Greater(t, len(idToken.Subject), len(expectedSubjectPrefix), "the ID token Subject should include the upstream user ID after the upstream issuer name") @@ -188,7 +223,7 @@ func TestSupervisorLogin(t *testing.T) { for k := range idTokenClaims { idTokenClaimNames = append(idTokenClaimNames, k) } - require.ElementsMatch(t, []string{"iss", "exp", "sub", "aud", "auth_time", "iat", "jti", "nonce", "rat"}, idTokenClaimNames) + require.ElementsMatch(t, expectedIDTokenClaims, idTokenClaimNames) // Some light verification of the other tokens that were returned. require.NotEmpty(t, tokenResponse.AccessToken) @@ -196,7 +231,7 @@ func TestSupervisorLogin(t *testing.T) { require.NotZero(t, tokenResponse.Expiry) testutil.RequireTimeInDelta(t, time.Now().UTC().Add(time.Minute*5), tokenResponse.Expiry, time.Second*30) - require.Empty(t, tokenResponse.RefreshToken) // for now, until the next user story :) + require.NotEmpty(t, tokenResponse.RefreshToken) } func startLocalCallbackServer(t *testing.T) *localCallbackServer { @@ -226,3 +261,41 @@ func (s *localCallbackServer) waitForCallback(timeout time.Duration) *http.Reque return nil } } + +func doTokenExchange(t *testing.T, config *oauth2.Config, tokenResponse *oauth2.Token, httpClient *http.Client, provider *oidc.Provider) { + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) + defer cancel() + + // Form the HTTP POST request with the parameters specified by RFC8693. + reqBody := strings.NewReader(url.Values{ + "grant_type": []string{"urn:ietf:params:oauth:grant-type:token-exchange"}, + "audience": []string{"cluster-1234"}, + "client_id": []string{config.ClientID}, + "subject_token": []string{tokenResponse.AccessToken}, + "subject_token_type": []string{"urn:ietf:params:oauth:token-type:access_token"}, + "requested_token_type": []string{"urn:ietf:params:oauth:token-type:jwt"}, + }.Encode()) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, config.Endpoint.TokenURL, reqBody) + require.NoError(t, err) + req.Header.Set("content-type", "application/x-www-form-urlencoded") + + resp, err := httpClient.Do(req) + require.NoError(t, err) + defer func() { _ = resp.Body.Close() }() + var respBody struct { + AccessToken string `json:"access_token"` + IssuedTokenType string `json:"issued_token_type"` + TokenType string `json:"token_type"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&respBody)) + + var clusterVerifier = provider.Verifier(&oidc.Config{ClientID: "cluster-1234"}) + exchangedToken, err := clusterVerifier.Verify(ctx, respBody.AccessToken) + require.NoError(t, err) + + var claims map[string]interface{} + require.NoError(t, exchangedToken.Claims(&claims)) + indentedClaims, err := json.MarshalIndent(claims, " ", " ") + require.NoError(t, err) + t.Logf("exchanged token claims:\n%s", string(indentedClaims)) +}