Repository: zitadel/oidc
Pull request #255 (2022-12-20 01:23:05)

feat: Token Exchange (RFC 8693)
This PR implements OAuth2 Token Exchange in OP according to [RFC 8693](https://www.rfc-editor.org/rfc/rfc8693) (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


fforootd: 👋 Hi There

Thank you for this PR, this looks awesome.

Please give us some time to schedule the review of this. Since the holiday season comes up our resources are a little tight 😁 

Question: @livio-a would this be something where also @stebenz could assist?
codecov[bot]: # [Codecov](https://codecov.io/gh/zitadel/oidc/pull/255?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zitadel) Report
> Merging [#255](https://codecov.io/gh/zitadel/oidc/pull/255?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zitadel) (cf8128a) into [main](https://codecov.io/gh/zitadel/oidc/commit/205f2c4a307fde45c6810f581e29af8e0b3d027e?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zitadel) (205f2c4) will **increase** coverage by `0.87%`.
> The diff coverage is `42.04%`.

```diff
@@            Coverage Diff             @@
##             main     #255      +/-   ##
==========================================
+ Coverage   39.54%   40.42%   +0.87%     
==========================================
  Files          74       75       +1     
  Lines        5910     6338     +428     
==========================================
+ Hits         2337     2562     +225     
- Misses       3322     3492     +170     
- Partials      251      284      +33     
```


| [Impacted Files](https://codecov.io/gh/zitadel/oidc/pull/255?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zitadel) | Coverage Δ | |
|---|---|---|
| [example/server/storage/oidc.go](https://codecov.io/gh/zitadel/oidc/pull/255/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zitadel#diff-ZXhhbXBsZS9zZXJ2ZXIvc3RvcmFnZS9vaWRjLmdv) | `77.65% <ø> (ø)` | |
| [pkg/oidc/token.go](https://codecov.io/gh/zitadel/oidc/pull/255/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zitadel#diff-cGtnL29pZGMvdG9rZW4uZ28=) | `22.49% <0.00%> (-0.14%)` | :arrow_down: |
| [pkg/op/token\_request.go](https://codecov.io/gh/zitadel/oidc/pull/255/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zitadel#diff-cGtnL29wL3Rva2VuX3JlcXVlc3QuZ28=) | `42.55% <ø> (+5.31%)` | :arrow_up: |
| [pkg/op/token.go](https://codecov.io/gh/zitadel/oidc/pull/255/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zitadel#diff-cGtnL29wL3Rva2VuLmdv) | `55.97% <12.50%> (-9.21%)` | :arrow_down: |
| [pkg/client/tokenexchange/tokenexchange.go](https://codecov.io/gh/zitadel/oidc/pull/255/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zitadel#diff-cGtnL2NsaWVudC90b2tlbmV4Y2hhbmdlL3Rva2VuZXhjaGFuZ2UuZ28=) | `22.38% <22.38%> (ø)` | |
| [example/server/storage/storage.go](https://codecov.io/gh/zitadel/oidc/pull/255/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zitadel#diff-ZXhhbXBsZS9zZXJ2ZXIvc3RvcmFnZS9zdG9yYWdlLmdv) | `56.52% <32.46%> (-4.15%)` | :arrow_down: |
| [pkg/op/token\_exchange.go](https://codecov.io/gh/zitadel/oidc/pull/255/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zitadel#diff-cGtnL29wL3Rva2VuX2V4Y2hhbmdlLmdv) | `47.39% <47.16%> (+47.39%)` | :arrow_up: |
| [pkg/client/client.go](https://codecov.io/gh/zitadel/oidc/pull/255/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zitadel#diff-cGtnL2NsaWVudC9jbGllbnQuZ28=) | `35.43% <60.00%> (+3.29%)` | :arrow_up: |
| [pkg/oidc/token\_request.go](https://codecov.io/gh/zitadel/oidc/pull/255/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zitadel#diff-cGtnL29pZGMvdG9rZW5fcmVxdWVzdC5nbw==) | `12.85% <83.33%> (+6.60%)` | :arrow_up: |
| [example/server/storage/client.go](https://codecov.io/gh/zitadel/oidc/pull/255/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zitadel#diff-ZXhhbXBsZS9zZXJ2ZXIvc3RvcmFnZS9jbGllbnQuZ28=) | `90.47% <100.00%> (ø)` | |
| ... and [11 more](https://codecov.io/gh/zitadel/oidc/pull/255/diff?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zitadel) | |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zitadel). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zitadel)

livio-a: Hey @lefelys 

First of all, thank you very much for this PR. Unfortunately, i did not have time to have a close look at it.
I'll try later this week of at least the beginning of next.
muhlemmer: I've updated the comments, after going though them with my colleague. Irrelevant ones are put on resolved. For the ones that remain, a kind request to solve them.

I took the liberty to merge `next` into your branch and resolve the merge conflicts, as there were a lot of changes on our side in the last weeks.



Patch:
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/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 <tokenID>:<userID> 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")
+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) {
-	RequestError(w, r, errors.New("unimplemented"))
+	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) {


Test patch:
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 {