Merge pull request #267 from vmware-tanzu/token-exchange-endpoint
Implement RFC8693 token exchange handler in the supervisor
This commit is contained in:
commit
2ddba8d825
@ -127,14 +127,12 @@ func (s *secretsStorage) DeleteByLabel(ctx context.Context, labelName string, la
|
|||||||
}.String(),
|
}.String(),
|
||||||
})
|
})
|
||||||
if err != nil {
|
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)
|
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
|
// TODO try to delete all of the items and consolidate all of the errors and return them all
|
||||||
for _, secret := range list.Items {
|
for _, secret := range list.Items {
|
||||||
err = s.secrets.Delete(ctx, secret.Name, metav1.DeleteOptions{})
|
err = s.secrets.Delete(ctx, secret.Name, metav1.DeleteOptions{})
|
||||||
if err != nil {
|
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)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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.
|
// 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)
|
oidc.GrantScopeIfRequested(authorizeRequester, coreosoidc.ScopeOfflineAccess)
|
||||||
|
|
||||||
|
// Grant the Pinniped STS scope if requested.
|
||||||
|
oidc.GrantScopeIfRequested(authorizeRequester, "pinniped.sts.unrestricted")
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
_, err = oauthHelper.NewAuthorizeResponse(r.Context(), authorizeRequester, &openid.DefaultSession{
|
_, err = oauthHelper.NewAuthorizeResponse(r.Context(), authorizeRequester, &openid.DefaultSession{
|
||||||
Claims: &jwt.IDTokenClaims{
|
Claims: &jwt.IDTokenClaims{
|
||||||
|
@ -71,9 +71,10 @@ func NewHandler(
|
|||||||
return httperr.New(http.StatusBadRequest, "error using state downstream auth params")
|
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.ScopeOpenID)
|
||||||
oidc.GrantScopeIfRequested(authorizeRequester, coreosoidc.ScopeOfflineAccess)
|
oidc.GrantScopeIfRequested(authorizeRequester, coreosoidc.ScopeOfflineAccess)
|
||||||
|
oidc.GrantScopeIfRequested(authorizeRequester, "pinniped.sts.unrestricted")
|
||||||
|
|
||||||
token, err := upstreamIDPConfig.ExchangeAuthcodeAndValidateTokens(
|
token, err := upstreamIDPConfig.ExchangeAuthcodeAndValidateTokens(
|
||||||
r.Context(),
|
r.Context(),
|
||||||
|
@ -27,8 +27,8 @@ func TestNullStorage_GetClient(t *testing.T) {
|
|||||||
Public: true,
|
Public: true,
|
||||||
RedirectURIs: []string{"http://127.0.0.1/callback"},
|
RedirectURIs: []string{"http://127.0.0.1/callback"},
|
||||||
ResponseTypes: []string{"code"},
|
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"},
|
Scopes: []string{"openid", "offline_access", "profile", "email", "pinniped.sts.unrestricted"},
|
||||||
},
|
},
|
||||||
TokenEndpointAuthMethod: "none",
|
TokenEndpointAuthMethod: "none",
|
||||||
},
|
},
|
||||||
|
@ -84,8 +84,8 @@ func PinnipedCLIOIDCClient() *fosite.DefaultOpenIDConnectClient {
|
|||||||
Public: true,
|
Public: true,
|
||||||
RedirectURIs: []string{"http://127.0.0.1/callback"},
|
RedirectURIs: []string{"http://127.0.0.1/callback"},
|
||||||
ResponseTypes: []string{"code"},
|
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{coreosoidc.ScopeOpenID, coreosoidc.ScopeOfflineAccess, "profile", "email"},
|
Scopes: []string{coreosoidc.ScopeOpenID, coreosoidc.ScopeOfflineAccess, "profile", "email", "pinniped.sts.unrestricted"},
|
||||||
},
|
},
|
||||||
TokenEndpointAuthMethod: "none",
|
TokenEndpointAuthMethod: "none",
|
||||||
}
|
}
|
||||||
@ -131,6 +131,7 @@ func FositeOauth2Helper(
|
|||||||
compose.OpenIDConnectExplicitFactory,
|
compose.OpenIDConnectExplicitFactory,
|
||||||
compose.OpenIDConnectRefreshFactory,
|
compose.OpenIDConnectRefreshFactory,
|
||||||
compose.OAuth2PKCEFactory,
|
compose.OAuth2PKCEFactory,
|
||||||
|
TokenExchangeFactory,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -29,6 +29,7 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"gopkg.in/square/go-jose.v2"
|
"gopkg.in/square/go-jose.v2"
|
||||||
josejwt "gopkg.in/square/go-jose.v2/jwt"
|
josejwt "gopkg.in/square/go-jose.v2/jwt"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
"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"
|
v1 "k8s.io/client-go/kubernetes/typed/core/v1"
|
||||||
@ -219,6 +220,19 @@ var (
|
|||||||
"redirect_uri": {goodRedirectURI},
|
"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 {
|
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 {
|
type refreshRequestInputs struct {
|
||||||
modifyTokenRequest func(tokenRequest *http.Request, refreshToken string, accessToken string)
|
modifyTokenRequest func(tokenRequest *http.Request, refreshToken string, accessToken string)
|
||||||
want tokenEndpointResponseExpectedValues
|
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{
|
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{
|
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"},
|
wantRequestedScopes: []string{"openid", "offline_access", "pinniped.sts.unrestricted"},
|
||||||
wantGrantedScopes: []string{"openid", "offline_access"},
|
wantGrantedScopes: []string{"openid", "offline_access", "pinniped.sts.unrestricted"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
refreshRequest: refreshRequestInputs{
|
refreshRequest: refreshRequestInputs{
|
||||||
modifyTokenRequest: func(r *http.Request, refreshToken string, accessToken string) {
|
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{
|
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"},
|
wantRequestedScopes: []string{"openid", "offline_access", "pinniped.sts.unrestricted"},
|
||||||
wantGrantedScopes: []string{"openid", "offline_access"},
|
wantGrantedScopes: []string{"openid", "offline_access", "pinniped.sts.unrestricted"},
|
||||||
}},
|
}},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -1122,6 +1351,38 @@ func makeHappyOauthHelper(
|
|||||||
return oauthHelper, authResponder.GetCode(), jwtSigningKey
|
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(
|
func makeOauthHelperWithNilPrivateJWTSigningKey(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
authRequest *http.Request,
|
authRequest *http.Request,
|
||||||
@ -1162,6 +1423,9 @@ func simulateAuthEndpointHavingAlreadyRun(t *testing.T, authRequest *http.Reques
|
|||||||
if strings.Contains(authRequest.Form.Get("scope"), "offline_access") {
|
if strings.Contains(authRequest.Form.Get("scope"), "offline_access") {
|
||||||
authRequester.GrantScope("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)
|
authResponder, err := oauthHelper.NewAuthorizeResponse(ctx, authRequester, session)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
return authResponder
|
return authResponder
|
||||||
|
141
internal/oidc/token_exchange.go
Normal file
141
internal/oidc/token_exchange.go
Normal file
@ -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
|
||||||
|
}
|
@ -338,11 +338,9 @@ func (h *handlerState) tokenExchangeRFC8693(baseToken *oidctypes.Token) (*oidcty
|
|||||||
return nil, err
|
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.
|
// Form the HTTP POST request with the parameters specified by RFC8693.
|
||||||
reqBody := strings.NewReader(url.Values{
|
reqBody := strings.NewReader(url.Values{
|
||||||
|
"client_id": []string{h.clientID},
|
||||||
"grant_type": []string{"urn:ietf:params:oauth:grant-type:token-exchange"},
|
"grant_type": []string{"urn:ietf:params:oauth:grant-type:token-exchange"},
|
||||||
"audience": []string{h.requestedAudience},
|
"audience": []string{h.requestedAudience},
|
||||||
"subject_token": []string{baseToken.AccessToken.Token},
|
"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")
|
req.Header.Set("content-type", "application/x-www-form-urlencoded")
|
||||||
|
|
||||||
// Perform the request.
|
// Perform the request.
|
||||||
resp, err := client.Do(req)
|
resp, err := h.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -152,6 +152,11 @@ func TestLogin(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case "urn:ietf:params:oauth:grant-type:token-exchange":
|
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") {
|
switch r.Form.Get("audience") {
|
||||||
case "test-audience-produce-invalid-http-response":
|
case "test-audience-produce-invalid-http-response":
|
||||||
http.Redirect(w, r, "%", http.StatusTemporaryRedirect)
|
http.Redirect(w, r, "%", http.StatusTemporaryRedirect)
|
||||||
|
@ -8,6 +8,7 @@ import (
|
|||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"crypto/x509/pkix"
|
"crypto/x509/pkix"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"net/url"
|
"net/url"
|
||||||
@ -125,7 +126,7 @@ func TestSupervisorLogin(t *testing.T) {
|
|||||||
ClientID: "pinniped-cli",
|
ClientID: "pinniped-cli",
|
||||||
Endpoint: discovery.Endpoint(),
|
Endpoint: discovery.Endpoint(),
|
||||||
RedirectURL: localCallbackServer.URL,
|
RedirectURL: localCallbackServer.URL,
|
||||||
Scopes: []string{"openid"},
|
Scopes: []string{"openid", "pinniped.sts.unrestricted", "offline_access"},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build a valid downstream authorize URL for the supervisor.
|
// Build a valid downstream authorize URL for the supervisor.
|
||||||
@ -159,7 +160,7 @@ func TestSupervisorLogin(t *testing.T) {
|
|||||||
callback := localCallbackServer.waitForCallback(10 * time.Second)
|
callback := localCallbackServer.waitForCallback(10 * time.Second)
|
||||||
t.Logf("got callback request: %s", library.MaskTokens(callback.URL.String()))
|
t.Logf("got callback request: %s", library.MaskTokens(callback.URL.String()))
|
||||||
require.Equal(t, stateParam.String(), callback.URL.Query().Get("state"))
|
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")
|
authcode := callback.URL.Query().Get("code")
|
||||||
require.NotEmpty(t, authcode)
|
require.NotEmpty(t, authcode)
|
||||||
|
|
||||||
@ -167,6 +168,40 @@ func TestSupervisorLogin(t *testing.T) {
|
|||||||
tokenResponse, err := downstreamOAuth2Config.Exchange(oidcHTTPClientContext, authcode, pkceParam.Verifier())
|
tokenResponse, err := downstreamOAuth2Config.Exchange(oidcHTTPClientContext, authcode, pkceParam.Verifier())
|
||||||
require.NoError(t, err)
|
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.
|
// Verify the ID Token.
|
||||||
rawIDToken, ok := tokenResponse.Extra("id_token").(string)
|
rawIDToken, ok := tokenResponse.Extra("id_token").(string)
|
||||||
require.True(t, ok, "expected to get an ID token but did not")
|
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)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Check the claims of the ID token.
|
// Check the claims of the ID token.
|
||||||
expectedSubjectPrefix := env.SupervisorTestUpstream.Issuer + "?sub="
|
expectedSubjectPrefix := upstreamIssuerName + "?sub="
|
||||||
require.True(t, strings.HasPrefix(idToken.Subject, expectedSubjectPrefix))
|
require.True(t, strings.HasPrefix(idToken.Subject, expectedSubjectPrefix))
|
||||||
require.Greater(t, len(idToken.Subject), len(expectedSubjectPrefix),
|
require.Greater(t, len(idToken.Subject), len(expectedSubjectPrefix),
|
||||||
"the ID token Subject should include the upstream user ID after the upstream issuer name")
|
"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 {
|
for k := range idTokenClaims {
|
||||||
idTokenClaimNames = append(idTokenClaimNames, k)
|
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.
|
// Some light verification of the other tokens that were returned.
|
||||||
require.NotEmpty(t, tokenResponse.AccessToken)
|
require.NotEmpty(t, tokenResponse.AccessToken)
|
||||||
@ -196,7 +231,7 @@ func TestSupervisorLogin(t *testing.T) {
|
|||||||
require.NotZero(t, tokenResponse.Expiry)
|
require.NotZero(t, tokenResponse.Expiry)
|
||||||
testutil.RequireTimeInDelta(t, time.Now().UTC().Add(time.Minute*5), tokenResponse.Expiry, time.Second*30)
|
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 {
|
func startLocalCallbackServer(t *testing.T) *localCallbackServer {
|
||||||
@ -226,3 +261,41 @@ func (s *localCallbackServer) waitForCallback(timeout time.Duration) *http.Reque
|
|||||||
return nil
|
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))
|
||||||
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user