From 8e298791d7e6d0d6fdd475c6e2cb1fd94a9bfff7 Mon Sep 17 00:00:00 2001 From: Emil Bektimirov Date: Sun, 19 Feb 2023 14:57:46 +0100 Subject: [PATCH] feat: Token Exchange (RFC 8693) (#255) This change implements OAuth2 Token Exchange in OP according to RFC 8693 (and client code) Some implementation details: - OP parses and verifies subject/actor tokens natively if they were issued by OP - Third-party tokens verification is also possible by implementing additional storage interface - Token exchange can issue only OP's native tokens (id_token, access_token and refresh_token) with static issuer --- example/client/app/app.go | 25 ++ example/server/storage/client.go | 4 +- example/server/storage/oidc.go | 3 + example/server/storage/storage.go | 135 +++++++- example/server/storage/user.go | 15 + pkg/client/client.go | 15 + pkg/client/{rp => }/integration_test.go | 160 ++++++--- pkg/client/rs/resource_server.go | 5 + pkg/client/tokenexchange/tokenexchange.go | 127 +++++++ pkg/oidc/token.go | 15 + pkg/oidc/token_request.go | 40 ++- pkg/op/op.go | 3 +- pkg/op/storage.go | 43 +++ pkg/op/token.go | 32 +- pkg/op/token_exchange.go | 396 +++++++++++++++++++++- pkg/op/token_request.go | 2 + 16 files changed, 961 insertions(+), 59 deletions(-) rename pkg/client/{rp => }/integration_test.go (72%) create mode 100644 pkg/client/tokenexchange/tokenexchange.go diff --git a/example/client/app/app.go b/example/client/app/app.go index 3e5f19c..97e8948 100644 --- a/example/client/app/app.go +++ b/example/client/app/app.go @@ -80,6 +80,31 @@ func main() { // w.Write(data) //} + // you can also try token exchange flow + // + // requestTokenExchange := func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens, state string, rp rp.RelyingParty, info oidc.UserInfo) { + // data := make(url.Values) + // data.Set("grant_type", string(oidc.GrantTypeTokenExchange)) + // data.Set("requested_token_type", string(oidc.IDTokenType)) + // data.Set("subject_token", tokens.RefreshToken) + // data.Set("subject_token_type", string(oidc.RefreshTokenType)) + // data.Add("scope", "profile custom_scope:impersonate:id2") + + // client := &http.Client{} + // r2, _ := http.NewRequest(http.MethodPost, issuer+"/oauth/token", strings.NewReader(data.Encode())) + // // r2.Header.Add("Authorization", "Basic "+"d2ViOnNlY3JldA==") + // r2.Header.Add("Content-Type", "application/x-www-form-urlencoded") + // r2.SetBasicAuth("web", "secret") + + // resp, _ := client.Do(r2) + // fmt.Println(resp.Status) + + // b, _ := io.ReadAll(resp.Body) + // resp.Body.Close() + + // w.Write(b) + // } + // register the CodeExchangeHandler at the callbackPath // the CodeExchangeHandler handles the auth response, creates the token request and calls the callback function // with the returned tokens from the token endpoint diff --git a/example/server/storage/client.go b/example/server/storage/client.go index bd6ff3c..5a5b33f 100644 --- a/example/server/storage/client.go +++ b/example/server/storage/client.go @@ -158,7 +158,7 @@ func NativeClient(id string, redirectURIs ...string) *Client { loginURL: defaultLoginURL, responseTypes: []oidc.ResponseType{oidc.ResponseTypeCode}, grantTypes: []oidc.GrantType{oidc.GrantTypeCode, oidc.GrantTypeRefreshToken}, - accessTokenType: 0, + accessTokenType: op.AccessTokenTypeBearer, devMode: false, idTokenUserinfoClaimsAssertion: false, clockSkew: 0, @@ -184,7 +184,7 @@ func WebClient(id, secret string, redirectURIs ...string) *Client { loginURL: defaultLoginURL, responseTypes: []oidc.ResponseType{oidc.ResponseTypeCode}, grantTypes: []oidc.GrantType{oidc.GrantTypeCode, oidc.GrantTypeRefreshToken}, - accessTokenType: 0, + accessTokenType: op.AccessTokenTypeBearer, devMode: false, idTokenUserinfoClaimsAssertion: false, clockSkew: 0, diff --git a/example/server/storage/oidc.go b/example/server/storage/oidc.go index 505ab72..83db739 100644 --- a/example/server/storage/oidc.go +++ b/example/server/storage/oidc.go @@ -16,6 +16,9 @@ const ( // CustomClaim is an example for how to return custom claims with this library CustomClaim = "custom_claim" + + // CustomScopeImpersonatePrefix is an example scope prefix for passing user id to impersonate using token exchage + CustomScopeImpersonatePrefix = "custom_scope:impersonate:" ) type AuthRequest struct { diff --git a/example/server/storage/storage.go b/example/server/storage/storage.go index 64bffc8..662132c 100644 --- a/example/server/storage/storage.go +++ b/example/server/storage/storage.go @@ -4,8 +4,10 @@ import ( "context" "crypto/rand" "crypto/rsa" + "errors" "fmt" "math/big" + "strings" "sync" "time" @@ -213,11 +215,14 @@ func (s *Storage) DeleteAuthRequest(ctx context.Context, id string) error { // it will be called for all requests able to return an access token (Authorization Code Flow, Implicit Flow, JWT Profile, ...) func (s *Storage) CreateAccessToken(ctx context.Context, request op.TokenRequest) (string, time.Time, error) { var applicationID string - // if authenticated for an app (auth code / implicit flow) we must save the client_id to the token - authReq, ok := request.(*AuthRequest) - if ok { - applicationID = authReq.ApplicationID + switch req := request.(type) { + case *AuthRequest: + // if authenticated for an app (auth code / implicit flow) we must save the client_id to the token + applicationID = req.ApplicationID + case op.TokenExchangeRequest: + applicationID = req.GetClientID() } + token, err := s.accessToken(applicationID, "", request.GetSubject(), request.GetAudience(), request.GetScopes()) if err != nil { return "", time.Time{}, err @@ -228,6 +233,11 @@ func (s *Storage) CreateAccessToken(ctx context.Context, request op.TokenRequest // CreateAccessAndRefreshTokens implements the op.Storage interface // it will be called for all requests able to return an access and refresh token (Authorization Code Flow, Refresh Token Request) func (s *Storage) CreateAccessAndRefreshTokens(ctx context.Context, request op.TokenRequest, currentRefreshToken string) (accessTokenID string, newRefreshToken string, expiration time.Time, err error) { + // generate tokens via token exchange flow if request is relevant + if teReq, ok := request.(op.TokenExchangeRequest); ok { + return s.exchangeRefreshToken(ctx, teReq) + } + // get the information depending on the request type / implementation applicationID, authTime, amr := getInfoFromRequest(request) @@ -258,6 +268,24 @@ func (s *Storage) CreateAccessAndRefreshTokens(ctx context.Context, request op.T return accessToken.ID, refreshToken, accessToken.Expiration, nil } +func (s *Storage) exchangeRefreshToken(ctx context.Context, request op.TokenExchangeRequest) (accessTokenID string, newRefreshToken string, expiration time.Time, err error) { + applicationID := request.GetClientID() + authTime := request.GetAuthTime() + + refreshTokenID := uuid.NewString() + accessToken, err := s.accessToken(applicationID, refreshTokenID, request.GetSubject(), request.GetAudience(), request.GetScopes()) + if err != nil { + return "", "", time.Time{}, err + } + + refreshToken, err := s.createRefreshToken(accessToken, nil, authTime) + if err != nil { + return "", "", time.Time{}, err + } + + return accessToken.ID, refreshToken, accessToken.Expiration, nil +} + // TokenRequestByRefreshToken implements the op.Storage interface // it will be called after parsing and validation of the refresh token request func (s *Storage) TokenRequestByRefreshToken(ctx context.Context, refreshToken string) (op.RefreshTokenRequest, error) { @@ -444,6 +472,10 @@ func (s *Storage) SetIntrospectionFromToken(ctx context.Context, introspection o // GetPrivateClaimsFromScopes implements the op.Storage interface // it will be called for the creation of a JWT access token to assert claims for custom scopes func (s *Storage) GetPrivateClaimsFromScopes(ctx context.Context, userID, clientID string, scopes []string) (claims map[string]interface{}, err error) { + return s.getPrivateClaimsFromScopes(ctx, userID, clientID, scopes) +} + +func (s *Storage) getPrivateClaimsFromScopes(ctx context.Context, userID, clientID string, scopes []string) (claims map[string]interface{}, err error) { for _, scope := range scopes { switch scope { case CustomScope: @@ -580,6 +612,101 @@ func (s *Storage) setUserinfo(ctx context.Context, userInfo oidc.UserInfoSetter, return nil } +// ValidateTokenExchangeRequest implements the op.TokenExchangeStorage interface +// it will be called to validate parsed Token Exchange Grant request +func (s *Storage) ValidateTokenExchangeRequest(ctx context.Context, request op.TokenExchangeRequest) error { + if request.GetRequestedTokenType() == "" { + request.SetRequestedTokenType(oidc.RefreshTokenType) + } + + // Just an example, some use cases might need this use case + if request.GetExchangeSubjectTokenType() == oidc.IDTokenType && request.GetRequestedTokenType() == oidc.RefreshTokenType { + return errors.New("exchanging id_token to refresh_token is not supported") + } + + // Check impersonation permissions + if request.GetExchangeActor() == "" && !s.userStore.GetUserByID(request.GetExchangeSubject()).IsAdmin { + return errors.New("user doesn't have impersonation permission") + } + + allowedScopes := make([]string, 0) + for _, scope := range request.GetScopes() { + if scope == oidc.ScopeAddress { + continue + } + + if strings.HasPrefix(scope, CustomScopeImpersonatePrefix) { + subject := strings.TrimPrefix(scope, CustomScopeImpersonatePrefix) + request.SetSubject(subject) + } + + allowedScopes = append(allowedScopes, scope) + } + + request.SetCurrentScopes(allowedScopes) + + return nil +} + +// ValidateTokenExchangeRequest implements the op.TokenExchangeStorage interface +// Common use case is to store request for audit purposes. For this example we skip the storing. +func (s *Storage) CreateTokenExchangeRequest(ctx context.Context, request op.TokenExchangeRequest) error { + return nil +} + +// GetPrivateClaimsFromScopesForTokenExchange implements the op.TokenExchangeStorage interface +// it will be called for the creation of an exchanged JWT access token to assert claims for custom scopes +// plus adding token exchange specific claims related to delegation or impersonation +func (s *Storage) GetPrivateClaimsFromTokenExchangeRequest(ctx context.Context, request op.TokenExchangeRequest) (claims map[string]interface{}, err error) { + claims, err = s.getPrivateClaimsFromScopes(ctx, "", request.GetClientID(), request.GetScopes()) + if err != nil { + return nil, err + } + + for k, v := range s.getTokenExchangeClaims(ctx, request) { + claims = appendClaim(claims, k, v) + } + + return claims, nil +} + +// SetUserinfoFromScopesForTokenExchange implements the op.TokenExchangeStorage interface +// it will be called for the creation of an id_token - we are using the same private function as for other flows, +// plus adding token exchange specific claims related to delegation or impersonation +func (s *Storage) SetUserinfoFromTokenExchangeRequest(ctx context.Context, userinfo oidc.UserInfoSetter, request op.TokenExchangeRequest) error { + err := s.setUserinfo(ctx, userinfo, request.GetSubject(), request.GetClientID(), request.GetScopes()) + if err != nil { + return err + } + + for k, v := range s.getTokenExchangeClaims(ctx, request) { + userinfo.AppendClaims(k, v) + } + + return nil +} + +func (s *Storage) getTokenExchangeClaims(ctx context.Context, request op.TokenExchangeRequest) (claims map[string]interface{}) { + for _, scope := range request.GetScopes() { + switch { + case strings.HasPrefix(scope, CustomScopeImpersonatePrefix) && request.GetExchangeActor() == "": + // Set actor subject claim for impersonation flow + claims = appendClaim(claims, "act", map[string]interface{}{ + "sub": request.GetExchangeSubject(), + }) + } + } + + // Set actor subject claim for delegation flow + // if request.GetExchangeActor() != "" { + // claims = appendClaim(claims, "act", map[string]interface{}{ + // "sub": request.GetExchangeActor(), + // }) + // } + + return claims +} + // getInfoFromRequest returns the clientID, authTime and amr depending on the op.TokenRequest type / implementation func getInfoFromRequest(req op.TokenRequest) (clientID string, authTime time.Time, amr []string) { authReq, ok := req.(*AuthRequest) // Code Flow (with scope offline_access) diff --git a/example/server/storage/user.go b/example/server/storage/user.go index 82c06d0..173daef 100644 --- a/example/server/storage/user.go +++ b/example/server/storage/user.go @@ -18,6 +18,7 @@ type User struct { Phone string PhoneVerified bool PreferredLanguage language.Tag + IsAdmin bool } type Service struct { @@ -49,6 +50,20 @@ func NewUserStore(issuer string) UserStore { Phone: "", PhoneVerified: false, PreferredLanguage: language.German, + IsAdmin: true, + }, + "id2": { + ID: "id2", + Username: "test-user2", + Password: "verysecure", + FirstName: "Test", + LastName: "User2", + Email: "test-user2@zitadel.ch", + EmailVerified: true, + Phone: "", + PhoneVerified: false, + PreferredLanguage: language.German, + IsAdmin: false, }, }, } diff --git a/pkg/client/client.go b/pkg/client/client.go index eaa1a80..077baf2 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -90,6 +90,9 @@ func CallEndSessionEndpoint(request interface{}, authFn interface{}, caller EndS return http.ErrUseLastResponse } resp, err := client.Do(req) + if err != nil { + return nil, err + } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 400 { body, err := io.ReadAll(resp.Body) @@ -148,6 +151,18 @@ func CallRevokeEndpoint(request interface{}, authFn interface{}, caller RevokeCa return nil } +func CallTokenExchangeEndpoint(request interface{}, authFn interface{}, caller TokenEndpointCaller) (resp *oidc.TokenExchangeResponse, err error) { + req, err := httphelper.FormRequest(caller.TokenEndpoint(), request, Encoder, authFn) + if err != nil { + return nil, err + } + tokenRes := new(oidc.TokenExchangeResponse) + if err := httphelper.HttpRequest(caller.HttpClient(), req, &tokenRes); err != nil { + return nil, err + } + return tokenRes, nil +} + func NewSignerFromPrivateKeyByte(key []byte, keyID string) (jose.Signer, error) { privateKey, err := crypto.BytesToPrivateKey(key) if err != nil { diff --git a/pkg/client/rp/integration_test.go b/pkg/client/integration_test.go similarity index 72% rename from pkg/client/rp/integration_test.go rename to pkg/client/integration_test.go index e29ddd3..e89004a 100644 --- a/pkg/client/rp/integration_test.go +++ b/pkg/client/integration_test.go @@ -1,4 +1,4 @@ -package rp_test +package client_test import ( "bytes" @@ -18,9 +18,12 @@ import ( "github.com/jeremija/gosubmit" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/zitadel/oidc/v2/example/server/exampleop" "github.com/zitadel/oidc/v2/example/server/storage" "github.com/zitadel/oidc/v2/pkg/client/rp" + "github.com/zitadel/oidc/v2/pkg/client/rs" + "github.com/zitadel/oidc/v2/pkg/client/tokenexchange" httphelper "github.com/zitadel/oidc/v2/pkg/http" "github.com/zitadel/oidc/v2/pkg/oidc" ) @@ -36,12 +39,120 @@ func TestRelyingPartySession(t *testing.T) { t.Logf("auth server at %s", opServer.URL) dh.Handler = exampleop.SetupServer(ctx, opServer.URL, exampleStorage) - localURL, err := url.Parse(targetURL + "/login?requestID=1234") - require.NoError(t, err, "local url") + seed := rand.New(rand.NewSource(int64(os.Getpid()) + time.Now().UnixNano())) + clientID := t.Name() + "-" + strconv.FormatInt(seed.Int63(), 25) + + t.Log("------- run authorization code flow ------") + provider, _, refreshToken, idToken := RunAuthorizationCodeFlow(t, opServer, clientID, "secret") + + t.Log("------- refresh tokens ------") + + newTokens, err := rp.RefreshAccessToken(provider, refreshToken, "", "") + require.NoError(t, err, "refresh token") + assert.NotNil(t, newTokens, "access token") + t.Logf("new access token %s", newTokens.AccessToken) + t.Logf("new refresh token %s", newTokens.RefreshToken) + t.Logf("new token type %s", newTokens.TokenType) + t.Logf("new expiry %s", newTokens.Expiry.Format(time.RFC3339)) + require.NotEmpty(t, newTokens.AccessToken, "new accessToken") + + t.Log("------ end session (logout) ------") + + newLoc, err := rp.EndSession(provider, idToken, "", "") + require.NoError(t, err, "logout") + if newLoc != nil { + t.Logf("redirect to %s", newLoc) + } else { + t.Logf("no redirect") + } + + t.Log("------ attempt refresh again (should fail) ------") + t.Log("trying original refresh token", refreshToken) + _, err = rp.RefreshAccessToken(provider, refreshToken, "", "") + assert.Errorf(t, err, "refresh with original") + if newTokens.RefreshToken != "" { + t.Log("trying replacement refresh token", newTokens.RefreshToken) + _, err = rp.RefreshAccessToken(provider, newTokens.RefreshToken, "", "") + assert.Errorf(t, err, "refresh with replacement") + } +} + +func TestResourceServerTokenExchange(t *testing.T) { + t.Log("------- start example OP ------") + ctx := context.Background() + targetURL := "http://local-site" + exampleStorage := storage.NewStorage(storage.NewUserStore(targetURL)) + var dh deferredHandler + opServer := httptest.NewServer(&dh) + defer opServer.Close() + t.Logf("auth server at %s", opServer.URL) + dh.Handler = exampleop.SetupServer(ctx, opServer.URL, exampleStorage) seed := rand.New(rand.NewSource(int64(os.Getpid()) + time.Now().UnixNano())) clientID := t.Name() + "-" + strconv.FormatInt(seed.Int63(), 25) - client := storage.WebClient(clientID, "secret", targetURL) + clientSecret := "secret" + + t.Log("------- run authorization code flow ------") + provider, _, refreshToken, idToken := RunAuthorizationCodeFlow(t, opServer, clientID, clientSecret) + + resourceServer, err := rs.NewResourceServerClientCredentials(opServer.URL, clientID, clientSecret) + require.NoError(t, err, "new resource server") + + t.Log("------- exchage refresh tokens (impersonation) ------") + + tokenExchangeResponse, err := tokenexchange.ExchangeToken( + resourceServer, + refreshToken, + oidc.RefreshTokenType, + "", + "", + []string{}, + []string{}, + []string{"profile", "custom_scope:impersonate:id2"}, + oidc.RefreshTokenType, + ) + require.NoError(t, err, "refresh token") + require.NotNil(t, tokenExchangeResponse, "token exchange response") + assert.Equal(t, tokenExchangeResponse.IssuedTokenType, oidc.RefreshTokenType) + assert.NotEmpty(t, tokenExchangeResponse.AccessToken, "access token") + assert.NotEmpty(t, tokenExchangeResponse.RefreshToken, "refresh token") + assert.Equal(t, []string(tokenExchangeResponse.Scopes), []string{"profile", "custom_scope:impersonate:id2"}) + + t.Log("------ end session (logout) ------") + + newLoc, err := rp.EndSession(provider, idToken, "", "") + require.NoError(t, err, "logout") + if newLoc != nil { + t.Logf("redirect to %s", newLoc) + } else { + t.Logf("no redirect") + } + + t.Log("------- attempt exchage again (should fail) ------") + + tokenExchangeResponse, err = tokenexchange.ExchangeToken( + resourceServer, + refreshToken, + oidc.RefreshTokenType, + "", + "", + []string{}, + []string{}, + []string{"profile", "custom_scope:impersonate:id2"}, + oidc.RefreshTokenType, + ) + require.Error(t, err, "refresh token") + assert.Contains(t, err.Error(), "subject_token is invalid") + require.Nil(t, tokenExchangeResponse, "token exchange response") + +} + +func RunAuthorizationCodeFlow(t *testing.T, opServer *httptest.Server, clientID, clientSecret string) (provider rp.RelyingParty, accessToken, refreshToken, idToken string) { + targetURL := "http://local-site" + localURL, err := url.Parse(targetURL + "/login?requestID=1234") + require.NoError(t, err, "local url") + + client := storage.WebClient(clientID, clientSecret, targetURL) storage.RegisterClients(client) jar, err := cookiejar.New(nil) @@ -57,10 +168,10 @@ func TestRelyingPartySession(t *testing.T) { t.Log("------- create RP ------") key := []byte("test1234test1234") cookieHandler := httphelper.NewCookieHandler(key, key, httphelper.WithUnsecure()) - provider, err := rp.NewRelyingPartyOIDC( + provider, err = rp.NewRelyingPartyOIDC( opServer.URL, clientID, - "secret", + clientSecret, targetURL, []string{"openid", "email", "profile", "offline_access"}, rp.WithPKCE(cookieHandler), @@ -69,8 +180,10 @@ func TestRelyingPartySession(t *testing.T) { rp.WithSupportedSigningAlgorithms("RS256", "RS384", "RS512", "ES256", "ES384", "ES512"), ), ) + require.NoError(t, err, "new rp") t.Log("------- get redirect from local client (rp) to OP ------") + seed := rand.New(rand.NewSource(int64(os.Getpid()) + time.Now().UnixNano())) state := "state-" + strconv.FormatInt(seed.Int63(), 25) capturedW := httptest.NewRecorder() get := httptest.NewRequest("GET", localURL.String(), nil) @@ -124,7 +237,7 @@ func TestRelyingPartySession(t *testing.T) { t.Logf("setting cookie %s", cookie) } - var accessToken, refreshToken, idToken, email string + var email string redirect := func(w http.ResponseWriter, r *http.Request, tokens *oidc.Tokens, state string, rp rp.RelyingParty, info oidc.UserInfo) { require.NotNil(t, tokens, "tokens") require.NotNil(t, info, "info") @@ -137,7 +250,7 @@ func TestRelyingPartySession(t *testing.T) { refreshToken = tokens.RefreshToken idToken = tokens.IDToken email = info.GetEmail() - http.Redirect(w, r, targetURL, 302) + http.Redirect(w, r, targetURL, http.StatusFound) } rp.CodeExchangeHandler(rp.UserinfoCallback(redirect), provider)(capturedW, get) @@ -162,36 +275,7 @@ func TestRelyingPartySession(t *testing.T) { assert.NotEmpty(t, accessToken, "access token") assert.NotEmpty(t, email, "email") - t.Log("------- refresh tokens ------") - - newTokens, err := rp.RefreshAccessToken(provider, refreshToken, "", "") - require.NoError(t, err, "refresh token") - assert.NotNil(t, newTokens, "access token") - t.Logf("new access token %s", newTokens.AccessToken) - t.Logf("new refresh token %s", newTokens.RefreshToken) - t.Logf("new token type %s", newTokens.TokenType) - t.Logf("new expiry %s", newTokens.Expiry.Format(time.RFC3339)) - require.NotEmpty(t, newTokens.AccessToken, "new accessToken") - - t.Log("------ end session (logout) ------") - - newLoc, err := rp.EndSession(provider, idToken, "", "") - require.NoError(t, err, "logout") - if newLoc != nil { - t.Logf("redirect to %s", newLoc) - } else { - t.Logf("no redirect") - } - - t.Log("------ attempt refresh again (should fail) ------") - t.Log("trying original refresh token", refreshToken) - _, err = rp.RefreshAccessToken(provider, refreshToken, "", "") - assert.Errorf(t, err, "refresh with original") - if newTokens.RefreshToken != "" { - t.Log("trying replacement refresh token", newTokens.RefreshToken) - _, err = rp.RefreshAccessToken(provider, newTokens.RefreshToken, "", "") - assert.Errorf(t, err, "refresh with replacement") - } + return provider, accessToken, refreshToken, idToken } type deferredHandler struct { diff --git a/pkg/client/rs/resource_server.go b/pkg/client/rs/resource_server.go index 1d9860f..95a0121 100644 --- a/pkg/client/rs/resource_server.go +++ b/pkg/client/rs/resource_server.go @@ -13,6 +13,7 @@ import ( type ResourceServer interface { IntrospectionURL() string + TokenEndpoint() string HttpClient() *http.Client AuthFn() (interface{}, error) } @@ -29,6 +30,10 @@ func (r *resourceServer) IntrospectionURL() string { return r.introspectURL } +func (r *resourceServer) TokenEndpoint() string { + return r.tokenURL +} + func (r *resourceServer) HttpClient() *http.Client { return r.httpClient } diff --git a/pkg/client/tokenexchange/tokenexchange.go b/pkg/client/tokenexchange/tokenexchange.go new file mode 100644 index 0000000..1375f68 --- /dev/null +++ b/pkg/client/tokenexchange/tokenexchange.go @@ -0,0 +1,127 @@ +package tokenexchange + +import ( + "errors" + "net/http" + + "github.com/zitadel/oidc/v2/pkg/client" + httphelper "github.com/zitadel/oidc/v2/pkg/http" + "github.com/zitadel/oidc/v2/pkg/oidc" +) + +type TokenExchanger interface { + TokenEndpoint() string + HttpClient() *http.Client + AuthFn() (interface{}, error) +} + +type OAuthTokenExchange struct { + httpClient *http.Client + tokenEndpoint string + authFn func() (interface{}, error) +} + +func NewTokenExchanger(issuer string, options ...func(source *OAuthTokenExchange)) (TokenExchanger, error) { + return newOAuthTokenExchange(issuer, nil, options...) +} + +func NewTokenExchangerClientCredentials(issuer, clientID, clientSecret string, options ...func(source *OAuthTokenExchange)) (TokenExchanger, error) { + authorizer := func() (interface{}, error) { + return httphelper.AuthorizeBasic(clientID, clientSecret), nil + } + return newOAuthTokenExchange(issuer, authorizer, options...) +} + +func newOAuthTokenExchange(issuer string, authorizer func() (interface{}, error), options ...func(source *OAuthTokenExchange)) (*OAuthTokenExchange, error) { + te := &OAuthTokenExchange{ + httpClient: httphelper.DefaultHTTPClient, + } + for _, opt := range options { + opt(te) + } + + if te.tokenEndpoint == "" { + config, err := client.Discover(issuer, te.httpClient) + if err != nil { + return nil, err + } + + te.tokenEndpoint = config.TokenEndpoint + } + + if te.tokenEndpoint == "" { + return nil, errors.New("tokenURL is empty: please provide with either `WithStaticTokenEndpoint` or a discovery url") + } + + te.authFn = authorizer + + return te, nil +} + +func WithHTTPClient(client *http.Client) func(*OAuthTokenExchange) { + return func(source *OAuthTokenExchange) { + source.httpClient = client + } +} + +func WithStaticTokenEndpoint(issuer, tokenEndpoint string) func(*OAuthTokenExchange) { + return func(source *OAuthTokenExchange) { + source.tokenEndpoint = tokenEndpoint + } +} + +func (te *OAuthTokenExchange) TokenEndpoint() string { + return te.tokenEndpoint +} + +func (te *OAuthTokenExchange) HttpClient() *http.Client { + return te.httpClient +} + +func (te *OAuthTokenExchange) AuthFn() (interface{}, error) { + if te.authFn != nil { + return te.authFn() + } + + return nil, nil +} + +// ExchangeToken sends a token exchange request (rfc 8693) to te's token endpoint. +// SubjectToken and SubjectTokenType are required parameters. +func ExchangeToken( + te TokenExchanger, + SubjectToken string, + SubjectTokenType oidc.TokenType, + ActorToken string, + ActorTokenType oidc.TokenType, + Resource []string, + Audience []string, + Scopes []string, + RequestedTokenType oidc.TokenType, +) (*oidc.TokenExchangeResponse, error) { + if SubjectToken == "" { + return nil, errors.New("empty subject_token") + } + if SubjectTokenType == "" { + return nil, errors.New("empty subject_token_type") + } + + authFn, err := te.AuthFn() + if err != nil { + return nil, err + } + + request := oidc.TokenExchangeRequest{ + GrantType: oidc.GrantTypeTokenExchange, + SubjectToken: SubjectToken, + SubjectTokenType: SubjectTokenType, + ActorToken: ActorToken, + ActorTokenType: ActorTokenType, + Resource: Resource, + Audience: Audience, + Scopes: Scopes, + RequestedTokenType: RequestedTokenType, + } + + return client.CallTokenExchangeEndpoint(request, authFn, te) +} diff --git a/pkg/oidc/token.go b/pkg/oidc/token.go index 198049d..b538465 100644 --- a/pkg/oidc/token.go +++ b/pkg/oidc/token.go @@ -31,6 +31,7 @@ type AccessTokenClaims interface { GetSubject() string GetTokenID() string SetPrivateClaims(map[string]interface{}) + GetClaims() map[string]interface{} } type IDTokenClaims interface { @@ -151,6 +152,11 @@ func (a *accessTokenClaims) SetPrivateClaims(claims map[string]interface{}) { a.claims = claims } +// GetClaims implements the AccessTokenClaims interface +func (a *accessTokenClaims) GetClaims() map[string]interface{} { + return a.claims +} + func (a *accessTokenClaims) MarshalJSON() ([]byte, error) { type Alias accessTokenClaims s := &struct { @@ -612,3 +618,12 @@ func GenerateJWTProfileToken(assertion JWTProfileAssertionClaims) (string, error } return signedAssertion.CompactSerialize() } + +type TokenExchangeResponse struct { + AccessToken string `json:"access_token"` // Can be access token or ID token + IssuedTokenType TokenType `json:"issued_token_type"` + TokenType string `json:"token_type"` + ExpiresIn uint64 `json:"expires_in,omitempty"` + Scopes SpaceDelimitedArray `json:"scope,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` +} diff --git a/pkg/oidc/token_request.go b/pkg/oidc/token_request.go index 2b56535..6d8f186 100644 --- a/pkg/oidc/token_request.go +++ b/pkg/oidc/token_request.go @@ -40,6 +40,29 @@ var AllGrantTypes = []GrantType{ type GrantType string +const ( + AccessTokenType TokenType = "urn:ietf:params:oauth:token-type:access_token" + RefreshTokenType TokenType = "urn:ietf:params:oauth:token-type:refresh_token" + IDTokenType TokenType = "urn:ietf:params:oauth:token-type:id_token" + JWTTokenType TokenType = "urn:ietf:params:oauth:token-type:jwt" +) + +var AllTokenTypes = []TokenType{ + AccessTokenType, RefreshTokenType, IDTokenType, JWTTokenType, +} + +type TokenType string + +func (t TokenType) IsSupported() bool { + for _, tt := range AllTokenTypes { + if t == tt { + return true + } + } + + return false +} + type TokenRequest interface { // GrantType GrantType `schema:"grant_type"` GrantType() GrantType @@ -203,14 +226,15 @@ func (j *JWTTokenRequest) GetScopes() []string { } type TokenExchangeRequest struct { - subjectToken string `schema:"subject_token"` - subjectTokenType string `schema:"subject_token_type"` - actorToken string `schema:"actor_token"` - actorTokenType string `schema:"actor_token_type"` - resource []string `schema:"resource"` - audience Audience `schema:"audience"` - Scope SpaceDelimitedArray `schema:"scope"` - requestedTokenType string `schema:"requested_token_type"` + GrantType GrantType `schema:"grant_type"` + SubjectToken string `schema:"subject_token"` + SubjectTokenType TokenType `schema:"subject_token_type"` + ActorToken string `schema:"actor_token"` + ActorTokenType TokenType `schema:"actor_token_type"` + Resource []string `schema:"resource"` + Audience Audience `schema:"audience"` + Scopes SpaceDelimitedArray `schema:"scope"` + RequestedTokenType TokenType `schema:"requested_token_type"` } type ClientCredentialsRequest struct { diff --git a/pkg/op/op.go b/pkg/op/op.go index acedcb6..699fb45 100644 --- a/pkg/op/op.go +++ b/pkg/op/op.go @@ -267,7 +267,8 @@ func (o *Provider) GrantTypeRefreshTokenSupported() bool { } func (o *Provider) GrantTypeTokenExchangeSupported() bool { - return false + _, ok := o.storage.(TokenExchangeStorage) + return ok } func (o *Provider) GrantTypeJWTAuthorizationSupported() bool { diff --git a/pkg/op/storage.go b/pkg/op/storage.go index b040b72..1e19c76 100644 --- a/pkg/op/storage.go +++ b/pkg/op/storage.go @@ -25,6 +25,8 @@ type AuthStorage interface { // // * *oidc.JWTTokenRequest from a JWT that is the assertion value of a JWT Profile // Grant: https://datatracker.ietf.org/doc/html/rfc7523#section-2.1 + // + // * TokenExchangeRequest as returned by ValidateTokenExchangeRequest CreateAccessToken(context.Context, TokenRequest) (accessTokenID string, expiration time.Time, err error) // The TokenRequest parameter of CreateAccessAndRefreshTokens can be any of: @@ -36,6 +38,8 @@ type AuthStorage interface { // * AuthRequest as by returned by the AuthRequestByID or AuthRequestByCode (above). // Used for the authorization code flow which requested offline_access scope and // registered the refresh_token grant type in advance + // + // * TokenExchangeRequest as returned by ValidateTokenExchangeRequest CreateAccessAndRefreshTokens(ctx context.Context, request TokenRequest, currentRefreshToken string) (accessTokenID string, newRefreshTokenID string, expiration time.Time, err error) TokenRequestByRefreshToken(ctx context.Context, refreshTokenID string) (RefreshTokenRequest, error) @@ -57,6 +61,45 @@ type ClientCredentialsStorage interface { ClientCredentialsTokenRequest(ctx context.Context, clientID string, scopes []string) (TokenRequest, error) } +type TokenExchangeStorage interface { + // ValidateTokenExchangeRequest will be called to validate parsed (including tokens) Token Exchange Grant request. + // + // Important validations can include: + // - permissions + // - set requested token type to some default value if it is empty (rfc 8693 allows it) using SetRequestedTokenType method. + // Depending on RequestedTokenType - the following tokens will be issued: + // - RefreshTokenType - both access and refresh tokens + // - AccessTokenType - only access token + // - IDTokenType - only id token + // - validation of subject's token type on possibility to be exchanged to the requested token type (according to your requirements) + // - scopes (and update them using SetCurrentScopes method) + // - set new subject if it differs from exchange subject (impersonation flow) + // + // Request will include subject's and/or actor's token claims if correspinding tokens are access/id_token issued by op + // or third party tokens parsed by TokenExchangeTokensVerifierStorage interface methods. + ValidateTokenExchangeRequest(ctx context.Context, request TokenExchangeRequest) error + + // CreateTokenExchangeRequest will be called after parsing and validating token exchange request. + // Stored request is not accessed later by op - so it is up to implementer to decide + // should this method actually store the request or not (common use case - store for it for audit purposes) + CreateTokenExchangeRequest(ctx context.Context, request TokenExchangeRequest) error + + // GetPrivateClaimsFromTokenExchangeRequest will be called during access token creation. + // Claims evaluation can be based on all validated request data available, including: scopes, resource, audience, etc. + GetPrivateClaimsFromTokenExchangeRequest(ctx context.Context, request TokenExchangeRequest) (claims map[string]interface{}, err error) + + // SetUserinfoFromTokenExchangeRequest will be called during id token creation. + // Claims evaluation can be based on all validated request data available, including: scopes, resource, audience, etc. + SetUserinfoFromTokenExchangeRequest(ctx context.Context, userinfo oidc.UserInfoSetter, request TokenExchangeRequest) error +} + +// TokenExchangeTokensVerifierStorage is an optional interface used in token exchange process to verify tokens +// issued by third-party applications. If interface is not implemented - only tokens issued by op will be exchanged. +type TokenExchangeTokensVerifierStorage interface { + VerifyExchangeSubjectToken(ctx context.Context, token string, tokenType oidc.TokenType) (tokenIDOrToken string, subject string, tokenClaims map[string]interface{}, err error) + VerifyExchangeActorToken(ctx context.Context, token string, tokenType oidc.TokenType) (tokenIDOrToken string, actor string, tokenClaims map[string]interface{}, err error) +} + // CanRefreshTokenInfo is an optional additional interface that Storage can support. // Supporting CanRefreshTokenInfo is required to be able to (revoke) a refresh token that // is neither an encrypted string of : nor a JWT. diff --git a/pkg/op/token.go b/pkg/op/token.go index 4d3e620..3a35062 100644 --- a/pkg/op/token.go +++ b/pkg/op/token.go @@ -74,6 +74,8 @@ func needsRefreshToken(tokenRequest TokenRequest, client AccessTokenClient) bool switch req := tokenRequest.(type) { case AuthRequest: return strings.Contains(req.GetScopes(), oidc.ScopeOfflineAccess) && req.GetResponseType() == oidc.ResponseTypeCode && ValidateGrantType(client, oidc.GrantTypeRefreshToken) + case TokenExchangeRequest: + return req.GetRequestedTokenType() == oidc.RefreshTokenType case RefreshTokenRequest: return true default: @@ -107,7 +109,23 @@ func CreateJWT(ctx context.Context, issuer string, tokenRequest TokenRequest, ex claims := oidc.NewAccessTokenClaims(issuer, tokenRequest.GetSubject(), tokenRequest.GetAudience(), exp, id, client.GetID(), client.ClockSkew()) if client != nil { restrictedScopes := client.RestrictAdditionalAccessTokenScopes()(tokenRequest.GetScopes()) - privateClaims, err := storage.GetPrivateClaimsFromScopes(ctx, tokenRequest.GetSubject(), client.GetID(), removeUserinfoScopes(restrictedScopes)) + + var ( + privateClaims map[string]interface{} + err error + ) + + tokenExchangeRequest, okReq := tokenRequest.(TokenExchangeRequest) + teStorage, okStorage := storage.(TokenExchangeStorage) + if okReq && okStorage { + privateClaims, err = teStorage.GetPrivateClaimsFromTokenExchangeRequest( + ctx, + tokenExchangeRequest, + ) + } else { + privateClaims, err = storage.GetPrivateClaimsFromScopes(ctx, tokenRequest.GetSubject(), client.GetID(), removeUserinfoScopes(restrictedScopes)) + } + if err != nil { return "", err } @@ -156,7 +174,17 @@ func CreateIDToken(ctx context.Context, issuer string, request IDTokenRequest, v scopes = removeUserinfoScopes(scopes) } } - if len(scopes) > 0 { + + tokenExchangeRequest, okReq := request.(TokenExchangeRequest) + teStorage, okStorage := storage.(TokenExchangeStorage) + if okReq && okStorage { + userInfo := oidc.NewUserInfo() + err := teStorage.SetUserinfoFromTokenExchangeRequest(ctx, userInfo, tokenExchangeRequest) + if err != nil { + return "", err + } + claims.SetUserinfo(userInfo) + } else if len(scopes) > 0 { userInfo := oidc.NewUserInfo() err := storage.SetUserinfoFromScopes(ctx, userInfo, request.GetSubject(), request.GetClientID(), scopes) if err != nil { diff --git a/pkg/op/token_exchange.go b/pkg/op/token_exchange.go index 7bb6e42..6b918b1 100644 --- a/pkg/op/token_exchange.go +++ b/pkg/op/token_exchange.go @@ -1,11 +1,399 @@ package op import ( - "errors" + "context" "net/http" + "net/url" + "strings" + "time" + + httphelper "github.com/zitadel/oidc/v2/pkg/http" + "github.com/zitadel/oidc/v2/pkg/oidc" ) -// TokenExchange will handle the OAuth 2.0 token exchange grant ("urn:ietf:params:oauth:grant-type:token-exchange") -func TokenExchange(w http.ResponseWriter, r *http.Request, exchanger Exchanger) { - RequestError(w, r, errors.New("unimplemented")) +type TokenExchangeRequest interface { + GetAMR() []string + GetAudience() []string + GetResourses() []string + GetAuthTime() time.Time + GetClientID() string + GetScopes() []string + GetSubject() string + GetRequestedTokenType() oidc.TokenType + + GetExchangeSubject() string + GetExchangeSubjectTokenType() oidc.TokenType + GetExchangeSubjectTokenIDOrToken() string + GetExchangeSubjectTokenClaims() map[string]interface{} + + GetExchangeActor() string + GetExchangeActorTokenType() oidc.TokenType + GetExchangeActorTokenIDOrToken() string + GetExchangeActorTokenClaims() map[string]interface{} + + SetCurrentScopes(scopes []string) + SetRequestedTokenType(tt oidc.TokenType) + SetSubject(subject string) +} + +type tokenExchangeRequest struct { + exchangeSubjectTokenIDOrToken string + exchangeSubjectTokenType oidc.TokenType + exchangeSubject string + exchangeSubjectTokenClaims map[string]interface{} + + exchangeActorTokenIDOrToken string + exchangeActorTokenType oidc.TokenType + exchangeActor string + exchangeActorTokenClaims map[string]interface{} + + resource []string + audience oidc.Audience + scopes oidc.SpaceDelimitedArray + requestedTokenType oidc.TokenType + clientID string + authTime time.Time + subject string +} + +func (r *tokenExchangeRequest) GetAMR() []string { + return []string{} +} + +func (r *tokenExchangeRequest) GetAudience() []string { + return r.audience +} + +func (r *tokenExchangeRequest) GetResourses() []string { + return r.resource +} + +func (r *tokenExchangeRequest) GetAuthTime() time.Time { + return r.authTime +} + +func (r *tokenExchangeRequest) GetClientID() string { + return r.clientID +} + +func (r *tokenExchangeRequest) GetScopes() []string { + return r.scopes +} + +func (r *tokenExchangeRequest) GetRequestedTokenType() oidc.TokenType { + return r.requestedTokenType +} + +func (r *tokenExchangeRequest) GetExchangeSubject() string { + return r.exchangeSubject +} + +func (r *tokenExchangeRequest) GetExchangeSubjectTokenType() oidc.TokenType { + return r.exchangeSubjectTokenType +} + +func (r *tokenExchangeRequest) GetExchangeSubjectTokenIDOrToken() string { + return r.exchangeSubjectTokenIDOrToken +} + +func (r *tokenExchangeRequest) GetExchangeSubjectTokenClaims() map[string]interface{} { + return r.exchangeSubjectTokenClaims +} + +func (r *tokenExchangeRequest) GetExchangeActor() string { + return r.exchangeActor +} + +func (r *tokenExchangeRequest) GetExchangeActorTokenType() oidc.TokenType { + return r.exchangeActorTokenType +} + +func (r *tokenExchangeRequest) GetExchangeActorTokenIDOrToken() string { + return r.exchangeActorTokenIDOrToken +} + +func (r *tokenExchangeRequest) GetExchangeActorTokenClaims() map[string]interface{} { + return r.exchangeActorTokenClaims +} + +func (r *tokenExchangeRequest) GetSubject() string { + return r.subject +} + +func (r *tokenExchangeRequest) SetCurrentScopes(scopes []string) { + r.scopes = scopes +} + +func (r *tokenExchangeRequest) SetRequestedTokenType(tt oidc.TokenType) { + r.requestedTokenType = tt +} + +func (r *tokenExchangeRequest) SetSubject(subject string) { + r.subject = subject +} + +// TokenExchange handles the OAuth 2.0 token exchange grant ("urn:ietf:params:oauth:grant-type:token-exchange") +func TokenExchange(w http.ResponseWriter, r *http.Request, exchanger Exchanger) { + tokenExchangeReq, clientID, clientSecret, err := ParseTokenExchangeRequest(r, exchanger.Decoder()) + if err != nil { + RequestError(w, r, err) + } + + tokenExchangeRequest, client, err := ValidateTokenExchangeRequest(r.Context(), tokenExchangeReq, clientID, clientSecret, exchanger) + if err != nil { + RequestError(w, r, err) + return + } + resp, err := CreateTokenExchangeResponse(r.Context(), tokenExchangeRequest, client, exchanger) + if err != nil { + RequestError(w, r, err) + return + } + httphelper.MarshalJSON(w, resp) +} + +// ParseTokenExchangeRequest parses the http request into oidc.TokenExchangeRequest +func ParseTokenExchangeRequest(r *http.Request, decoder httphelper.Decoder) (_ *oidc.TokenExchangeRequest, clientID, clientSecret string, err error) { + err = r.ParseForm() + if err != nil { + return nil, "", "", oidc.ErrInvalidRequest().WithDescription("error parsing form").WithParent(err) + } + + request := new(oidc.TokenExchangeRequest) + err = decoder.Decode(request, r.Form) + if err != nil { + return nil, "", "", oidc.ErrInvalidRequest().WithDescription("error decoding form").WithParent(err) + } + + var ok bool + if clientID, clientSecret, ok = r.BasicAuth(); ok { + clientID, err = url.QueryUnescape(clientID) + if err != nil { + return nil, "", "", oidc.ErrInvalidClient().WithDescription("invalid basic auth header").WithParent(err) + } + + clientSecret, err = url.QueryUnescape(clientSecret) + if err != nil { + return nil, "", "", oidc.ErrInvalidClient().WithDescription("invalid basic auth header").WithParent(err) + } + } + + return request, clientID, clientSecret, nil +} + +// ValidateTokenExchangeRequest validates the token exchange request parameters including authorization check of the client, +// subject_token and actor_token +func ValidateTokenExchangeRequest( + ctx context.Context, + oidcTokenExchangeRequest *oidc.TokenExchangeRequest, + clientID, clientSecret string, + exchanger Exchanger, +) (TokenExchangeRequest, Client, error) { + if oidcTokenExchangeRequest.SubjectToken == "" { + return nil, nil, oidc.ErrInvalidRequest().WithDescription("subject_token missing") + } + + if oidcTokenExchangeRequest.SubjectTokenType == "" { + return nil, nil, oidc.ErrInvalidRequest().WithDescription("subject_token_type missing") + } + + storage := exchanger.Storage() + teStorage, ok := storage.(TokenExchangeStorage) + if !ok { + return nil, nil, oidc.ErrUnsupportedGrantType().WithDescription("token_exchange grant not supported") + } + + client, err := AuthorizeTokenExchangeClient(ctx, clientID, clientSecret, exchanger) + if err != nil { + return nil, nil, err + } + + if oidcTokenExchangeRequest.RequestedTokenType != "" && !oidcTokenExchangeRequest.RequestedTokenType.IsSupported() { + return nil, nil, oidc.ErrInvalidRequest().WithDescription("requested_token_type is not supported") + } + + if !oidcTokenExchangeRequest.SubjectTokenType.IsSupported() { + return nil, nil, oidc.ErrInvalidRequest().WithDescription("subject_token_type is not supported") + } + + if oidcTokenExchangeRequest.ActorTokenType != "" && !oidcTokenExchangeRequest.ActorTokenType.IsSupported() { + return nil, nil, oidc.ErrInvalidRequest().WithDescription("actor_token_type is not supported") + } + + exchangeSubjectTokenIDOrToken, exchangeSubject, exchangeSubjectTokenClaims, ok := GetTokenIDAndSubjectFromToken(ctx, exchanger, + oidcTokenExchangeRequest.SubjectToken, oidcTokenExchangeRequest.SubjectTokenType, false) + if !ok { + return nil, nil, oidc.ErrInvalidRequest().WithDescription("subject_token is invalid") + } + + var ( + exchangeActorTokenIDOrToken, exchangeActor string + exchangeActorTokenClaims map[string]interface{} + ) + if oidcTokenExchangeRequest.ActorToken != "" { + exchangeActorTokenIDOrToken, exchangeActor, exchangeActorTokenClaims, ok = GetTokenIDAndSubjectFromToken(ctx, exchanger, + oidcTokenExchangeRequest.ActorToken, oidcTokenExchangeRequest.ActorTokenType, true) + if !ok { + return nil, nil, oidc.ErrInvalidRequest().WithDescription("actor_token is invalid") + } + } + + req := &tokenExchangeRequest{ + exchangeSubjectTokenIDOrToken: exchangeSubjectTokenIDOrToken, + exchangeSubjectTokenType: oidcTokenExchangeRequest.SubjectTokenType, + exchangeSubject: exchangeSubject, + exchangeSubjectTokenClaims: exchangeSubjectTokenClaims, + + exchangeActorTokenIDOrToken: exchangeActorTokenIDOrToken, + exchangeActorTokenType: oidcTokenExchangeRequest.ActorTokenType, + exchangeActor: exchangeActor, + exchangeActorTokenClaims: exchangeActorTokenClaims, + + subject: exchangeSubject, + resource: oidcTokenExchangeRequest.Resource, + audience: oidcTokenExchangeRequest.Audience, + scopes: oidcTokenExchangeRequest.Scopes, + requestedTokenType: oidcTokenExchangeRequest.RequestedTokenType, + clientID: client.GetID(), + authTime: time.Now(), + } + + err = teStorage.ValidateTokenExchangeRequest(ctx, req) + if err != nil { + return nil, nil, err + } + + err = teStorage.CreateTokenExchangeRequest(ctx, req) + if err != nil { + return nil, nil, err + } + + return req, client, nil +} + +func GetTokenIDAndSubjectFromToken( + ctx context.Context, + exchanger Exchanger, + token string, + tokenType oidc.TokenType, + isActor bool, +) (tokenIDOrToken, subject string, claims map[string]interface{}, ok bool) { + switch tokenType { + case oidc.AccessTokenType: + var accessTokenClaims oidc.AccessTokenClaims + tokenIDOrToken, subject, accessTokenClaims, ok = getTokenIDAndClaims(ctx, exchanger, token) + claims = accessTokenClaims.GetClaims() + case oidc.RefreshTokenType: + refreshTokenRequest, err := exchanger.Storage().TokenRequestByRefreshToken(ctx, token) + if err != nil { + break + } + + tokenIDOrToken, subject, ok = token, refreshTokenRequest.GetSubject(), true + case oidc.IDTokenType: + idTokenClaims, err := VerifyIDTokenHint(ctx, token, exchanger.IDTokenHintVerifier(ctx)) + if err != nil { + break + } + + tokenIDOrToken, subject, claims, ok = token, idTokenClaims.GetSubject(), idTokenClaims.GetClaims(), true + } + + if !ok { + if verifier, ok := exchanger.Storage().(TokenExchangeTokensVerifierStorage); ok { + var err error + if isActor { + tokenIDOrToken, subject, claims, err = verifier.VerifyExchangeActorToken(ctx, token, tokenType) + } else { + tokenIDOrToken, subject, claims, err = verifier.VerifyExchangeSubjectToken(ctx, token, tokenType) + } + if err != nil { + return "", "", nil, false + } + + return tokenIDOrToken, subject, claims, true + } + + return "", "", nil, false + } + + return tokenIDOrToken, subject, claims, true +} + +// AuthorizeTokenExchangeClient authorizes a client by validating the client_id and client_secret +func AuthorizeTokenExchangeClient(ctx context.Context, clientID, clientSecret string, exchanger Exchanger) (client Client, err error) { + if err := AuthorizeClientIDSecret(ctx, clientID, clientSecret, exchanger.Storage()); err != nil { + return nil, err + } + + client, err = exchanger.Storage().GetClientByClientID(ctx, clientID) + if err != nil { + return nil, oidc.ErrInvalidClient().WithParent(err) + } + + return client, nil +} + +func CreateTokenExchangeResponse( + ctx context.Context, + tokenExchangeRequest TokenExchangeRequest, + client Client, + creator TokenCreator, +) (_ *oidc.TokenExchangeResponse, err error) { + + var ( + token, refreshToken, tokenType string + validity time.Duration + ) + + switch tokenExchangeRequest.GetRequestedTokenType() { + case oidc.AccessTokenType, oidc.RefreshTokenType: + token, refreshToken, validity, err = CreateAccessToken(ctx, tokenExchangeRequest, client.AccessTokenType(), creator, client, "") + if err != nil { + return nil, err + } + + tokenType = oidc.BearerToken + case oidc.IDTokenType: + token, err = CreateIDToken(ctx, IssuerFromContext(ctx), tokenExchangeRequest, client.IDTokenLifetime(), "", "", creator.Storage(), client) + if err != nil { + return nil, err + } + + // not applicable (see https://datatracker.ietf.org/doc/html/rfc8693#section-2-2-1-2-6) + tokenType = "N_A" + default: + // oidc.JWTTokenType and other custom token types are not supported for issuing. + // In the future it can be considered to have custom tokens generation logic injected via op configuration + // or via expanding Storage interface + oidc.ErrInvalidRequest().WithDescription("requested_token_type is invalid") + } + + exp := uint64(validity.Seconds()) + return &oidc.TokenExchangeResponse{ + AccessToken: token, + IssuedTokenType: tokenExchangeRequest.GetRequestedTokenType(), + TokenType: tokenType, + ExpiresIn: exp, + RefreshToken: refreshToken, + Scopes: tokenExchangeRequest.GetScopes(), + }, nil +} + +func getTokenIDAndClaims(ctx context.Context, userinfoProvider UserinfoProvider, accessToken string) (string, string, oidc.AccessTokenClaims, bool) { + tokenIDSubject, err := userinfoProvider.Crypto().Decrypt(accessToken) + if err == nil { + splitToken := strings.Split(tokenIDSubject, ":") + if len(splitToken) != 2 { + return "", "", nil, false + } + + return splitToken[0], splitToken[1], nil, true + } + accessTokenClaims, err := VerifyAccessToken(ctx, accessToken, userinfoProvider.AccessTokenVerifier(ctx)) + if err != nil { + return "", "", nil, false + } + + return accessTokenClaims.GetTokenID(), accessTokenClaims.GetSubject(), accessTokenClaims, true } diff --git a/pkg/op/token_request.go b/pkg/op/token_request.go index 190e812..3d65ea0 100644 --- a/pkg/op/token_request.go +++ b/pkg/op/token_request.go @@ -19,6 +19,8 @@ type Exchanger interface { GrantTypeTokenExchangeSupported() bool GrantTypeJWTAuthorizationSupported() bool GrantTypeClientCredentialsSupported() bool + AccessTokenVerifier(context.Context) AccessTokenVerifier + IDTokenHintVerifier(context.Context) IDTokenHintVerifier } func tokenHandler(exchanger Exchanger) func(w http.ResponseWriter, r *http.Request) {