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
This commit is contained in:
parent
9291ca9908
commit
8e298791d7
16 changed files with 961 additions and 59 deletions
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
|
@ -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
|
||||
}
|
||||
|
|
127
pkg/client/tokenexchange/tokenexchange.go
Normal file
127
pkg/client/tokenexchange/tokenexchange.go
Normal file
|
@ -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)
|
||||
}
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue