mirror of
https://github.com/zitadel/zitadel.git
synced 2025-08-12 03:57:32 +00:00
chore(tests): use a coverage server binary (#8407)
# Which Problems Are Solved Use a single server instance for API integration tests. This optimizes the time taken for the integration test pipeline, because it allows running tests on multiple packages in parallel. Also, it saves time by not start and stopping a zitadel server for every package. # How the Problems Are Solved - Build a binary with `go build -race -cover ....` - Integration tests only construct clients. The server remains running in the background. - The integration package and tested packages now fully utilize the API. No more direct database access trough `query` and `command` packages. - Use Makefile recipes to setup, start and stop the server in the background. - The binary has the race detector enabled - Init and setup jobs are configured to halt immediately on race condition - Because the server runs in the background, races are only logged. When the server is stopped and race logs exist, the Makefile recipe will throw an error and print the logs. - Makefile recipes include logic to print logs and convert coverage reports after the server is stopped. - Some tests need a downstream HTTP server to make requests, like quota and milestones. A new `integration/sink` package creates an HTTP server and uses websockets to forward HTTP request back to the test packages. The package API uses Go channels for abstraction and easy usage. # Additional Changes - Integration test files already used the `//go:build integration` directive. In order to properly split integration from unit tests, integration test files need to be in a `integration_test` subdirectory of their package. - `UseIsolatedInstance` used to overwrite the `Tester.Client` for each instance. Now a `Instance` object is returned with a gRPC client that is connected to the isolated instance's hostname. - The `Tester` type is now `Instance`. The object is created for the first instance, used by default in any test. Isolated instances are also `Instance` objects and therefore benefit from the same methods and values. The first instance and any other us capable of creating an isolated instance over the system API. - All test packages run in an Isolated instance by calling `NewInstance()` - Individual tests that use an isolated instance use `t.Parallel()` # Additional Context - Closes #6684 - https://go.dev/doc/articles/race_detector - https://go.dev/doc/build-cover --------- Co-authored-by: Stefan Benz <46600784+stebenz@users.noreply.github.com>
This commit is contained in:
571
internal/api/oidc/integration_test/auth_request_test.go
Normal file
571
internal/api/oidc/integration_test/auth_request_test.go
Normal file
@@ -0,0 +1,571 @@
|
||||
//go:build integration
|
||||
|
||||
package oidc_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/muhlemmer/gu"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/zitadel/oidc/v3/pkg/client/rp"
|
||||
"github.com/zitadel/oidc/v3/pkg/oidc"
|
||||
|
||||
http_utils "github.com/zitadel/zitadel/internal/api/http"
|
||||
oidc_api "github.com/zitadel/zitadel/internal/api/oidc"
|
||||
"github.com/zitadel/zitadel/internal/command"
|
||||
"github.com/zitadel/zitadel/internal/integration"
|
||||
oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/session/v2"
|
||||
)
|
||||
|
||||
var (
|
||||
armPasskey = []string{oidc_api.UserPresence, oidc_api.MFA}
|
||||
armPassword = []string{oidc_api.PWD}
|
||||
)
|
||||
|
||||
func TestOPStorage_CreateAuthRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
clientID, _ := createClient(t, Instance)
|
||||
|
||||
id := createAuthRequest(t, Instance, clientID, redirectURI)
|
||||
require.Contains(t, id, command.IDPrefixV2)
|
||||
}
|
||||
|
||||
func TestOPStorage_CreateAccessToken_code(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
clientID, _ := createClient(t, Instance)
|
||||
authRequestID := createAuthRequest(t, Instance, clientID, redirectURI)
|
||||
sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId())
|
||||
linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{
|
||||
AuthRequestId: authRequestID,
|
||||
CallbackKind: &oidc_pb.CreateCallbackRequest_Session{
|
||||
Session: &oidc_pb.Session{
|
||||
SessionId: sessionID,
|
||||
SessionToken: sessionToken,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// test code exchange
|
||||
code := assertCodeResponse(t, linkResp.GetCallbackUrl())
|
||||
tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI)
|
||||
require.NoError(t, err)
|
||||
assertTokens(t, tokens, false)
|
||||
assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID)
|
||||
|
||||
// callback on a succeeded request must fail
|
||||
linkResp, err = Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{
|
||||
AuthRequestId: authRequestID,
|
||||
CallbackKind: &oidc_pb.CreateCallbackRequest_Session{
|
||||
Session: &oidc_pb.Session{
|
||||
SessionId: sessionID,
|
||||
SessionToken: sessionToken,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.Error(t, err)
|
||||
|
||||
// exchange with a used code must fail
|
||||
_, err = exchangeTokens(t, Instance, clientID, code, redirectURI)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestOPStorage_CreateAccessToken_implicit(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
clientID := createImplicitClient(t)
|
||||
authRequestID := createAuthRequestImplicit(t, clientID, redirectURIImplicit)
|
||||
sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId())
|
||||
linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{
|
||||
AuthRequestId: authRequestID,
|
||||
CallbackKind: &oidc_pb.CreateCallbackRequest_Session{
|
||||
Session: &oidc_pb.Session{
|
||||
SessionId: sessionID,
|
||||
SessionToken: sessionToken,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// test implicit callback
|
||||
callback, err := url.Parse(linkResp.GetCallbackUrl())
|
||||
require.NoError(t, err)
|
||||
values, err := url.ParseQuery(callback.Fragment)
|
||||
require.NoError(t, err)
|
||||
accessToken := values.Get("access_token")
|
||||
idToken := values.Get("id_token")
|
||||
refreshToken := values.Get("refresh_token")
|
||||
assert.NotEmpty(t, accessToken)
|
||||
assert.NotEmpty(t, idToken)
|
||||
assert.Empty(t, refreshToken)
|
||||
assert.NotEmpty(t, values.Get("expires_in"))
|
||||
assert.Equal(t, oidc.BearerToken, values.Get("token_type"))
|
||||
assert.Equal(t, "state", values.Get("state"))
|
||||
|
||||
// check id_token / claims
|
||||
provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURIImplicit)
|
||||
require.NoError(t, err)
|
||||
claims, err := rp.VerifyTokens[*oidc.IDTokenClaims](context.Background(), accessToken, idToken, provider.IDTokenVerifier())
|
||||
require.NoError(t, err)
|
||||
assertIDTokenClaims(t, claims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID)
|
||||
|
||||
// callback on a succeeded request must fail
|
||||
linkResp, err = Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{
|
||||
AuthRequestId: authRequestID,
|
||||
CallbackKind: &oidc_pb.CreateCallbackRequest_Session{
|
||||
Session: &oidc_pb.Session{
|
||||
SessionId: sessionID,
|
||||
SessionToken: sessionToken,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestOPStorage_CreateAccessAndRefreshTokens_code(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
clientID, _ := createClient(t, Instance)
|
||||
authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess)
|
||||
sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId())
|
||||
linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{
|
||||
AuthRequestId: authRequestID,
|
||||
CallbackKind: &oidc_pb.CreateCallbackRequest_Session{
|
||||
Session: &oidc_pb.Session{
|
||||
SessionId: sessionID,
|
||||
SessionToken: sessionToken,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// test code exchange (expect refresh token to be returned)
|
||||
code := assertCodeResponse(t, linkResp.GetCallbackUrl())
|
||||
tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI)
|
||||
require.NoError(t, err)
|
||||
assertTokens(t, tokens, true)
|
||||
assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID)
|
||||
}
|
||||
|
||||
func TestOPStorage_CreateAccessAndRefreshTokens_refresh(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
clientID, _ := createClient(t, Instance)
|
||||
provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURI)
|
||||
require.NoError(t, err)
|
||||
authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess)
|
||||
sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId())
|
||||
linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{
|
||||
AuthRequestId: authRequestID,
|
||||
CallbackKind: &oidc_pb.CreateCallbackRequest_Session{
|
||||
Session: &oidc_pb.Session{
|
||||
SessionId: sessionID,
|
||||
SessionToken: sessionToken,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// code exchange
|
||||
code := assertCodeResponse(t, linkResp.GetCallbackUrl())
|
||||
tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI)
|
||||
require.NoError(t, err)
|
||||
assertTokens(t, tokens, true)
|
||||
assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID)
|
||||
|
||||
// test actual refresh grant
|
||||
newTokens, err := refreshTokens(t, clientID, tokens.RefreshToken)
|
||||
require.NoError(t, err)
|
||||
assertTokens(t, newTokens, true)
|
||||
// auth time must still be the initial
|
||||
assertIDTokenClaims(t, newTokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID)
|
||||
|
||||
// refresh with an old refresh_token must fail
|
||||
_, err = rp.RefreshTokens[*oidc.IDTokenClaims](CTX, provider, tokens.RefreshToken, "", "")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestOPStorage_RevokeToken_access_token(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
clientID, _ := createClient(t, Instance)
|
||||
provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURI)
|
||||
require.NoError(t, err)
|
||||
authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess)
|
||||
sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId())
|
||||
linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{
|
||||
AuthRequestId: authRequestID,
|
||||
CallbackKind: &oidc_pb.CreateCallbackRequest_Session{
|
||||
Session: &oidc_pb.Session{
|
||||
SessionId: sessionID,
|
||||
SessionToken: sessionToken,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// code exchange
|
||||
code := assertCodeResponse(t, linkResp.GetCallbackUrl())
|
||||
tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI)
|
||||
require.NoError(t, err)
|
||||
assertTokens(t, tokens, true)
|
||||
assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID)
|
||||
|
||||
// revoke access token
|
||||
err = rp.RevokeToken(CTX, provider, tokens.AccessToken, "access_token")
|
||||
require.NoError(t, err)
|
||||
|
||||
// userinfo must fail
|
||||
_, err = rp.Userinfo[*oidc.UserInfo](CTX, tokens.AccessToken, tokens.TokenType, tokens.IDTokenClaims.Subject, provider)
|
||||
require.Error(t, err)
|
||||
|
||||
// refresh grant must still work
|
||||
_, err = refreshTokens(t, clientID, tokens.RefreshToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
// revocation with the same access token must not fail (with or without hint)
|
||||
err = rp.RevokeToken(CTX, provider, tokens.AccessToken, "access_token")
|
||||
require.NoError(t, err)
|
||||
err = rp.RevokeToken(CTX, provider, tokens.AccessToken, "")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestOPStorage_RevokeToken_access_token_invalid_token_hint_type(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
clientID, _ := createClient(t, Instance)
|
||||
provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURI)
|
||||
require.NoError(t, err)
|
||||
authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess)
|
||||
sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId())
|
||||
linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{
|
||||
AuthRequestId: authRequestID,
|
||||
CallbackKind: &oidc_pb.CreateCallbackRequest_Session{
|
||||
Session: &oidc_pb.Session{
|
||||
SessionId: sessionID,
|
||||
SessionToken: sessionToken,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// code exchange
|
||||
code := assertCodeResponse(t, linkResp.GetCallbackUrl())
|
||||
tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI)
|
||||
require.NoError(t, err)
|
||||
assertTokens(t, tokens, true)
|
||||
assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID)
|
||||
|
||||
// revoke access token
|
||||
err = rp.RevokeToken(CTX, provider, tokens.AccessToken, "refresh_token")
|
||||
require.NoError(t, err)
|
||||
|
||||
// userinfo must fail
|
||||
_, err = rp.Userinfo[*oidc.UserInfo](CTX, tokens.AccessToken, tokens.TokenType, tokens.IDTokenClaims.Subject, provider)
|
||||
require.Error(t, err)
|
||||
|
||||
// refresh grant must still work
|
||||
_, err = refreshTokens(t, clientID, tokens.RefreshToken)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestOPStorage_RevokeToken_refresh_token(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
clientID, _ := createClient(t, Instance)
|
||||
provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURI)
|
||||
require.NoError(t, err)
|
||||
authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess)
|
||||
sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId())
|
||||
linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{
|
||||
AuthRequestId: authRequestID,
|
||||
CallbackKind: &oidc_pb.CreateCallbackRequest_Session{
|
||||
Session: &oidc_pb.Session{
|
||||
SessionId: sessionID,
|
||||
SessionToken: sessionToken,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// code exchange
|
||||
code := assertCodeResponse(t, linkResp.GetCallbackUrl())
|
||||
tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI)
|
||||
require.NoError(t, err)
|
||||
assertTokens(t, tokens, true)
|
||||
assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID)
|
||||
|
||||
// revoke refresh token -> invalidates also access token
|
||||
err = rp.RevokeToken(CTX, provider, tokens.RefreshToken, "refresh_token")
|
||||
require.NoError(t, err)
|
||||
|
||||
// userinfo must fail
|
||||
_, err = rp.Userinfo[*oidc.UserInfo](CTX, tokens.AccessToken, tokens.TokenType, tokens.IDTokenClaims.Subject, provider)
|
||||
require.Error(t, err)
|
||||
|
||||
// refresh must fail
|
||||
_, err = refreshTokens(t, clientID, tokens.RefreshToken)
|
||||
require.Error(t, err)
|
||||
|
||||
// revocation with the same refresh token must not fail (with or without hint)
|
||||
err = rp.RevokeToken(CTX, provider, tokens.RefreshToken, "refresh_token")
|
||||
require.NoError(t, err)
|
||||
err = rp.RevokeToken(CTX, provider, tokens.RefreshToken, "")
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestOPStorage_RevokeToken_refresh_token_invalid_token_type_hint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
clientID, _ := createClient(t, Instance)
|
||||
provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURI)
|
||||
require.NoError(t, err)
|
||||
authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess)
|
||||
sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId())
|
||||
linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{
|
||||
AuthRequestId: authRequestID,
|
||||
CallbackKind: &oidc_pb.CreateCallbackRequest_Session{
|
||||
Session: &oidc_pb.Session{
|
||||
SessionId: sessionID,
|
||||
SessionToken: sessionToken,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// code exchange
|
||||
code := assertCodeResponse(t, linkResp.GetCallbackUrl())
|
||||
tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI)
|
||||
require.NoError(t, err)
|
||||
assertTokens(t, tokens, true)
|
||||
assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID)
|
||||
|
||||
// revoke refresh token even with a wrong hint
|
||||
err = rp.RevokeToken(CTX, provider, tokens.RefreshToken, "access_token")
|
||||
require.NoError(t, err)
|
||||
|
||||
// userinfo must fail
|
||||
_, err = rp.Userinfo[*oidc.UserInfo](CTX, tokens.AccessToken, tokens.TokenType, tokens.IDTokenClaims.Subject, provider)
|
||||
require.Error(t, err)
|
||||
|
||||
// refresh must fail
|
||||
_, err = refreshTokens(t, clientID, tokens.RefreshToken)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestOPStorage_RevokeToken_invalid_client(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
clientID, _ := createClient(t, Instance)
|
||||
authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess)
|
||||
sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId())
|
||||
linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{
|
||||
AuthRequestId: authRequestID,
|
||||
CallbackKind: &oidc_pb.CreateCallbackRequest_Session{
|
||||
Session: &oidc_pb.Session{
|
||||
SessionId: sessionID,
|
||||
SessionToken: sessionToken,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// code exchange
|
||||
code := assertCodeResponse(t, linkResp.GetCallbackUrl())
|
||||
tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI)
|
||||
require.NoError(t, err)
|
||||
assertTokens(t, tokens, true)
|
||||
assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID)
|
||||
|
||||
// simulate second client (not part of the audience) trying to revoke the token
|
||||
otherClientID, _ := createClient(t, Instance)
|
||||
provider, err := Instance.CreateRelyingParty(CTX, otherClientID, redirectURI)
|
||||
require.NoError(t, err)
|
||||
err = rp.RevokeToken(CTX, provider, tokens.AccessToken, "")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestOPStorage_TerminateSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
clientID, _ := createClient(t, Instance)
|
||||
provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURI)
|
||||
require.NoError(t, err)
|
||||
authRequestID := createAuthRequest(t, Instance, clientID, redirectURI)
|
||||
sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId())
|
||||
linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{
|
||||
AuthRequestId: authRequestID,
|
||||
CallbackKind: &oidc_pb.CreateCallbackRequest_Session{
|
||||
Session: &oidc_pb.Session{
|
||||
SessionId: sessionID,
|
||||
SessionToken: sessionToken,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// test code exchange
|
||||
code := assertCodeResponse(t, linkResp.GetCallbackUrl())
|
||||
tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI)
|
||||
require.NoError(t, err)
|
||||
assertTokens(t, tokens, false)
|
||||
assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID)
|
||||
|
||||
// userinfo must not fail
|
||||
_, err = rp.Userinfo[*oidc.UserInfo](CTX, tokens.AccessToken, tokens.TokenType, tokens.IDTokenClaims.Subject, provider)
|
||||
require.NoError(t, err)
|
||||
|
||||
postLogoutRedirect, err := rp.EndSession(CTX, provider, tokens.IDToken, logoutRedirectURI, "state")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, logoutRedirectURI+"?state=state", postLogoutRedirect.String())
|
||||
|
||||
// userinfo must fail
|
||||
_, err = rp.Userinfo[*oidc.UserInfo](CTX, tokens.AccessToken, tokens.TokenType, tokens.IDTokenClaims.Subject, provider)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestOPStorage_TerminateSession_refresh_grant(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
clientID, _ := createClient(t, Instance)
|
||||
provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURI)
|
||||
require.NoError(t, err)
|
||||
authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess)
|
||||
sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId())
|
||||
linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{
|
||||
AuthRequestId: authRequestID,
|
||||
CallbackKind: &oidc_pb.CreateCallbackRequest_Session{
|
||||
Session: &oidc_pb.Session{
|
||||
SessionId: sessionID,
|
||||
SessionToken: sessionToken,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// test code exchange
|
||||
code := assertCodeResponse(t, linkResp.GetCallbackUrl())
|
||||
tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI)
|
||||
require.NoError(t, err)
|
||||
assertTokens(t, tokens, true)
|
||||
assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID)
|
||||
|
||||
// userinfo must not fail
|
||||
_, err = rp.Userinfo[*oidc.UserInfo](CTX, tokens.AccessToken, tokens.TokenType, tokens.IDTokenClaims.Subject, provider)
|
||||
require.NoError(t, err)
|
||||
|
||||
postLogoutRedirect, err := rp.EndSession(CTX, provider, tokens.IDToken, logoutRedirectURI, "state")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, logoutRedirectURI+"?state=state", postLogoutRedirect.String())
|
||||
|
||||
// userinfo must fail
|
||||
_, err = rp.Userinfo[*oidc.UserInfo](CTX, tokens.AccessToken, tokens.TokenType, tokens.IDTokenClaims.Subject, provider)
|
||||
require.Error(t, err)
|
||||
|
||||
refreshedTokens, err := refreshTokens(t, clientID, tokens.RefreshToken)
|
||||
require.NoError(t, err)
|
||||
|
||||
// userinfo must not fail
|
||||
_, err = rp.Userinfo[*oidc.UserInfo](CTX, refreshedTokens.AccessToken, refreshedTokens.TokenType, refreshedTokens.IDTokenClaims.Subject, provider)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestOPStorage_TerminateSession_empty_id_token_hint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
clientID, _ := createClient(t, Instance)
|
||||
provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURI)
|
||||
require.NoError(t, err)
|
||||
authRequestID := createAuthRequest(t, Instance, clientID, redirectURI)
|
||||
sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId())
|
||||
linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{
|
||||
AuthRequestId: authRequestID,
|
||||
CallbackKind: &oidc_pb.CreateCallbackRequest_Session{
|
||||
Session: &oidc_pb.Session{
|
||||
SessionId: sessionID,
|
||||
SessionToken: sessionToken,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// test code exchange
|
||||
code := assertCodeResponse(t, linkResp.GetCallbackUrl())
|
||||
tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI)
|
||||
require.NoError(t, err)
|
||||
assertTokens(t, tokens, false)
|
||||
assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID)
|
||||
|
||||
postLogoutRedirect, err := rp.EndSession(CTX, provider, "", logoutRedirectURI, "state")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http_utils.BuildOrigin(Instance.Host(), Instance.Config.Secure)+Instance.Config.LogoutURLV2+logoutRedirectURI+"?state=state", postLogoutRedirect.String())
|
||||
|
||||
// userinfo must not fail until login UI terminated session
|
||||
_, err = rp.Userinfo[*oidc.UserInfo](CTX, tokens.AccessToken, tokens.TokenType, tokens.IDTokenClaims.Subject, provider)
|
||||
require.NoError(t, err)
|
||||
|
||||
// simulate termination by login UI
|
||||
_, err = Instance.Client.SessionV2.DeleteSession(CTXLOGIN, &session.DeleteSessionRequest{
|
||||
SessionId: sessionID,
|
||||
SessionToken: gu.Ptr(sessionToken),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// userinfo must fail
|
||||
_, err = rp.Userinfo[*oidc.UserInfo](CTX, tokens.AccessToken, tokens.TokenType, tokens.IDTokenClaims.Subject, provider)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func exchangeTokens(t testing.TB, instance *integration.Instance, clientID, code, redirectURI string) (*oidc.Tokens[*oidc.IDTokenClaims], error) {
|
||||
provider, err := instance.CreateRelyingParty(CTX, clientID, redirectURI)
|
||||
require.NoError(t, err)
|
||||
|
||||
return rp.CodeExchange[*oidc.IDTokenClaims](context.Background(), code, provider, rp.WithCodeVerifier(integration.CodeVerifier))
|
||||
}
|
||||
|
||||
func refreshTokens(t testing.TB, clientID, refreshToken string) (*oidc.Tokens[*oidc.IDTokenClaims], error) {
|
||||
provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURI)
|
||||
require.NoError(t, err)
|
||||
|
||||
return rp.RefreshTokens[*oidc.IDTokenClaims](CTX, provider, refreshToken, "", "")
|
||||
}
|
||||
|
||||
func assertCodeResponse(t *testing.T, callback string) string {
|
||||
callbackURL, err := url.Parse(callback)
|
||||
require.NoError(t, err)
|
||||
code := callbackURL.Query().Get("code")
|
||||
require.NotEmpty(t, code)
|
||||
assert.Equal(t, "state", callbackURL.Query().Get("state"))
|
||||
return code
|
||||
}
|
||||
|
||||
func assertTokens(t *testing.T, tokens *oidc.Tokens[*oidc.IDTokenClaims], requireRefreshToken bool) {
|
||||
assert.NotEmpty(t, tokens.AccessToken)
|
||||
assert.NotEmpty(t, tokens.IDToken)
|
||||
if requireRefreshToken {
|
||||
assert.NotEmpty(t, tokens.RefreshToken)
|
||||
} else {
|
||||
assert.Empty(t, tokens.RefreshToken)
|
||||
}
|
||||
// since we test implicit flow directly, we can check that any token response must not
|
||||
// return a `state` in the response
|
||||
assert.Empty(t, tokens.Extra("state"))
|
||||
}
|
||||
|
||||
func assertIDTokenClaims(t *testing.T, claims *oidc.IDTokenClaims, userID string, arm []string, sessionStart, sessionChange time.Time, sessionID string) {
|
||||
assert.Equal(t, userID, claims.Subject)
|
||||
assert.Equal(t, arm, claims.AuthenticationMethodsReferences)
|
||||
assertOIDCTimeRange(t, claims.AuthTime, sessionStart, sessionChange)
|
||||
assert.Equal(t, sessionID, claims.SessionID)
|
||||
assert.Empty(t, claims.Name)
|
||||
assert.Empty(t, claims.GivenName)
|
||||
assert.Empty(t, claims.FamilyName)
|
||||
assert.Empty(t, claims.PreferredUsername)
|
||||
}
|
361
internal/api/oidc/integration_test/client_test.go
Normal file
361
internal/api/oidc/integration_test/client_test.go
Normal file
@@ -0,0 +1,361 @@
|
||||
//go:build integration
|
||||
|
||||
package oidc_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/zitadel/oidc/v3/pkg/client"
|
||||
"github.com/zitadel/oidc/v3/pkg/client/rp"
|
||||
"github.com/zitadel/oidc/v3/pkg/client/rs"
|
||||
"github.com/zitadel/oidc/v3/pkg/oidc"
|
||||
"golang.org/x/text/language"
|
||||
|
||||
oidc_api "github.com/zitadel/zitadel/internal/api/oidc"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/internal/integration"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/authn"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/management"
|
||||
oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2"
|
||||
)
|
||||
|
||||
func TestServer_Introspect(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
project, err := Instance.CreateProject(CTX)
|
||||
require.NoError(t, err)
|
||||
app, err := Instance.CreateOIDCNativeClient(CTX, redirectURI, logoutRedirectURI, project.GetId(), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
wantAudience := []string{app.GetClientId(), project.GetId()}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
api func(*testing.T) (apiID string, resourceServer rs.ResourceServer)
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "client assertion",
|
||||
api: func(t *testing.T) (string, rs.ResourceServer) {
|
||||
api, err := Instance.CreateAPIClientJWT(CTX, project.GetId())
|
||||
require.NoError(t, err)
|
||||
keyResp, err := Instance.Client.Mgmt.AddAppKey(CTX, &management.AddAppKeyRequest{
|
||||
ProjectId: project.GetId(),
|
||||
AppId: api.GetAppId(),
|
||||
Type: authn.KeyType_KEY_TYPE_JSON,
|
||||
ExpirationDate: nil,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
resourceServer, err := Instance.CreateResourceServerJWTProfile(CTX, keyResp.GetKeyDetails())
|
||||
require.NoError(t, err)
|
||||
return api.GetClientId(), resourceServer
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "client credentials",
|
||||
api: func(t *testing.T) (string, rs.ResourceServer) {
|
||||
api, err := Instance.CreateAPIClientBasic(CTX, project.GetId())
|
||||
require.NoError(t, err)
|
||||
resourceServer, err := Instance.CreateResourceServerClientCredentials(CTX, api.GetClientId(), api.GetClientSecret())
|
||||
require.NoError(t, err)
|
||||
return api.GetClientId(), resourceServer
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "client invalid id, error",
|
||||
api: func(t *testing.T) (string, rs.ResourceServer) {
|
||||
api, err := Instance.CreateAPIClientBasic(CTX, project.GetId())
|
||||
require.NoError(t, err)
|
||||
resourceServer, err := Instance.CreateResourceServerClientCredentials(CTX, "xxxxx", api.GetClientSecret())
|
||||
require.NoError(t, err)
|
||||
return api.GetClientId(), resourceServer
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "client invalid secret, error",
|
||||
api: func(t *testing.T) (string, rs.ResourceServer) {
|
||||
api, err := Instance.CreateAPIClientBasic(CTX, project.GetId())
|
||||
require.NoError(t, err)
|
||||
resourceServer, err := Instance.CreateResourceServerClientCredentials(CTX, api.GetClientId(), "xxxxx")
|
||||
require.NoError(t, err)
|
||||
return api.GetClientId(), resourceServer
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "client credentials on jwt client, error",
|
||||
api: func(t *testing.T) (string, rs.ResourceServer) {
|
||||
api, err := Instance.CreateAPIClientJWT(CTX, project.GetId())
|
||||
require.NoError(t, err)
|
||||
resourceServer, err := Instance.CreateResourceServerClientCredentials(CTX, api.GetClientId(), "xxxxx")
|
||||
require.NoError(t, err)
|
||||
return api.GetClientId(), resourceServer
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
apiID, resourceServer := tt.api(t)
|
||||
// wantAudience grows for every API we add to the project.
|
||||
wantAudience = append(wantAudience, apiID)
|
||||
|
||||
scope := []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail, oidc.ScopeOfflineAccess, oidc_api.ScopeResourceOwner}
|
||||
authRequestID := createAuthRequest(t, Instance, app.GetClientId(), redirectURI, scope...)
|
||||
sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId())
|
||||
linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{
|
||||
AuthRequestId: authRequestID,
|
||||
CallbackKind: &oidc_pb.CreateCallbackRequest_Session{
|
||||
Session: &oidc_pb.Session{
|
||||
SessionId: sessionID,
|
||||
SessionToken: sessionToken,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// code exchange
|
||||
code := assertCodeResponse(t, linkResp.GetCallbackUrl())
|
||||
tokens, err := exchangeTokens(t, Instance, app.GetClientId(), code, redirectURI)
|
||||
require.NoError(t, err)
|
||||
assertTokens(t, tokens, true)
|
||||
assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID)
|
||||
|
||||
// test actual introspection
|
||||
introspection, err := rs.Introspect[*oidc.IntrospectionResponse](context.Background(), resourceServer, tokens.AccessToken)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assertIntrospection(t, introspection,
|
||||
Instance.OIDCIssuer(), app.GetClientId(),
|
||||
scope, wantAudience,
|
||||
tokens.Expiry, tokens.Expiry.Add(-12*time.Hour))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_Introspect_invalid_auth_invalid_token(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// ensure that when an invalid authentication and token is sent, the authentication error is returned
|
||||
// https://github.com/zitadel/zitadel/pull/8133
|
||||
resourceServer, err := Instance.CreateResourceServerClientCredentials(CTX, "xxxxx", "xxxxx")
|
||||
require.NoError(t, err)
|
||||
_, err = rs.Introspect[*oidc.IntrospectionResponse](context.Background(), resourceServer, "xxxxx")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func assertIntrospection(
|
||||
t *testing.T,
|
||||
introspection *oidc.IntrospectionResponse,
|
||||
issuer, clientID string,
|
||||
scope, audience []string,
|
||||
expiration, creation time.Time,
|
||||
) {
|
||||
assert.True(t, introspection.Active)
|
||||
assert.Equal(t, scope, []string(introspection.Scope))
|
||||
assert.Equal(t, clientID, introspection.ClientID)
|
||||
assert.Equal(t, oidc.BearerToken, introspection.TokenType)
|
||||
assertOIDCTime(t, introspection.Expiration, expiration)
|
||||
assertOIDCTime(t, introspection.IssuedAt, creation)
|
||||
assertOIDCTime(t, introspection.NotBefore, creation)
|
||||
assert.Equal(t, User.GetUserId(), introspection.Subject)
|
||||
assert.ElementsMatch(t, audience, introspection.Audience)
|
||||
assert.Equal(t, issuer, introspection.Issuer)
|
||||
assert.NotEmpty(t, introspection.JWTID)
|
||||
assert.NotEmpty(t, introspection.Username)
|
||||
assert.Equal(t, introspection.Username, introspection.PreferredUsername)
|
||||
assert.Equal(t, "Mickey", introspection.GivenName)
|
||||
assert.Equal(t, "Mouse", introspection.FamilyName)
|
||||
assert.Equal(t, "Mickey Mouse", introspection.Name)
|
||||
assert.Equal(t, oidc.Gender("male"), introspection.Gender)
|
||||
assert.Equal(t, oidc.NewLocale(language.Dutch), introspection.Locale)
|
||||
assert.Equal(t, introspection.Username, introspection.Email)
|
||||
assert.False(t, bool(introspection.EmailVerified))
|
||||
assertOIDCTime(t, introspection.UpdatedAt, User.GetDetails().GetChangeDate().AsTime())
|
||||
|
||||
require.NotNil(t, introspection.Claims)
|
||||
assert.Equal(t, User.Details.ResourceOwner, introspection.Claims[oidc_api.ClaimResourceOwnerID])
|
||||
assert.NotEmpty(t, introspection.Claims[oidc_api.ClaimResourceOwnerName])
|
||||
assert.NotEmpty(t, introspection.Claims[oidc_api.ClaimResourceOwnerPrimaryDomain])
|
||||
}
|
||||
|
||||
// TestServer_VerifyClient tests verification by running code flow tests
|
||||
// with clients that have different authentication methods.
|
||||
func TestServer_VerifyClient(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId())
|
||||
project, err := Instance.CreateProject(CTX)
|
||||
require.NoError(t, err)
|
||||
|
||||
inactiveClient, err := Instance.CreateOIDCInactivateClient(CTX, redirectURI, logoutRedirectURI, project.GetId())
|
||||
require.NoError(t, err)
|
||||
nativeClient, err := Instance.CreateOIDCNativeClient(CTX, redirectURI, logoutRedirectURI, project.GetId(), false)
|
||||
require.NoError(t, err)
|
||||
basicWebClient, err := Instance.CreateOIDCWebClientBasic(CTX, redirectURI, logoutRedirectURI, project.GetId())
|
||||
require.NoError(t, err)
|
||||
jwtWebClient, keyData, err := Instance.CreateOIDCWebClientJWT(CTX, redirectURI, logoutRedirectURI, project.GetId())
|
||||
require.NoError(t, err)
|
||||
|
||||
type clientDetails struct {
|
||||
authReqClientID string
|
||||
clientID string
|
||||
clientSecret string
|
||||
keyData []byte
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
client clientDetails
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "empty client ID error",
|
||||
client: clientDetails{
|
||||
authReqClientID: nativeClient.GetClientId(),
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "client not found error",
|
||||
client: clientDetails{
|
||||
authReqClientID: nativeClient.GetClientId(),
|
||||
clientID: "foo",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "client inactive error",
|
||||
client: clientDetails{
|
||||
authReqClientID: nativeClient.GetClientId(),
|
||||
clientID: inactiveClient.GetClientId(),
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "native client success",
|
||||
client: clientDetails{
|
||||
authReqClientID: nativeClient.GetClientId(),
|
||||
clientID: nativeClient.GetClientId(),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "web client basic secret empty error",
|
||||
client: clientDetails{
|
||||
authReqClientID: basicWebClient.GetClientId(),
|
||||
clientID: basicWebClient.GetClientId(),
|
||||
clientSecret: "",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "web client basic secret invalid error",
|
||||
client: clientDetails{
|
||||
authReqClientID: basicWebClient.GetClientId(),
|
||||
clientID: basicWebClient.GetClientId(),
|
||||
clientSecret: "wrong",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "web client basic secret success",
|
||||
client: clientDetails{
|
||||
authReqClientID: basicWebClient.GetClientId(),
|
||||
clientID: basicWebClient.GetClientId(),
|
||||
clientSecret: basicWebClient.GetClientSecret(),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "web client JWT profile empty assertion error",
|
||||
client: clientDetails{
|
||||
authReqClientID: jwtWebClient.GetClientId(),
|
||||
clientID: jwtWebClient.GetClientId(),
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "web client JWT profile invalid assertion error",
|
||||
client: clientDetails{
|
||||
authReqClientID: jwtWebClient.GetClientId(),
|
||||
clientID: jwtWebClient.GetClientId(),
|
||||
keyData: createInvalidKeyData(t, jwtWebClient),
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "web client JWT profile success",
|
||||
client: clientDetails{
|
||||
authReqClientID: jwtWebClient.GetClientId(),
|
||||
clientID: jwtWebClient.GetClientId(),
|
||||
keyData: keyData,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
authRequestID, err := Instance.CreateOIDCAuthRequest(CTX, tt.client.authReqClientID, Instance.Users.Get(integration.UserTypeLogin).ID, redirectURI, oidc.ScopeOpenID)
|
||||
require.NoError(t, err)
|
||||
linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{
|
||||
AuthRequestId: authRequestID,
|
||||
CallbackKind: &oidc_pb.CreateCallbackRequest_Session{
|
||||
Session: &oidc_pb.Session{
|
||||
SessionId: sessionID,
|
||||
SessionToken: sessionToken,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// use a new RP so we can inject different credentials
|
||||
var options []rp.Option
|
||||
if tt.client.keyData != nil {
|
||||
options = append(options, rp.WithJWTProfile(rp.SignerFromKeyFile(tt.client.keyData)))
|
||||
}
|
||||
provider, err := rp.NewRelyingPartyOIDC(CTX, Instance.OIDCIssuer(), tt.client.clientID, tt.client.clientSecret, redirectURI, []string{oidc.ScopeOpenID}, options...)
|
||||
require.NoError(t, err)
|
||||
|
||||
// test code exchange
|
||||
code := assertCodeResponse(t, linkResp.GetCallbackUrl())
|
||||
codeOpts := codeExchangeOptions(t, provider)
|
||||
tokens, err := rp.CodeExchange[*oidc.IDTokenClaims](context.Background(), code, provider, codeOpts...)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assertTokens(t, tokens, false)
|
||||
assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func codeExchangeOptions(t testing.TB, provider rp.RelyingParty) []rp.CodeExchangeOpt {
|
||||
codeOpts := []rp.CodeExchangeOpt{rp.WithCodeVerifier(integration.CodeVerifier)}
|
||||
if signer := provider.Signer(); signer != nil {
|
||||
assertion, err := client.SignedJWTProfileAssertion(provider.OAuthConfig().ClientID, []string{provider.Issuer()}, time.Hour, provider.Signer())
|
||||
require.NoError(t, err)
|
||||
codeOpts = append(codeOpts, rp.WithClientAssertionJWT(assertion))
|
||||
}
|
||||
return codeOpts
|
||||
}
|
||||
|
||||
func createInvalidKeyData(t testing.TB, client *management.AddOIDCAppResponse) []byte {
|
||||
key := domain.ApplicationKey{
|
||||
Type: domain.AuthNKeyTypeJSON,
|
||||
KeyID: "1",
|
||||
PrivateKey: []byte("-----BEGIN RSA PRIVATE KEY-----\nMIIEogIBAAKCAQEAxHd087RoEm9ywVWZ/H+tDWxQsmVvhfRz4jAq/RfU+OWXNH4J\njMMSHdFs0Q+WP98nNXRyc7fgbMb8NdmlB2yD4qLYapN5SDaBc5dh/3EnyFt53oSs\njTlKnQUPAeJr2qh/NY046CfyUyQMM4JR5OiQFo4TssfWnqdcgamGt0AEnk2lvbMZ\nKQdAqNS9lDzYbjMGavEQPTZE35mFXFQXjaooZXq+TIa7hbaq7/idH7cHNbLcPLgj\nfPQA8q+DYvnvhXlmq0LPQZH3Oiixf+SF2vRwrBzT2mqGD2OiOkUmhuPwyqEiiBHt\nfxklRtRU6WfLa1Gcb1PsV0uoBGpV3KybIl/GlwIDAQABAoIBAEQjDduLgOCL6Gem\n0X3hpdnW6/HC/jed/Sa//9jBECq2LYeWAqff64ON40hqOHi0YvvGA/+gEOSI6mWe\nsv5tIxxRz+6+cLybsq+tG96kluCE4TJMHy/nY7orS/YiWbd+4odnEApr+D3fbZ/b\nnZ1fDsHTyn8hkYx6jLmnWsJpIHDp7zxD76y7k2Bbg6DZrCGiVxngiLJk23dvz79W\np03lHLM7XE92aFwXQmhfxHGxrbuoB/9eY4ai5IHp36H4fw0vL6NXdNQAo/bhe0p9\nAYB7y0ZumF8Hg0Z/BmMeEzLy6HrYB+VE8cO93pNjhSyH+p2yDB/BlUyTiRLQAoM0\nVTmOZXECgYEA7NGlzpKNhyQEJihVqt0MW0LhKIO/xbBn+XgYfX6GpqPa/ucnMx5/\nVezpl3gK8IU4wPUhAyXXAHJiqNBcEeyxrw0MXLujDVMJgYaLysCLJdvMVgoY08mS\nK5IQivpbozpf4+0y3mOnA+Sy1kbfxv2X8xiWLODRQW3f3q/xoklwOR8CgYEA1GEe\nfaibOFTQAYcIVj77KXtBfYZsX3EGAyfAN9O7cKHq5oaxVstwnF47WxpuVtoKZxCZ\nbNm9D5WvQ9b+Ztpioe42tzwE7Bff/Osj868GcDdRPK7nFlh9N2yVn/D514dOYVwR\n4MBr1KrJzgRWt4QqS4H+to1GzudDTSNlG7gnK4kCgYBUi6AbOHzoYzZL/RhgcJwp\ntJ23nhmH1Su5h2OO4e3mbhcP66w19sxU+8iFN+kH5zfUw26utgKk+TE5vXExQQRK\nT2k7bg2PAzcgk80ybD0BHhA8I0yrx4m0nmfjhe/TPVLgh10iwgbtP+eM0i6v1vc5\nZWyvxu9N4ZEL6lpkqr0y1wKBgG/NAIQd8jhhTW7Aav8cAJQBsqQl038avJOEpYe+\nCnpsgoAAf/K0/f8TDCQVceh+t+MxtdK7fO9rWOxZjWsPo8Si5mLnUaAHoX4/OpnZ\nlYYVWMqdOEFnK+O1Yb7k2GFBdV2DXlX2dc1qavntBsls5ecB89id3pyk2aUN8Pf6\npYQhAoGAMGtrHFely9wyaxI0RTCyfmJbWZHGVGkv6ELK8wneJjdjl82XOBUGCg5q\naRCrTZ3dPitKwrUa6ibJCIFCIziiriBmjDvTHzkMvoJEap2TVxYNDR6IfINVsQ57\nlOsiC4A2uGq4Lbfld+gjoplJ5GX6qXtTgZ6m7eo0y7U6zm2tkN0=\n-----END RSA PRIVATE KEY-----\n"),
|
||||
ApplicationID: client.GetAppId(),
|
||||
ClientID: client.GetClientId(),
|
||||
}
|
||||
data, err := key.Detail()
|
||||
require.NoError(t, err)
|
||||
return data
|
||||
}
|
123
internal/api/oidc/integration_test/keys_test.go
Normal file
123
internal/api/oidc/integration_test/keys_test.go
Normal file
@@ -0,0 +1,123 @@
|
||||
//go:build integration
|
||||
|
||||
package oidc_test
|
||||
|
||||
import (
|
||||
"crypto/rsa"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-jose/go-jose/v4"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/zitadel/oidc/v3/pkg/client"
|
||||
"github.com/zitadel/oidc/v3/pkg/oidc"
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
http_util "github.com/zitadel/zitadel/internal/api/http"
|
||||
"github.com/zitadel/zitadel/internal/crypto"
|
||||
"github.com/zitadel/zitadel/internal/integration"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/feature/v2"
|
||||
oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2"
|
||||
)
|
||||
|
||||
func TestServer_Keys(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
instance := integration.NewInstance(CTX)
|
||||
ctxLogin := instance.WithAuthorization(CTX, integration.UserTypeLogin)
|
||||
|
||||
clientID, _ := createClient(t, instance)
|
||||
authRequestID := createAuthRequest(t, instance, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess, zitadelAudienceScope)
|
||||
|
||||
instance.RegisterUserPasskey(instance.WithAuthorization(CTX, integration.UserTypeOrgOwner), instance.AdminUserID)
|
||||
sessionID, sessionToken, _, _ := instance.CreateVerifiedWebAuthNSession(t, ctxLogin, instance.AdminUserID)
|
||||
linkResp, err := instance.Client.OIDCv2.CreateCallback(ctxLogin, &oidc_pb.CreateCallbackRequest{
|
||||
AuthRequestId: authRequestID,
|
||||
CallbackKind: &oidc_pb.CreateCallbackRequest_Session{
|
||||
Session: &oidc_pb.Session{
|
||||
SessionId: sessionID,
|
||||
SessionToken: sessionToken,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// code exchange so we are sure there is 1 legacy key pair.
|
||||
code := assertCodeResponse(t, linkResp.GetCallbackUrl())
|
||||
_, err = exchangeTokens(t, instance, clientID, code, redirectURI)
|
||||
require.NoError(t, err)
|
||||
|
||||
issuer := http_util.BuildHTTP(instance.Domain, instance.Config.Port, instance.Config.Secure)
|
||||
discovery, err := client.Discover(CTX, issuer, http.DefaultClient)
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
webKeyFeature bool
|
||||
wantLen int
|
||||
}{
|
||||
{
|
||||
name: "legacy only",
|
||||
webKeyFeature: false,
|
||||
wantLen: 1,
|
||||
},
|
||||
{
|
||||
name: "webkeys with legacy",
|
||||
webKeyFeature: true,
|
||||
wantLen: 3, // 1 legacy + 2 created by enabling feature flag
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ensureWebKeyFeature(t, instance, tt.webKeyFeature)
|
||||
|
||||
assert.EventuallyWithT(t, func(ttt *assert.CollectT) {
|
||||
resp, err := http.Get(discovery.JwksURI)
|
||||
require.NoError(ttt, err)
|
||||
require.Equal(ttt, resp.StatusCode, http.StatusOK)
|
||||
defer resp.Body.Close()
|
||||
|
||||
got := new(jose.JSONWebKeySet)
|
||||
err = json.NewDecoder(resp.Body).Decode(got)
|
||||
require.NoError(ttt, err)
|
||||
|
||||
assert.Len(t, got.Keys, tt.wantLen)
|
||||
for _, key := range got.Keys {
|
||||
_, ok := key.Key.(*rsa.PublicKey)
|
||||
require.True(ttt, ok)
|
||||
require.NotEmpty(ttt, key.KeyID)
|
||||
require.Equal(ttt, key.Algorithm, string(jose.RS256))
|
||||
require.Equal(ttt, key.Use, crypto.KeyUsageSigning.String())
|
||||
}
|
||||
|
||||
cacheControl := resp.Header.Get("cache-control")
|
||||
if tt.webKeyFeature {
|
||||
require.Equal(ttt, "max-age=300, must-revalidate", cacheControl)
|
||||
return
|
||||
}
|
||||
require.Equal(ttt, "no-store", cacheControl)
|
||||
|
||||
}, time.Minute, time.Second/10)
|
||||
})
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func ensureWebKeyFeature(t *testing.T, instance *integration.Instance, set bool) {
|
||||
ctxIam := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner)
|
||||
|
||||
_, err := instance.Client.FeatureV2.SetInstanceFeatures(ctxIam, &feature.SetInstanceFeaturesRequest{
|
||||
WebKey: proto.Bool(set),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Cleanup(func() {
|
||||
_, err := instance.Client.FeatureV2.SetInstanceFeatures(ctxIam, &feature.SetInstanceFeaturesRequest{
|
||||
WebKey: proto.Bool(false),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
442
internal/api/oidc/integration_test/oidc_test.go
Normal file
442
internal/api/oidc/integration_test/oidc_test.go
Normal file
@@ -0,0 +1,442 @@
|
||||
//go:build integration
|
||||
|
||||
package oidc_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/zitadel/oidc/v3/pkg/client/rp"
|
||||
"github.com/zitadel/oidc/v3/pkg/oidc"
|
||||
"google.golang.org/grpc/metadata"
|
||||
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/internal/integration"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/auth"
|
||||
mgmt "github.com/zitadel/zitadel/pkg/grpc/management"
|
||||
oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/session/v2"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/user/v2"
|
||||
)
|
||||
|
||||
var (
|
||||
CTX context.Context
|
||||
CTXLOGIN context.Context
|
||||
CTXIAM context.Context
|
||||
Instance *integration.Instance
|
||||
User *user.AddHumanUserResponse
|
||||
)
|
||||
|
||||
const (
|
||||
redirectURI = "https://callback"
|
||||
redirectURIImplicit = "http://localhost:9999/callback"
|
||||
logoutRedirectURI = "https://logged-out"
|
||||
zitadelAudienceScope = domain.ProjectIDScope + domain.ProjectIDScopeZITADEL + domain.AudSuffix
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
os.Exit(func() int {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
Instance = integration.NewInstance(ctx)
|
||||
|
||||
CTX = Instance.WithAuthorization(ctx, integration.UserTypeOrgOwner)
|
||||
User = Instance.CreateHumanUser(CTX)
|
||||
Instance.SetUserPassword(CTX, User.GetUserId(), integration.UserPassword, false)
|
||||
Instance.RegisterUserPasskey(CTX, User.GetUserId())
|
||||
CTXLOGIN = Instance.WithAuthorization(ctx, integration.UserTypeLogin)
|
||||
CTXIAM = Instance.WithAuthorization(ctx, integration.UserTypeIAMOwner)
|
||||
return m.Run()
|
||||
}())
|
||||
}
|
||||
|
||||
func Test_ZITADEL_API_missing_audience_scope(t *testing.T) {
|
||||
clientID, _ := createClient(t, Instance)
|
||||
authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID)
|
||||
sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId())
|
||||
linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{
|
||||
AuthRequestId: authRequestID,
|
||||
CallbackKind: &oidc_pb.CreateCallbackRequest_Session{
|
||||
Session: &oidc_pb.Session{
|
||||
SessionId: sessionID,
|
||||
SessionToken: sessionToken,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// code exchange
|
||||
code := assertCodeResponse(t, linkResp.GetCallbackUrl())
|
||||
tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI)
|
||||
require.NoError(t, err)
|
||||
assertTokens(t, tokens, false)
|
||||
assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID)
|
||||
|
||||
ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("%s %s", tokens.TokenType, tokens.AccessToken))
|
||||
|
||||
myUserResp, err := Instance.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{})
|
||||
require.Error(t, err)
|
||||
require.Nil(t, myUserResp)
|
||||
}
|
||||
|
||||
func Test_ZITADEL_API_missing_authentication(t *testing.T) {
|
||||
clientID, _ := createClient(t, Instance)
|
||||
authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID, zitadelAudienceScope)
|
||||
createResp, err := Instance.Client.SessionV2.CreateSession(CTX, &session.CreateSessionRequest{
|
||||
Checks: &session.Checks{
|
||||
User: &session.CheckUser{
|
||||
Search: &session.CheckUser_UserId{UserId: User.GetUserId()},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{
|
||||
AuthRequestId: authRequestID,
|
||||
CallbackKind: &oidc_pb.CreateCallbackRequest_Session{
|
||||
Session: &oidc_pb.Session{
|
||||
SessionId: createResp.GetSessionId(),
|
||||
SessionToken: createResp.GetSessionToken(),
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// code exchange
|
||||
code := assertCodeResponse(t, linkResp.GetCallbackUrl())
|
||||
tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("%s %s", tokens.TokenType, tokens.AccessToken))
|
||||
|
||||
myUserResp, err := Instance.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{})
|
||||
require.Error(t, err)
|
||||
require.Nil(t, myUserResp)
|
||||
}
|
||||
|
||||
func Test_ZITADEL_API_missing_mfa_policy(t *testing.T) {
|
||||
clientID, _ := createClient(t, Instance)
|
||||
org := Instance.CreateOrganization(CTXIAM, fmt.Sprintf("ZITADEL_API_MISSING_MFA_%d", time.Now().UnixNano()), fmt.Sprintf("%d@mouse.com", time.Now().UnixNano()))
|
||||
userID := org.CreatedAdmins[0].GetUserId()
|
||||
Instance.SetUserPassword(CTXIAM, userID, integration.UserPassword, false)
|
||||
authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID, zitadelAudienceScope)
|
||||
sessionID, sessionToken, startTime, changeTime := Instance.CreatePasswordSession(t, CTXLOGIN, userID, integration.UserPassword)
|
||||
linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{
|
||||
AuthRequestId: authRequestID,
|
||||
CallbackKind: &oidc_pb.CreateCallbackRequest_Session{
|
||||
Session: &oidc_pb.Session{
|
||||
SessionId: sessionID,
|
||||
SessionToken: sessionToken,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// code exchange
|
||||
code := assertCodeResponse(t, linkResp.GetCallbackUrl())
|
||||
tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI)
|
||||
require.NoError(t, err)
|
||||
assertIDTokenClaims(t, tokens.IDTokenClaims, userID, armPassword, startTime, changeTime, sessionID)
|
||||
|
||||
ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("%s %s", tokens.TokenType, tokens.AccessToken))
|
||||
|
||||
// pre check if request would succeed
|
||||
myUserResp, err := Instance.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, userID, myUserResp.GetUser().GetId())
|
||||
|
||||
// require MFA
|
||||
ctxOrg := metadata.AppendToOutgoingContext(CTXIAM, "x-zitadel-orgid", org.GetOrganizationId())
|
||||
_, err = Instance.Client.Mgmt.AddCustomLoginPolicy(ctxOrg, &mgmt.AddCustomLoginPolicyRequest{
|
||||
ForceMfa: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// make sure policy is projected
|
||||
retryDuration := 5 * time.Second
|
||||
if ctxDeadline, ok := CTX.Deadline(); ok {
|
||||
retryDuration = time.Until(ctxDeadline)
|
||||
}
|
||||
require.EventuallyWithT(t, func(ttt *assert.CollectT) {
|
||||
got, getErr := Instance.Client.Mgmt.GetLoginPolicy(ctxOrg, &mgmt.GetLoginPolicyRequest{})
|
||||
assert.NoError(ttt, getErr)
|
||||
assert.False(ttt, got.GetPolicy().IsDefault)
|
||||
|
||||
}, retryDuration, time.Millisecond*100, "timeout waiting for login policy")
|
||||
|
||||
// now it must fail
|
||||
myUserResp, err = Instance.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{})
|
||||
require.Error(t, err)
|
||||
require.Nil(t, myUserResp)
|
||||
}
|
||||
|
||||
func Test_ZITADEL_API_success(t *testing.T) {
|
||||
clientID, _ := createClient(t, Instance)
|
||||
authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID, zitadelAudienceScope)
|
||||
sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId())
|
||||
linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{
|
||||
AuthRequestId: authRequestID,
|
||||
CallbackKind: &oidc_pb.CreateCallbackRequest_Session{
|
||||
Session: &oidc_pb.Session{
|
||||
SessionId: sessionID,
|
||||
SessionToken: sessionToken,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// code exchange
|
||||
code := assertCodeResponse(t, linkResp.GetCallbackUrl())
|
||||
tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI)
|
||||
require.NoError(t, err)
|
||||
assertTokens(t, tokens, false)
|
||||
assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID)
|
||||
|
||||
ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("%s %s", tokens.TokenType, tokens.AccessToken))
|
||||
|
||||
myUserResp, err := Instance.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, User.GetUserId(), myUserResp.GetUser().GetId())
|
||||
}
|
||||
|
||||
func Test_ZITADEL_API_glob_redirects(t *testing.T) {
|
||||
const redirectURI = "https://my-org-1yfnjl2xj-my-app.vercel.app/api/auth/callback/zitadel"
|
||||
clientID, _ := createClientWithOpts(t, Instance, clientOpts{
|
||||
redirectURI: "https://my-org-*-my-app.vercel.app/api/auth/callback/zitadel",
|
||||
logoutURI: "https://my-org-*-my-app.vercel.app/",
|
||||
devMode: true,
|
||||
})
|
||||
authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID, zitadelAudienceScope)
|
||||
sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId())
|
||||
linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{
|
||||
AuthRequestId: authRequestID,
|
||||
CallbackKind: &oidc_pb.CreateCallbackRequest_Session{
|
||||
Session: &oidc_pb.Session{
|
||||
SessionId: sessionID,
|
||||
SessionToken: sessionToken,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// code exchange
|
||||
code := assertCodeResponse(t, linkResp.GetCallbackUrl())
|
||||
tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI)
|
||||
require.NoError(t, err)
|
||||
assertTokens(t, tokens, false)
|
||||
assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID)
|
||||
|
||||
ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("%s %s", tokens.TokenType, tokens.AccessToken))
|
||||
|
||||
myUserResp, err := Instance.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, User.GetUserId(), myUserResp.GetUser().GetId())
|
||||
}
|
||||
|
||||
func Test_ZITADEL_API_inactive_access_token(t *testing.T) {
|
||||
clientID, _ := createClient(t, Instance)
|
||||
authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess, zitadelAudienceScope)
|
||||
sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId())
|
||||
linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{
|
||||
AuthRequestId: authRequestID,
|
||||
CallbackKind: &oidc_pb.CreateCallbackRequest_Session{
|
||||
Session: &oidc_pb.Session{
|
||||
SessionId: sessionID,
|
||||
SessionToken: sessionToken,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// code exchange
|
||||
code := assertCodeResponse(t, linkResp.GetCallbackUrl())
|
||||
tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI)
|
||||
require.NoError(t, err)
|
||||
assertTokens(t, tokens, true)
|
||||
assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID)
|
||||
|
||||
// make sure token works
|
||||
ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("%s %s", tokens.TokenType, tokens.AccessToken))
|
||||
myUserResp, err := Instance.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, User.GetUserId(), myUserResp.GetUser().GetId())
|
||||
|
||||
// refresh token
|
||||
newTokens, err := refreshTokens(t, clientID, tokens.RefreshToken)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, newTokens.AccessToken)
|
||||
|
||||
// use invalidated token
|
||||
ctx = metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("%s %s", tokens.TokenType, tokens.AccessToken))
|
||||
myUserResp, err = Instance.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{})
|
||||
require.Error(t, err)
|
||||
require.Nil(t, myUserResp)
|
||||
}
|
||||
|
||||
func Test_ZITADEL_API_terminated_session(t *testing.T) {
|
||||
clientID, _ := createClient(t, Instance)
|
||||
provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURI)
|
||||
require.NoError(t, err)
|
||||
authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess, zitadelAudienceScope)
|
||||
sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId())
|
||||
linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{
|
||||
AuthRequestId: authRequestID,
|
||||
CallbackKind: &oidc_pb.CreateCallbackRequest_Session{
|
||||
Session: &oidc_pb.Session{
|
||||
SessionId: sessionID,
|
||||
SessionToken: sessionToken,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// code exchange
|
||||
code := assertCodeResponse(t, linkResp.GetCallbackUrl())
|
||||
tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI)
|
||||
require.NoError(t, err)
|
||||
assertTokens(t, tokens, true)
|
||||
assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID)
|
||||
|
||||
// make sure token works
|
||||
ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("%s %s", tokens.TokenType, tokens.AccessToken))
|
||||
myUserResp, err := Instance.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, User.GetUserId(), myUserResp.GetUser().GetId())
|
||||
|
||||
// end session
|
||||
postLogoutRedirect, err := rp.EndSession(CTX, provider, tokens.IDToken, logoutRedirectURI, "state")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, logoutRedirectURI+"?state=state", postLogoutRedirect.String())
|
||||
|
||||
// use token from terminated session
|
||||
ctx = metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("%s %s", tokens.TokenType, tokens.AccessToken))
|
||||
myUserResp, err = Instance.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{})
|
||||
require.Error(t, err)
|
||||
require.Nil(t, myUserResp)
|
||||
}
|
||||
|
||||
func Test_ZITADEL_API_terminated_session_user_disabled(t *testing.T) {
|
||||
clientID, _ := createClient(t, Instance)
|
||||
tests := []struct {
|
||||
name string
|
||||
disable func(userID string) error
|
||||
}{
|
||||
{
|
||||
name: "deactivated",
|
||||
disable: func(userID string) error {
|
||||
_, err := Instance.Client.UserV2.DeactivateUser(CTX, &user.DeactivateUserRequest{UserId: userID})
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "locked",
|
||||
disable: func(userID string) error {
|
||||
_, err := Instance.Client.UserV2.LockUser(CTX, &user.LockUserRequest{UserId: userID})
|
||||
return err
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "deleted",
|
||||
disable: func(userID string) error {
|
||||
_, err := Instance.Client.UserV2.DeleteUser(CTX, &user.DeleteUserRequest{UserId: userID})
|
||||
return err
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
disabledUser := Instance.CreateHumanUser(CTX)
|
||||
Instance.SetUserPassword(CTX, disabledUser.GetUserId(), integration.UserPassword, false)
|
||||
authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID, oidc.ScopeOfflineAccess, zitadelAudienceScope)
|
||||
sessionID, sessionToken, startTime, changeTime := Instance.CreatePasswordSession(t, CTXLOGIN, disabledUser.GetUserId(), integration.UserPassword)
|
||||
linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{
|
||||
AuthRequestId: authRequestID,
|
||||
CallbackKind: &oidc_pb.CreateCallbackRequest_Session{
|
||||
Session: &oidc_pb.Session{
|
||||
SessionId: sessionID,
|
||||
SessionToken: sessionToken,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// code exchange
|
||||
code := assertCodeResponse(t, linkResp.GetCallbackUrl())
|
||||
tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI)
|
||||
require.NoError(t, err)
|
||||
assertTokens(t, tokens, true)
|
||||
assertIDTokenClaims(t, tokens.IDTokenClaims, disabledUser.GetUserId(), armPassword, startTime, changeTime, sessionID)
|
||||
|
||||
// make sure token works
|
||||
ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("%s %s", tokens.TokenType, tokens.AccessToken))
|
||||
myUserResp, err := Instance.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, disabledUser.GetUserId(), myUserResp.GetUser().GetId())
|
||||
|
||||
// deactivate user
|
||||
err = tt.disable(disabledUser.GetUserId())
|
||||
require.NoError(t, err)
|
||||
|
||||
// use token from deactivated user
|
||||
ctx = metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("%s %s", tokens.TokenType, tokens.AccessToken))
|
||||
myUserResp, err = Instance.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{})
|
||||
require.Error(t, err)
|
||||
require.Nil(t, myUserResp)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func createClient(t testing.TB, instance *integration.Instance) (clientID, projectID string) {
|
||||
return createClientWithOpts(t, instance, clientOpts{
|
||||
redirectURI: redirectURI,
|
||||
logoutURI: logoutRedirectURI,
|
||||
devMode: false,
|
||||
})
|
||||
}
|
||||
|
||||
type clientOpts struct {
|
||||
redirectURI string
|
||||
logoutURI string
|
||||
devMode bool
|
||||
}
|
||||
|
||||
func createClientWithOpts(t testing.TB, instance *integration.Instance, opts clientOpts) (clientID, projectID string) {
|
||||
ctx := instance.WithAuthorization(CTX, integration.UserTypeOrgOwner)
|
||||
|
||||
project, err := instance.CreateProject(ctx)
|
||||
require.NoError(t, err)
|
||||
app, err := instance.CreateOIDCNativeClient(ctx, opts.redirectURI, opts.logoutURI, project.GetId(), opts.devMode)
|
||||
require.NoError(t, err)
|
||||
return app.GetClientId(), project.GetId()
|
||||
}
|
||||
|
||||
func createImplicitClient(t testing.TB) string {
|
||||
app, err := Instance.CreateOIDCImplicitFlowClient(CTX, redirectURIImplicit)
|
||||
require.NoError(t, err)
|
||||
return app.GetClientId()
|
||||
}
|
||||
|
||||
func createAuthRequest(t testing.TB, instance *integration.Instance, clientID, redirectURI string, scope ...string) string {
|
||||
redURL, err := instance.CreateOIDCAuthRequest(CTX, clientID, instance.Users.Get(integration.UserTypeLogin).ID, redirectURI, scope...)
|
||||
require.NoError(t, err)
|
||||
return redURL
|
||||
}
|
||||
|
||||
func createAuthRequestImplicit(t testing.TB, clientID, redirectURI string, scope ...string) string {
|
||||
redURL, err := Instance.CreateOIDCAuthRequestImplicit(CTX, clientID, Instance.Users.Get(integration.UserTypeLogin).ID, redirectURI, scope...)
|
||||
require.NoError(t, err)
|
||||
return redURL
|
||||
}
|
||||
|
||||
func assertOIDCTime(t *testing.T, actual oidc.Time, expected time.Time) {
|
||||
assertOIDCTimeRange(t, actual, expected, expected)
|
||||
}
|
||||
|
||||
func assertOIDCTimeRange(t *testing.T, actual oidc.Time, expectedStart, expectedEnd time.Time) {
|
||||
assert.WithinRange(t, actual.AsTime(), expectedStart.Add(-10*time.Second), expectedEnd.Add(10*time.Second))
|
||||
}
|
78
internal/api/oidc/integration_test/server_test.go
Normal file
78
internal/api/oidc/integration_test/server_test.go
Normal file
@@ -0,0 +1,78 @@
|
||||
//go:build integration
|
||||
|
||||
package oidc_test
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/zitadel/oidc/v3/pkg/client"
|
||||
"github.com/zitadel/oidc/v3/pkg/client/rp"
|
||||
"github.com/zitadel/oidc/v3/pkg/oidc"
|
||||
"github.com/zitadel/schema"
|
||||
)
|
||||
|
||||
func TestServer_RefreshToken_Status(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
clientID, _ := createClient(t, Instance)
|
||||
provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURI)
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
refreshToken string
|
||||
}{
|
||||
{
|
||||
name: "invalid base64",
|
||||
refreshToken: "~!~@#$%",
|
||||
},
|
||||
{
|
||||
name: "invalid after decrypt",
|
||||
refreshToken: "DEADBEEFDEADBEEF",
|
||||
},
|
||||
{
|
||||
name: "short input",
|
||||
refreshToken: "DEAD",
|
||||
},
|
||||
{
|
||||
name: "empty input",
|
||||
refreshToken: "",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
request := rp.RefreshTokenRequest{
|
||||
RefreshToken: tt.refreshToken,
|
||||
ClientID: clientID,
|
||||
GrantType: oidc.GrantTypeRefreshToken,
|
||||
}
|
||||
client.CallTokenEndpoint(CTX, request, tokenEndpointCaller{RelyingParty: provider})
|
||||
|
||||
values := make(url.Values)
|
||||
err := schema.NewEncoder().Encode(request, values)
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := http.Post(provider.OAuthConfig().Endpoint.TokenURL, "application/x-www-form-urlencoded", strings.NewReader(values.Encode()))
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Less(t, resp.StatusCode, 500)
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
t.Log(string(body))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type tokenEndpointCaller struct {
|
||||
rp.RelyingParty
|
||||
}
|
||||
|
||||
func (t tokenEndpointCaller) TokenEndpoint() string {
|
||||
return t.OAuthConfig().Endpoint.TokenURL
|
||||
}
|
@@ -0,0 +1,168 @@
|
||||
//go:build integration
|
||||
|
||||
package oidc_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/brianvoe/gofakeit/v6"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/zitadel/oidc/v3/pkg/client/rp"
|
||||
"github.com/zitadel/oidc/v3/pkg/oidc"
|
||||
|
||||
oidc_api "github.com/zitadel/zitadel/internal/api/oidc"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/management"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/user"
|
||||
)
|
||||
|
||||
func TestServer_ClientCredentialsExchange(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
machine, name, clientID, clientSecret, err := Instance.CreateOIDCCredentialsClient(CTX)
|
||||
require.NoError(t, err)
|
||||
|
||||
type claims struct {
|
||||
name string
|
||||
username string
|
||||
updated time.Time
|
||||
resourceOwnerID any
|
||||
resourceOwnerName any
|
||||
resourceOwnerPrimaryDomain any
|
||||
orgDomain any
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
clientID string
|
||||
clientSecret string
|
||||
scope []string
|
||||
wantClaims claims
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "missing client ID error",
|
||||
clientID: "",
|
||||
clientSecret: clientSecret,
|
||||
scope: []string{oidc.ScopeOpenID},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "client not found error",
|
||||
clientID: "foo",
|
||||
clientSecret: clientSecret,
|
||||
scope: []string{oidc.ScopeOpenID},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "machine user without secret error",
|
||||
clientID: func() string {
|
||||
name := gofakeit.Username()
|
||||
_, err := Instance.Client.Mgmt.AddMachineUser(CTX, &management.AddMachineUserRequest{
|
||||
Name: name,
|
||||
UserName: name,
|
||||
AccessTokenType: user.AccessTokenType_ACCESS_TOKEN_TYPE_JWT,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return name
|
||||
}(),
|
||||
clientSecret: clientSecret,
|
||||
scope: []string{oidc.ScopeOpenID},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "wrong secret error",
|
||||
clientID: clientID,
|
||||
clientSecret: "bar",
|
||||
scope: []string{oidc.ScopeOpenID},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "success",
|
||||
clientID: clientID,
|
||||
clientSecret: clientSecret,
|
||||
scope: []string{oidc.ScopeOpenID},
|
||||
},
|
||||
{
|
||||
name: "openid, profile, email",
|
||||
clientID: clientID,
|
||||
clientSecret: clientSecret,
|
||||
scope: []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail},
|
||||
wantClaims: claims{
|
||||
name: name,
|
||||
username: name,
|
||||
updated: machine.GetDetails().GetChangeDate().AsTime(),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "org id and domain scope",
|
||||
clientID: clientID,
|
||||
clientSecret: clientSecret,
|
||||
scope: []string{
|
||||
oidc.ScopeOpenID,
|
||||
domain.OrgIDScope + Instance.DefaultOrg.Id,
|
||||
domain.OrgDomainPrimaryScope + Instance.DefaultOrg.PrimaryDomain,
|
||||
},
|
||||
wantClaims: claims{
|
||||
resourceOwnerID: Instance.DefaultOrg.Id,
|
||||
resourceOwnerName: Instance.DefaultOrg.Name,
|
||||
resourceOwnerPrimaryDomain: Instance.DefaultOrg.PrimaryDomain,
|
||||
orgDomain: Instance.DefaultOrg.PrimaryDomain,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid org domain filtered",
|
||||
clientID: clientID,
|
||||
clientSecret: clientSecret,
|
||||
scope: []string{
|
||||
oidc.ScopeOpenID,
|
||||
domain.OrgDomainPrimaryScope + Instance.DefaultOrg.PrimaryDomain,
|
||||
domain.OrgDomainPrimaryScope + "foo"},
|
||||
wantClaims: claims{
|
||||
orgDomain: Instance.DefaultOrg.PrimaryDomain,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid org id filtered",
|
||||
clientID: clientID,
|
||||
clientSecret: clientSecret,
|
||||
scope: []string{oidc.ScopeOpenID,
|
||||
domain.OrgIDScope + Instance.DefaultOrg.Id,
|
||||
domain.OrgIDScope + "foo",
|
||||
},
|
||||
wantClaims: claims{
|
||||
resourceOwnerID: Instance.DefaultOrg.Id,
|
||||
resourceOwnerName: Instance.DefaultOrg.Name,
|
||||
resourceOwnerPrimaryDomain: Instance.DefaultOrg.PrimaryDomain,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
provider, err := rp.NewRelyingPartyOIDC(CTX, Instance.OIDCIssuer(), tt.clientID, tt.clientSecret, redirectURI, tt.scope)
|
||||
require.NoError(t, err)
|
||||
tokens, err := rp.ClientCredentials(CTX, provider, nil)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, tokens)
|
||||
userinfo, err := rp.Userinfo[*oidc.UserInfo](CTX, tokens.AccessToken, oidc.BearerToken, machine.GetUserId(), provider)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantClaims.resourceOwnerID, userinfo.Claims[oidc_api.ClaimResourceOwnerID])
|
||||
assert.Equal(t, tt.wantClaims.resourceOwnerName, userinfo.Claims[oidc_api.ClaimResourceOwnerName])
|
||||
assert.Equal(t, tt.wantClaims.resourceOwnerPrimaryDomain, userinfo.Claims[oidc_api.ClaimResourceOwnerPrimaryDomain])
|
||||
assert.Equal(t, tt.wantClaims.orgDomain, userinfo.Claims[domain.OrgDomainPrimaryClaim])
|
||||
assert.Equal(t, tt.wantClaims.name, userinfo.Name)
|
||||
assert.Equal(t, tt.wantClaims.username, userinfo.PreferredUsername)
|
||||
assertOIDCTime(t, userinfo.UpdatedAt, tt.wantClaims.updated)
|
||||
assert.Empty(t, userinfo.UserInfoProfile.FamilyName)
|
||||
assert.Empty(t, userinfo.UserInfoProfile.GivenName)
|
||||
assert.Empty(t, userinfo.UserInfoEmail)
|
||||
assert.Empty(t, userinfo.UserInfoPhone)
|
||||
assert.Empty(t, userinfo.Address)
|
||||
})
|
||||
}
|
||||
}
|
593
internal/api/oidc/integration_test/token_exchange_test.go
Normal file
593
internal/api/oidc/integration_test/token_exchange_test.go
Normal file
@@ -0,0 +1,593 @@
|
||||
//go:build integration
|
||||
|
||||
package oidc_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/zitadel/oidc/v3/pkg/client"
|
||||
"github.com/zitadel/oidc/v3/pkg/client/rp"
|
||||
"github.com/zitadel/oidc/v3/pkg/client/rs"
|
||||
"github.com/zitadel/oidc/v3/pkg/client/tokenexchange"
|
||||
"github.com/zitadel/oidc/v3/pkg/crypto"
|
||||
"github.com/zitadel/oidc/v3/pkg/oidc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/protobuf/proto"
|
||||
|
||||
oidc_api "github.com/zitadel/zitadel/internal/api/oidc"
|
||||
"github.com/zitadel/zitadel/internal/integration"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/admin"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/feature/v2"
|
||||
)
|
||||
|
||||
func setTokenExchangeFeature(t *testing.T, value bool) {
|
||||
iamCTX := Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner)
|
||||
|
||||
_, err := Instance.Client.FeatureV2.SetInstanceFeatures(iamCTX, &feature.SetInstanceFeaturesRequest{
|
||||
OidcTokenExchange: proto.Bool(value),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
|
||||
func resetFeatures(t *testing.T) {
|
||||
iamCTX := Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner)
|
||||
_, err := Instance.Client.FeatureV2.ResetInstanceFeatures(iamCTX, &feature.ResetInstanceFeaturesRequest{})
|
||||
require.NoError(t, err)
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
|
||||
func setImpersonationPolicy(t *testing.T, value bool) {
|
||||
iamCTX := Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner)
|
||||
|
||||
policy, err := Instance.Client.Admin.GetSecurityPolicy(iamCTX, &admin.GetSecurityPolicyRequest{})
|
||||
require.NoError(t, err)
|
||||
if policy.GetPolicy().GetEnableImpersonation() != value {
|
||||
_, err = Instance.Client.Admin.SetSecurityPolicy(iamCTX, &admin.SetSecurityPolicyRequest{
|
||||
EnableImpersonation: value,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
|
||||
func createMachineUserPATWithMembership(t *testing.T, roles ...string) (userID, pat string) {
|
||||
iamCTX := Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner)
|
||||
userID, pat, err := Instance.CreateMachineUserPATWithMembership(iamCTX, roles...)
|
||||
require.NoError(t, err)
|
||||
return userID, pat
|
||||
}
|
||||
|
||||
func accessTokenVerifier(ctx context.Context, server rs.ResourceServer, subject, actorSubject string) func(t *testing.T, token string) {
|
||||
return func(t *testing.T, token string) {
|
||||
resp, err := rs.Introspect[*oidc.IntrospectionResponse](ctx, server, token)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, resp.Active)
|
||||
if subject != "" {
|
||||
assert.Equal(t, subject, resp.Subject)
|
||||
}
|
||||
if actorSubject != "" {
|
||||
require.NotNil(t, resp.Actor)
|
||||
assert.Equal(t, actorSubject, resp.Actor.Subject)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func idTokenVerifier(ctx context.Context, provider rp.RelyingParty, subject, actorSubject string) func(t *testing.T, token string) {
|
||||
return func(t *testing.T, token string) {
|
||||
verifier := provider.IDTokenVerifier()
|
||||
resp, err := rp.VerifyIDToken[*oidc.IDTokenClaims](ctx, token, verifier)
|
||||
require.NoError(t, err)
|
||||
if subject != "" {
|
||||
assert.Equal(t, subject, resp.Subject)
|
||||
}
|
||||
if actorSubject != "" {
|
||||
require.NotNil(t, resp.Actor)
|
||||
assert.Equal(t, actorSubject, resp.Actor.Subject)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func refreshTokenVerifier(ctx context.Context, provider rp.RelyingParty, subject, actorSubject string) func(t *testing.T, token string) {
|
||||
return func(t *testing.T, token string) {
|
||||
clientAssertion, err := client.SignedJWTProfileAssertion(provider.OAuthConfig().ClientID, []string{provider.Issuer()}, time.Hour, provider.Signer())
|
||||
require.NoError(t, err)
|
||||
tokens, err := rp.RefreshTokens[*oidc.IDTokenClaims](ctx, provider, token, clientAssertion, oidc.ClientAssertionTypeJWTAssertion)
|
||||
require.NoError(t, err)
|
||||
|
||||
if subject != "" {
|
||||
assert.Equal(t, subject, tokens.IDTokenClaims.Subject)
|
||||
}
|
||||
if actorSubject != "" {
|
||||
require.NotNil(t, tokens.IDTokenClaims.Actor)
|
||||
assert.Equal(t, actorSubject, tokens.IDTokenClaims.Actor.Subject)
|
||||
}
|
||||
assert.NotEmpty(t, tokens.RefreshToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServer_TokenExchange(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Cleanup(func() {
|
||||
resetFeatures(t)
|
||||
setImpersonationPolicy(t, false)
|
||||
})
|
||||
|
||||
client, keyData, err := Instance.CreateOIDCTokenExchangeClient(CTX)
|
||||
require.NoError(t, err)
|
||||
signer, err := rp.SignerFromKeyFile(keyData)()
|
||||
require.NoError(t, err)
|
||||
exchanger, err := tokenexchange.NewTokenExchangerJWTProfile(CTX, Instance.OIDCIssuer(), client.GetClientId(), signer)
|
||||
require.NoError(t, err)
|
||||
|
||||
time.Sleep(time.Second)
|
||||
|
||||
iamUserID, iamImpersonatorPAT := createMachineUserPATWithMembership(t, "IAM_ADMIN_IMPERSONATOR")
|
||||
orgUserID, orgImpersonatorPAT := createMachineUserPATWithMembership(t, "ORG_ADMIN_IMPERSONATOR")
|
||||
serviceUserID, noPermPAT := createMachineUserPATWithMembership(t)
|
||||
|
||||
// exchange some tokens for later use
|
||||
setTokenExchangeFeature(t, true)
|
||||
teResp, err := tokenexchange.ExchangeToken(CTX, exchanger, noPermPAT, oidc.AccessTokenType, "", "", nil, nil, nil, oidc.AccessTokenType)
|
||||
require.NoError(t, err)
|
||||
|
||||
patScopes := oidc.SpaceDelimitedArray{"openid", "profile", "urn:zitadel:iam:user:metadata", "urn:zitadel:iam:user:resourceowner"}
|
||||
|
||||
relyingParty, err := rp.NewRelyingPartyOIDC(CTX, Instance.OIDCIssuer(), client.GetClientId(), "", "", []string{"openid"}, rp.WithJWTProfile(rp.SignerFromKeyFile(keyData)))
|
||||
require.NoError(t, err)
|
||||
resourceServer, err := Instance.CreateResourceServerJWTProfile(CTX, keyData)
|
||||
require.NoError(t, err)
|
||||
|
||||
type settings struct {
|
||||
tokenExchangeFeature bool
|
||||
impersonationPolicy bool
|
||||
}
|
||||
type args struct {
|
||||
SubjectToken string
|
||||
SubjectTokenType oidc.TokenType
|
||||
ActorToken string
|
||||
ActorTokenType oidc.TokenType
|
||||
Resource []string
|
||||
Audience []string
|
||||
Scopes []string
|
||||
RequestedTokenType oidc.TokenType
|
||||
}
|
||||
type result struct {
|
||||
issuedTokenType oidc.TokenType
|
||||
tokenType string
|
||||
expiresIn uint64
|
||||
scopes oidc.SpaceDelimitedArray
|
||||
verifyAccessToken func(t *testing.T, token string)
|
||||
verifyRefreshToken func(t *testing.T, token string)
|
||||
verifyIDToken func(t *testing.T, token string)
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
settings settings
|
||||
args args
|
||||
want result
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "feature disabled error",
|
||||
settings: settings{
|
||||
tokenExchangeFeature: false,
|
||||
impersonationPolicy: false,
|
||||
},
|
||||
args: args{
|
||||
SubjectToken: noPermPAT,
|
||||
SubjectTokenType: oidc.AccessTokenType,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "unsupported resource parameter",
|
||||
settings: settings{
|
||||
tokenExchangeFeature: true,
|
||||
impersonationPolicy: false,
|
||||
},
|
||||
args: args{
|
||||
SubjectToken: noPermPAT,
|
||||
SubjectTokenType: oidc.AccessTokenType,
|
||||
Resource: []string{"https://example.com"},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "invalid subject token",
|
||||
settings: settings{
|
||||
tokenExchangeFeature: true,
|
||||
impersonationPolicy: false,
|
||||
},
|
||||
args: args{
|
||||
SubjectToken: "foo",
|
||||
SubjectTokenType: oidc.AccessTokenType,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "EXCHANGE: access token to default",
|
||||
settings: settings{
|
||||
tokenExchangeFeature: true,
|
||||
impersonationPolicy: false,
|
||||
},
|
||||
args: args{
|
||||
SubjectToken: noPermPAT,
|
||||
SubjectTokenType: oidc.AccessTokenType,
|
||||
},
|
||||
want: result{
|
||||
issuedTokenType: oidc.AccessTokenType,
|
||||
tokenType: oidc.BearerToken,
|
||||
expiresIn: 43100,
|
||||
scopes: patScopes,
|
||||
verifyAccessToken: accessTokenVerifier(CTX, resourceServer, serviceUserID, ""),
|
||||
verifyIDToken: idTokenVerifier(CTX, relyingParty, serviceUserID, ""),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "EXCHANGE: access token to access token",
|
||||
settings: settings{
|
||||
tokenExchangeFeature: true,
|
||||
impersonationPolicy: false,
|
||||
},
|
||||
args: args{
|
||||
SubjectToken: noPermPAT,
|
||||
SubjectTokenType: oidc.AccessTokenType,
|
||||
RequestedTokenType: oidc.AccessTokenType,
|
||||
},
|
||||
want: result{
|
||||
issuedTokenType: oidc.AccessTokenType,
|
||||
tokenType: oidc.BearerToken,
|
||||
expiresIn: 43100,
|
||||
scopes: patScopes,
|
||||
verifyAccessToken: accessTokenVerifier(CTX, resourceServer, serviceUserID, ""),
|
||||
verifyIDToken: idTokenVerifier(CTX, relyingParty, serviceUserID, ""),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "EXCHANGE: access token to JWT",
|
||||
settings: settings{
|
||||
tokenExchangeFeature: true,
|
||||
impersonationPolicy: false,
|
||||
},
|
||||
args: args{
|
||||
SubjectToken: noPermPAT,
|
||||
SubjectTokenType: oidc.AccessTokenType,
|
||||
RequestedTokenType: oidc.JWTTokenType,
|
||||
},
|
||||
want: result{
|
||||
issuedTokenType: oidc.JWTTokenType,
|
||||
tokenType: oidc.BearerToken,
|
||||
expiresIn: 43100,
|
||||
scopes: patScopes,
|
||||
verifyAccessToken: accessTokenVerifier(CTX, resourceServer, serviceUserID, ""),
|
||||
verifyIDToken: idTokenVerifier(CTX, relyingParty, serviceUserID, ""),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "EXCHANGE: access token to ID Token",
|
||||
settings: settings{
|
||||
tokenExchangeFeature: true,
|
||||
impersonationPolicy: false,
|
||||
},
|
||||
args: args{
|
||||
SubjectToken: noPermPAT,
|
||||
SubjectTokenType: oidc.AccessTokenType,
|
||||
RequestedTokenType: oidc.IDTokenType,
|
||||
},
|
||||
want: result{
|
||||
issuedTokenType: oidc.IDTokenType,
|
||||
tokenType: "N_A",
|
||||
expiresIn: 43100,
|
||||
scopes: patScopes,
|
||||
verifyAccessToken: idTokenVerifier(CTX, relyingParty, serviceUserID, ""),
|
||||
verifyIDToken: func(t *testing.T, token string) {
|
||||
assert.Empty(t, token)
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "EXCHANGE: refresh token not allowed",
|
||||
settings: settings{
|
||||
tokenExchangeFeature: true,
|
||||
impersonationPolicy: false,
|
||||
},
|
||||
args: args{
|
||||
SubjectToken: teResp.RefreshToken,
|
||||
SubjectTokenType: oidc.RefreshTokenType,
|
||||
RequestedTokenType: oidc.IDTokenType,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "EXCHANGE: alternate scope for refresh token",
|
||||
settings: settings{
|
||||
tokenExchangeFeature: true,
|
||||
impersonationPolicy: false,
|
||||
},
|
||||
args: args{
|
||||
SubjectToken: noPermPAT,
|
||||
SubjectTokenType: oidc.AccessTokenType,
|
||||
RequestedTokenType: oidc.AccessTokenType,
|
||||
Scopes: []string{oidc.ScopeOpenID, oidc.ScopeOfflineAccess, "profile"},
|
||||
},
|
||||
want: result{
|
||||
issuedTokenType: oidc.AccessTokenType,
|
||||
tokenType: oidc.BearerToken,
|
||||
expiresIn: 43100,
|
||||
scopes: []string{oidc.ScopeOpenID, oidc.ScopeOfflineAccess, "profile"},
|
||||
verifyAccessToken: accessTokenVerifier(CTX, resourceServer, serviceUserID, ""),
|
||||
verifyIDToken: idTokenVerifier(CTX, relyingParty, serviceUserID, ""),
|
||||
verifyRefreshToken: refreshTokenVerifier(CTX, relyingParty, "", ""),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "EXCHANGE: access token, requested token type not supported error",
|
||||
settings: settings{
|
||||
tokenExchangeFeature: true,
|
||||
impersonationPolicy: false,
|
||||
},
|
||||
args: args{
|
||||
SubjectToken: noPermPAT,
|
||||
SubjectTokenType: oidc.AccessTokenType,
|
||||
RequestedTokenType: oidc.RefreshTokenType,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "EXCHANGE: access token, invalid audience",
|
||||
settings: settings{
|
||||
tokenExchangeFeature: true,
|
||||
impersonationPolicy: false,
|
||||
},
|
||||
args: args{
|
||||
SubjectToken: noPermPAT,
|
||||
SubjectTokenType: oidc.AccessTokenType,
|
||||
RequestedTokenType: oidc.AccessTokenType,
|
||||
Audience: []string{"foo", "bar"},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "IMPERSONATION: subject: userID, actor: access token, policy disabled error",
|
||||
settings: settings{
|
||||
tokenExchangeFeature: true,
|
||||
impersonationPolicy: false,
|
||||
},
|
||||
args: args{
|
||||
SubjectToken: User.GetUserId(),
|
||||
SubjectTokenType: oidc_api.UserIDTokenType,
|
||||
RequestedTokenType: oidc.AccessTokenType,
|
||||
ActorToken: orgImpersonatorPAT,
|
||||
ActorTokenType: oidc.AccessTokenType,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "IMPERSONATION: subject: userID, actor: access token, membership not found error",
|
||||
settings: settings{
|
||||
tokenExchangeFeature: true,
|
||||
impersonationPolicy: true,
|
||||
},
|
||||
args: args{
|
||||
SubjectToken: User.GetUserId(),
|
||||
SubjectTokenType: oidc_api.UserIDTokenType,
|
||||
RequestedTokenType: oidc.AccessTokenType,
|
||||
ActorToken: noPermPAT,
|
||||
ActorTokenType: oidc.AccessTokenType,
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "IAM IMPERSONATION: subject: userID, actor: access token, success",
|
||||
settings: settings{
|
||||
tokenExchangeFeature: true,
|
||||
impersonationPolicy: true,
|
||||
},
|
||||
args: args{
|
||||
SubjectToken: User.GetUserId(),
|
||||
SubjectTokenType: oidc_api.UserIDTokenType,
|
||||
RequestedTokenType: oidc.AccessTokenType,
|
||||
ActorToken: iamImpersonatorPAT,
|
||||
ActorTokenType: oidc.AccessTokenType,
|
||||
},
|
||||
want: result{
|
||||
issuedTokenType: oidc.AccessTokenType,
|
||||
tokenType: oidc.BearerToken,
|
||||
expiresIn: 43100,
|
||||
scopes: patScopes,
|
||||
verifyAccessToken: accessTokenVerifier(CTX, resourceServer, User.GetUserId(), iamUserID),
|
||||
verifyIDToken: idTokenVerifier(CTX, relyingParty, User.GetUserId(), iamUserID),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ORG IMPERSONATION: subject: userID, actor: access token, success",
|
||||
settings: settings{
|
||||
tokenExchangeFeature: true,
|
||||
impersonationPolicy: true,
|
||||
},
|
||||
args: args{
|
||||
SubjectToken: User.GetUserId(),
|
||||
SubjectTokenType: oidc_api.UserIDTokenType,
|
||||
RequestedTokenType: oidc.AccessTokenType,
|
||||
ActorToken: orgImpersonatorPAT,
|
||||
ActorTokenType: oidc.AccessTokenType,
|
||||
},
|
||||
want: result{
|
||||
issuedTokenType: oidc.AccessTokenType,
|
||||
tokenType: oidc.BearerToken,
|
||||
expiresIn: 43100,
|
||||
scopes: patScopes,
|
||||
verifyAccessToken: accessTokenVerifier(CTX, resourceServer, User.GetUserId(), orgUserID),
|
||||
verifyIDToken: idTokenVerifier(CTX, relyingParty, User.GetUserId(), orgUserID),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ORG IMPERSONATION: subject: access token, actor: access token, success",
|
||||
settings: settings{
|
||||
tokenExchangeFeature: true,
|
||||
impersonationPolicy: true,
|
||||
},
|
||||
args: args{
|
||||
SubjectToken: teResp.AccessToken,
|
||||
SubjectTokenType: oidc.AccessTokenType,
|
||||
RequestedTokenType: oidc.AccessTokenType,
|
||||
ActorToken: orgImpersonatorPAT,
|
||||
ActorTokenType: oidc.AccessTokenType,
|
||||
},
|
||||
want: result{
|
||||
issuedTokenType: oidc.AccessTokenType,
|
||||
tokenType: oidc.BearerToken,
|
||||
expiresIn: 43100,
|
||||
scopes: patScopes,
|
||||
verifyAccessToken: accessTokenVerifier(CTX, resourceServer, serviceUserID, orgUserID),
|
||||
verifyIDToken: idTokenVerifier(CTX, relyingParty, serviceUserID, orgUserID),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ORG IMPERSONATION: subject: ID token, actor: access token, success",
|
||||
settings: settings{
|
||||
tokenExchangeFeature: true,
|
||||
impersonationPolicy: true,
|
||||
},
|
||||
args: args{
|
||||
SubjectToken: teResp.IDToken,
|
||||
SubjectTokenType: oidc.IDTokenType,
|
||||
RequestedTokenType: oidc.AccessTokenType,
|
||||
ActorToken: orgImpersonatorPAT,
|
||||
ActorTokenType: oidc.AccessTokenType,
|
||||
},
|
||||
want: result{
|
||||
issuedTokenType: oidc.AccessTokenType,
|
||||
tokenType: oidc.BearerToken,
|
||||
expiresIn: 43100,
|
||||
scopes: patScopes,
|
||||
verifyAccessToken: accessTokenVerifier(CTX, resourceServer, serviceUserID, orgUserID),
|
||||
verifyIDToken: idTokenVerifier(CTX, relyingParty, serviceUserID, orgUserID),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ORG IMPERSONATION: subject: JWT, actor: access token, success",
|
||||
settings: settings{
|
||||
tokenExchangeFeature: true,
|
||||
impersonationPolicy: true,
|
||||
},
|
||||
args: args{
|
||||
SubjectToken: func() string {
|
||||
token, err := crypto.Sign(&oidc.JWTTokenRequest{
|
||||
Issuer: client.GetClientId(),
|
||||
Subject: User.GetUserId(),
|
||||
Audience: oidc.Audience{Instance.OIDCIssuer()},
|
||||
ExpiresAt: oidc.FromTime(time.Now().Add(time.Hour)),
|
||||
IssuedAt: oidc.FromTime(time.Now().Add(-time.Second)),
|
||||
}, signer)
|
||||
require.NoError(t, err)
|
||||
return token
|
||||
}(),
|
||||
SubjectTokenType: oidc.JWTTokenType,
|
||||
RequestedTokenType: oidc.AccessTokenType,
|
||||
ActorToken: orgImpersonatorPAT,
|
||||
ActorTokenType: oidc.AccessTokenType,
|
||||
},
|
||||
want: result{
|
||||
issuedTokenType: oidc.AccessTokenType,
|
||||
tokenType: oidc.BearerToken,
|
||||
expiresIn: 43100,
|
||||
scopes: patScopes,
|
||||
verifyAccessToken: accessTokenVerifier(CTX, resourceServer, User.GetUserId(), orgUserID),
|
||||
verifyIDToken: idTokenVerifier(CTX, relyingParty, User.GetUserId(), orgUserID),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ORG IMPERSONATION: subject: access token, actor: access token, with refresh token, success",
|
||||
settings: settings{
|
||||
tokenExchangeFeature: true,
|
||||
impersonationPolicy: true,
|
||||
},
|
||||
args: args{
|
||||
SubjectToken: teResp.AccessToken,
|
||||
SubjectTokenType: oidc.AccessTokenType,
|
||||
RequestedTokenType: oidc.AccessTokenType,
|
||||
ActorToken: orgImpersonatorPAT,
|
||||
ActorTokenType: oidc.AccessTokenType,
|
||||
Scopes: []string{oidc.ScopeOpenID, oidc.ScopeOfflineAccess},
|
||||
},
|
||||
want: result{
|
||||
issuedTokenType: oidc.AccessTokenType,
|
||||
tokenType: oidc.BearerToken,
|
||||
expiresIn: 43100,
|
||||
scopes: []string{oidc.ScopeOpenID, oidc.ScopeOfflineAccess},
|
||||
verifyAccessToken: accessTokenVerifier(CTX, resourceServer, serviceUserID, orgUserID),
|
||||
verifyIDToken: idTokenVerifier(CTX, relyingParty, serviceUserID, orgUserID),
|
||||
verifyRefreshToken: refreshTokenVerifier(CTX, relyingParty, serviceUserID, orgUserID),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
setTokenExchangeFeature(t, tt.settings.tokenExchangeFeature)
|
||||
setImpersonationPolicy(t, tt.settings.impersonationPolicy)
|
||||
|
||||
got, err := tokenexchange.ExchangeToken(CTX, exchanger, tt.args.SubjectToken, tt.args.SubjectTokenType, tt.args.ActorToken, tt.args.ActorTokenType, tt.args.Resource, tt.args.Audience, tt.args.Scopes, tt.args.RequestedTokenType)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.want.issuedTokenType, got.IssuedTokenType)
|
||||
assert.Equal(t, tt.want.tokenType, got.TokenType)
|
||||
assert.Greater(t, got.ExpiresIn, tt.want.expiresIn)
|
||||
assert.Equal(t, tt.want.scopes, got.Scopes)
|
||||
if tt.want.verifyAccessToken != nil {
|
||||
tt.want.verifyAccessToken(t, got.AccessToken)
|
||||
}
|
||||
if tt.want.verifyRefreshToken != nil {
|
||||
tt.want.verifyRefreshToken(t, got.RefreshToken)
|
||||
}
|
||||
if tt.want.verifyIDToken != nil {
|
||||
tt.want.verifyIDToken(t, got.IDToken)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// This test tries to call the zitadel API with an impersonated token,
|
||||
// which should fail.
|
||||
func TestImpersonation_API_Call(t *testing.T) {
|
||||
client, keyData, err := Instance.CreateOIDCTokenExchangeClient(CTX)
|
||||
require.NoError(t, err)
|
||||
signer, err := rp.SignerFromKeyFile(keyData)()
|
||||
require.NoError(t, err)
|
||||
exchanger, err := tokenexchange.NewTokenExchangerJWTProfile(CTX, Instance.OIDCIssuer(), client.GetClientId(), signer)
|
||||
require.NoError(t, err)
|
||||
resourceServer, err := Instance.CreateResourceServerJWTProfile(CTX, keyData)
|
||||
require.NoError(t, err)
|
||||
|
||||
setTokenExchangeFeature(t, true)
|
||||
setImpersonationPolicy(t, true)
|
||||
t.Cleanup(func() {
|
||||
resetFeatures(t)
|
||||
setImpersonationPolicy(t, false)
|
||||
})
|
||||
|
||||
iamUserID, iamImpersonatorPAT := createMachineUserPATWithMembership(t, "IAM_ADMIN_IMPERSONATOR")
|
||||
iamOwner := Instance.Users.Get(integration.UserTypeIAMOwner)
|
||||
|
||||
// impersonating the IAM owner!
|
||||
resp, err := tokenexchange.ExchangeToken(CTX, exchanger, iamOwner.Token, oidc.AccessTokenType, iamImpersonatorPAT, oidc.AccessTokenType, nil, nil, nil, oidc.AccessTokenType)
|
||||
require.NoError(t, err)
|
||||
accessTokenVerifier(CTX, resourceServer, iamOwner.ID, iamUserID)
|
||||
|
||||
impersonatedCTX := integration.WithAuthorizationToken(CTX, resp.AccessToken)
|
||||
_, err = Instance.Client.Admin.GetAllowedLanguages(impersonatedCTX, &admin.GetAllowedLanguagesRequest{})
|
||||
status := status.Convert(err)
|
||||
assert.Equal(t, codes.PermissionDenied, status.Code())
|
||||
assert.Equal(t, "Errors.TokenExchange.Token.NotForAPI (APP-Shi0J)", status.Message())
|
||||
}
|
127
internal/api/oidc/integration_test/token_jwt_profile_test.go
Normal file
127
internal/api/oidc/integration_test/token_jwt_profile_test.go
Normal file
@@ -0,0 +1,127 @@
|
||||
//go:build integration
|
||||
|
||||
package oidc_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/zitadel/oidc/v3/pkg/client/profile"
|
||||
"github.com/zitadel/oidc/v3/pkg/client/rp"
|
||||
"github.com/zitadel/oidc/v3/pkg/oidc"
|
||||
|
||||
oidc_api "github.com/zitadel/zitadel/internal/api/oidc"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
)
|
||||
|
||||
func TestServer_JWTProfile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
user, name, keyData, err := Instance.CreateOIDCJWTProfileClient(CTX)
|
||||
require.NoError(t, err)
|
||||
|
||||
type claims struct {
|
||||
name string
|
||||
username string
|
||||
updated time.Time
|
||||
resourceOwnerID any
|
||||
resourceOwnerName any
|
||||
resourceOwnerPrimaryDomain any
|
||||
orgDomain any
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
keyData []byte
|
||||
scope []string
|
||||
wantClaims claims
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "success",
|
||||
keyData: keyData,
|
||||
scope: []string{oidc.ScopeOpenID},
|
||||
},
|
||||
{
|
||||
name: "openid, profile, email",
|
||||
keyData: keyData,
|
||||
scope: []string{oidc.ScopeOpenID, oidc.ScopeProfile, oidc.ScopeEmail},
|
||||
wantClaims: claims{
|
||||
name: name,
|
||||
username: name,
|
||||
updated: user.GetDetails().GetChangeDate().AsTime(),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "org id and domain scope",
|
||||
keyData: keyData,
|
||||
scope: []string{
|
||||
oidc.ScopeOpenID,
|
||||
domain.OrgIDScope + Instance.DefaultOrg.Id,
|
||||
domain.OrgDomainPrimaryScope + Instance.DefaultOrg.PrimaryDomain,
|
||||
},
|
||||
wantClaims: claims{
|
||||
resourceOwnerID: Instance.DefaultOrg.Id,
|
||||
resourceOwnerName: Instance.DefaultOrg.Name,
|
||||
resourceOwnerPrimaryDomain: Instance.DefaultOrg.PrimaryDomain,
|
||||
orgDomain: Instance.DefaultOrg.PrimaryDomain,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid org domain filtered",
|
||||
keyData: keyData,
|
||||
scope: []string{
|
||||
oidc.ScopeOpenID,
|
||||
domain.OrgDomainPrimaryScope + Instance.DefaultOrg.PrimaryDomain,
|
||||
domain.OrgDomainPrimaryScope + "foo"},
|
||||
wantClaims: claims{
|
||||
orgDomain: Instance.DefaultOrg.PrimaryDomain,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid org id filtered",
|
||||
keyData: keyData,
|
||||
scope: []string{oidc.ScopeOpenID,
|
||||
domain.OrgIDScope + Instance.DefaultOrg.Id,
|
||||
domain.OrgIDScope + "foo",
|
||||
},
|
||||
wantClaims: claims{
|
||||
resourceOwnerID: Instance.DefaultOrg.Id,
|
||||
resourceOwnerName: Instance.DefaultOrg.Name,
|
||||
resourceOwnerPrimaryDomain: Instance.DefaultOrg.PrimaryDomain,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tokenSource, err := profile.NewJWTProfileTokenSourceFromKeyFileData(CTX, Instance.OIDCIssuer(), tt.keyData, tt.scope)
|
||||
require.NoError(t, err)
|
||||
|
||||
tokens, err := tokenSource.TokenCtx(CTX)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, tokens)
|
||||
|
||||
provider, err := rp.NewRelyingPartyOIDC(CTX, Instance.OIDCIssuer(), "", "", redirectURI, tt.scope)
|
||||
require.NoError(t, err)
|
||||
userinfo, err := rp.Userinfo[*oidc.UserInfo](CTX, tokens.AccessToken, oidc.BearerToken, user.GetUserId(), provider)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantClaims.resourceOwnerID, userinfo.Claims[oidc_api.ClaimResourceOwnerID])
|
||||
assert.Equal(t, tt.wantClaims.resourceOwnerName, userinfo.Claims[oidc_api.ClaimResourceOwnerName])
|
||||
assert.Equal(t, tt.wantClaims.resourceOwnerPrimaryDomain, userinfo.Claims[oidc_api.ClaimResourceOwnerPrimaryDomain])
|
||||
assert.Equal(t, tt.wantClaims.orgDomain, userinfo.Claims[domain.OrgDomainPrimaryClaim])
|
||||
assert.Equal(t, tt.wantClaims.name, userinfo.Name)
|
||||
assert.Equal(t, tt.wantClaims.username, userinfo.PreferredUsername)
|
||||
assertOIDCTime(t, userinfo.UpdatedAt, tt.wantClaims.updated)
|
||||
assert.Empty(t, userinfo.UserInfoProfile.FamilyName)
|
||||
assert.Empty(t, userinfo.UserInfoProfile.GivenName)
|
||||
assert.Empty(t, userinfo.UserInfoEmail)
|
||||
assert.Empty(t, userinfo.UserInfoPhone)
|
||||
assert.Empty(t, userinfo.Address)
|
||||
})
|
||||
}
|
||||
}
|
454
internal/api/oidc/integration_test/userinfo_test.go
Normal file
454
internal/api/oidc/integration_test/userinfo_test.go
Normal file
@@ -0,0 +1,454 @@
|
||||
//go:build integration
|
||||
|
||||
package oidc_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/zitadel/oidc/v3/pkg/client/rp"
|
||||
"github.com/zitadel/oidc/v3/pkg/oidc"
|
||||
"golang.org/x/oauth2"
|
||||
"google.golang.org/grpc/metadata"
|
||||
|
||||
oidc_api "github.com/zitadel/zitadel/internal/api/oidc"
|
||||
"github.com/zitadel/zitadel/internal/domain"
|
||||
"github.com/zitadel/zitadel/internal/integration"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/feature/v2"
|
||||
"github.com/zitadel/zitadel/pkg/grpc/management"
|
||||
oidc_pb "github.com/zitadel/zitadel/pkg/grpc/oidc/v2"
|
||||
)
|
||||
|
||||
// TestServer_UserInfo is a top-level test which re-executes the actual
|
||||
// userinfo integration test against a matrix of different feature flags.
|
||||
// This ensure that the response of the different implementations remains the same.
|
||||
func TestServer_UserInfo(t *testing.T) {
|
||||
iamOwnerCTX := Instance.WithAuthorization(CTX, integration.UserTypeIAMOwner)
|
||||
t.Cleanup(func() {
|
||||
_, err := Instance.Client.FeatureV2.ResetInstanceFeatures(iamOwnerCTX, &feature.ResetInstanceFeaturesRequest{})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
tests := []struct {
|
||||
name string
|
||||
legacy bool
|
||||
trigger bool
|
||||
webKey bool
|
||||
}{
|
||||
{
|
||||
name: "legacy enabled",
|
||||
legacy: true,
|
||||
},
|
||||
{
|
||||
name: "legacy disabled, trigger disabled",
|
||||
legacy: false,
|
||||
trigger: false,
|
||||
},
|
||||
{
|
||||
name: "legacy disabled, trigger enabled",
|
||||
legacy: false,
|
||||
trigger: true,
|
||||
},
|
||||
|
||||
// This is the only functional test we need to cover web keys.
|
||||
// - By creating tokens the signer is tested
|
||||
// - When obtaining the tokens, the RP verifies the ID Token using the key set from the jwks endpoint.
|
||||
// - By calling userinfo with the access token as JWT, the Token Verifier with the public key cache is tested.
|
||||
{
|
||||
name: "web keys",
|
||||
legacy: false,
|
||||
trigger: false,
|
||||
webKey: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := Instance.Client.FeatureV2.SetInstanceFeatures(iamOwnerCTX, &feature.SetInstanceFeaturesRequest{
|
||||
OidcLegacyIntrospection: &tt.legacy,
|
||||
OidcTriggerIntrospectionProjections: &tt.trigger,
|
||||
WebKey: &tt.webKey,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
testServer_UserInfo(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// testServer_UserInfo is the actual userinfo integration test,
|
||||
// which calls the userinfo endpoint with different client configurations, roles and token scopes.
|
||||
func testServer_UserInfo(t *testing.T) {
|
||||
const (
|
||||
roleFoo = "foo"
|
||||
roleBar = "bar"
|
||||
)
|
||||
|
||||
clientID, projectID := createClient(t, Instance)
|
||||
addProjectRolesGrants(t, User.GetUserId(), projectID, roleFoo, roleBar)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
prepare func(t *testing.T, clientID string, scope []string) *oidc.Tokens[*oidc.IDTokenClaims]
|
||||
scope []string
|
||||
assertions []func(*testing.T, *oidc.UserInfo)
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "invalid token",
|
||||
prepare: func(*testing.T, string, []string) *oidc.Tokens[*oidc.IDTokenClaims] {
|
||||
return &oidc.Tokens[*oidc.IDTokenClaims]{
|
||||
Token: &oauth2.Token{
|
||||
AccessToken: "DEAFBEEFDEADBEEF",
|
||||
TokenType: oidc.BearerToken,
|
||||
},
|
||||
IDTokenClaims: &oidc.IDTokenClaims{
|
||||
TokenClaims: oidc.TokenClaims{
|
||||
Subject: User.GetUserId(),
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
scope: []string{oidc.ScopeProfile, oidc.ScopeOpenID, oidc.ScopeEmail, oidc.ScopeOfflineAccess},
|
||||
assertions: []func(*testing.T, *oidc.UserInfo){
|
||||
func(t *testing.T, ui *oidc.UserInfo) {
|
||||
assert.Nil(t, ui)
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "standard scopes",
|
||||
prepare: getTokens,
|
||||
scope: []string{oidc.ScopeProfile, oidc.ScopeOpenID, oidc.ScopeEmail, oidc.ScopeOfflineAccess},
|
||||
assertions: []func(*testing.T, *oidc.UserInfo){
|
||||
assertUserinfo,
|
||||
func(t *testing.T, ui *oidc.UserInfo) {
|
||||
assertNoReservedScopes(t, ui.Claims)
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "project role assertion",
|
||||
prepare: func(t *testing.T, clientID string, scope []string) *oidc.Tokens[*oidc.IDTokenClaims] {
|
||||
_, err := Instance.Client.Mgmt.UpdateProject(CTX, &management.UpdateProjectRequest{
|
||||
Id: projectID,
|
||||
Name: fmt.Sprintf("project-%d", time.Now().UnixNano()),
|
||||
ProjectRoleAssertion: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_, err := Instance.Client.Mgmt.UpdateProject(CTX, &management.UpdateProjectRequest{
|
||||
Id: projectID,
|
||||
Name: fmt.Sprintf("project-%d", time.Now().UnixNano()),
|
||||
ProjectRoleAssertion: false,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
resp, err := Instance.Client.Mgmt.GetProjectByID(CTX, &management.GetProjectByIDRequest{Id: projectID})
|
||||
require.NoError(t, err)
|
||||
require.True(t, resp.GetProject().GetProjectRoleAssertion(), "project role assertion")
|
||||
|
||||
return getTokens(t, clientID, scope)
|
||||
},
|
||||
scope: []string{oidc.ScopeProfile, oidc.ScopeOpenID, oidc.ScopeEmail, oidc.ScopeOfflineAccess},
|
||||
assertions: []func(*testing.T, *oidc.UserInfo){
|
||||
assertUserinfo,
|
||||
func(t *testing.T, ui *oidc.UserInfo) {
|
||||
assertProjectRoleClaims(t, projectID, ui.Claims, true, []string{roleFoo, roleBar}, []string{Instance.DefaultOrg.Id})
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "project role scope",
|
||||
prepare: getTokens,
|
||||
scope: []string{oidc.ScopeProfile, oidc.ScopeOpenID, oidc.ScopeEmail, oidc.ScopeOfflineAccess,
|
||||
oidc_api.ScopeProjectRolePrefix + roleFoo,
|
||||
},
|
||||
assertions: []func(*testing.T, *oidc.UserInfo){
|
||||
assertUserinfo,
|
||||
func(t *testing.T, ui *oidc.UserInfo) {
|
||||
assertProjectRoleClaims(t, projectID, ui.Claims, true, []string{roleFoo}, []string{Instance.DefaultOrg.Id})
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "project role and audience scope",
|
||||
prepare: getTokens,
|
||||
scope: []string{oidc.ScopeProfile, oidc.ScopeOpenID, oidc.ScopeEmail, oidc.ScopeOfflineAccess,
|
||||
oidc_api.ScopeProjectRolePrefix + roleFoo,
|
||||
domain.ProjectIDScope + projectID + domain.AudSuffix,
|
||||
},
|
||||
assertions: []func(*testing.T, *oidc.UserInfo){
|
||||
assertUserinfo,
|
||||
func(t *testing.T, ui *oidc.UserInfo) {
|
||||
assertProjectRoleClaims(t, projectID, ui.Claims, true, []string{roleFoo}, []string{Instance.DefaultOrg.Id})
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "PAT",
|
||||
prepare: func(t *testing.T, clientID string, scope []string) *oidc.Tokens[*oidc.IDTokenClaims] {
|
||||
user := Instance.Users.Get(integration.UserTypeOrgOwner)
|
||||
return &oidc.Tokens[*oidc.IDTokenClaims]{
|
||||
Token: &oauth2.Token{
|
||||
AccessToken: user.Token,
|
||||
TokenType: oidc.BearerToken,
|
||||
},
|
||||
IDTokenClaims: &oidc.IDTokenClaims{
|
||||
TokenClaims: oidc.TokenClaims{
|
||||
Subject: user.ID,
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
assertions: []func(*testing.T, *oidc.UserInfo){
|
||||
func(t *testing.T, ui *oidc.UserInfo) {
|
||||
user := Instance.Users.Get(integration.UserTypeOrgOwner)
|
||||
assert.Equal(t, user.ID, ui.Subject)
|
||||
assert.NotEmpty(t, ui.Claims[oidc_api.ClaimResourceOwnerName])
|
||||
assert.NotEmpty(t, ui.Claims[oidc_api.ClaimResourceOwnerPrimaryDomain])
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tokens := tt.prepare(t, clientID, tt.scope)
|
||||
provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURI)
|
||||
require.NoError(t, err)
|
||||
userinfo, err := rp.Userinfo[*oidc.UserInfo](CTX, tokens.AccessToken, tokens.TokenType, tokens.IDTokenClaims.Subject, provider)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
for _, assertion := range tt.assertions {
|
||||
assertion(t, userinfo)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestServer_UserInfo_OrgIDRoles tests the [domain.OrgRoleIDScope] functionality
|
||||
// it is a separate test because it is not supported in legacy mode.
|
||||
func TestServer_UserInfo_OrgIDRoles(t *testing.T) {
|
||||
const (
|
||||
roleFoo = "foo"
|
||||
roleBar = "bar"
|
||||
)
|
||||
clientID, projectID := createClient(t, Instance)
|
||||
addProjectRolesGrants(t, User.GetUserId(), projectID, roleFoo, roleBar)
|
||||
grantedOrgID := addProjectOrgGrant(t, User.GetUserId(), projectID, roleFoo, roleBar)
|
||||
|
||||
_, err := Instance.Client.Mgmt.UpdateProject(CTX, &management.UpdateProjectRequest{
|
||||
Id: projectID,
|
||||
Name: fmt.Sprintf("project-%d", time.Now().UnixNano()),
|
||||
ProjectRoleAssertion: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
resp, err := Instance.Client.Mgmt.GetProjectByID(CTX, &management.GetProjectByIDRequest{Id: projectID})
|
||||
require.NoError(t, err)
|
||||
require.True(t, resp.GetProject().GetProjectRoleAssertion(), "project role assertion")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
scope []string
|
||||
wantRoleOrgIDs []string
|
||||
}{
|
||||
{
|
||||
name: "default returns all role orgs",
|
||||
scope: []string{
|
||||
oidc.ScopeOpenID, oidc.ScopeOfflineAccess,
|
||||
},
|
||||
wantRoleOrgIDs: []string{Instance.DefaultOrg.Id, grantedOrgID},
|
||||
},
|
||||
{
|
||||
name: "only granted org",
|
||||
scope: []string{
|
||||
oidc.ScopeOpenID, oidc.ScopeOfflineAccess,
|
||||
domain.OrgRoleIDScope + grantedOrgID},
|
||||
wantRoleOrgIDs: []string{grantedOrgID},
|
||||
},
|
||||
{
|
||||
name: "only own org",
|
||||
scope: []string{
|
||||
oidc.ScopeOpenID, oidc.ScopeOfflineAccess,
|
||||
domain.OrgRoleIDScope + Instance.DefaultOrg.Id,
|
||||
},
|
||||
wantRoleOrgIDs: []string{Instance.DefaultOrg.Id},
|
||||
},
|
||||
{
|
||||
name: "request both orgs",
|
||||
scope: []string{
|
||||
oidc.ScopeOpenID, oidc.ScopeOfflineAccess,
|
||||
domain.OrgRoleIDScope + Instance.DefaultOrg.Id,
|
||||
domain.OrgRoleIDScope + grantedOrgID,
|
||||
},
|
||||
wantRoleOrgIDs: []string{Instance.DefaultOrg.Id, grantedOrgID},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tokens := getTokens(t, clientID, tt.scope)
|
||||
provider, err := Instance.CreateRelyingParty(CTX, clientID, redirectURI)
|
||||
require.NoError(t, err)
|
||||
userinfo, err := rp.Userinfo[*oidc.UserInfo](CTX, tokens.AccessToken, tokens.TokenType, tokens.IDTokenClaims.Subject, provider)
|
||||
require.NoError(t, err)
|
||||
assertProjectRoleClaims(t, projectID, userinfo.Claims, true, []string{roleFoo, roleBar}, tt.wantRoleOrgIDs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// https://github.com/zitadel/zitadel/issues/6662
|
||||
func TestServer_UserInfo_Issue6662(t *testing.T) {
|
||||
const (
|
||||
roleFoo = "foo"
|
||||
roleBar = "bar"
|
||||
)
|
||||
|
||||
project, err := Instance.CreateProject(CTX)
|
||||
projectID := project.GetId()
|
||||
require.NoError(t, err)
|
||||
user, _, clientID, clientSecret, err := Instance.CreateOIDCCredentialsClient(CTX)
|
||||
require.NoError(t, err)
|
||||
addProjectRolesGrants(t, user.GetUserId(), projectID, roleFoo, roleBar)
|
||||
|
||||
scope := []string{oidc.ScopeProfile, oidc.ScopeOpenID, oidc.ScopeEmail, oidc.ScopeOfflineAccess,
|
||||
oidc_api.ScopeProjectRolePrefix + roleFoo,
|
||||
domain.ProjectIDScope + projectID + domain.AudSuffix,
|
||||
}
|
||||
|
||||
provider, err := rp.NewRelyingPartyOIDC(CTX, Instance.OIDCIssuer(), clientID, clientSecret, redirectURI, scope)
|
||||
require.NoError(t, err)
|
||||
tokens, err := rp.ClientCredentials(CTX, provider, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
userinfo, err := rp.Userinfo[*oidc.UserInfo](CTX, tokens.AccessToken, tokens.TokenType, user.GetUserId(), provider)
|
||||
require.NoError(t, err)
|
||||
assertProjectRoleClaims(t, projectID, userinfo.Claims, false, []string{roleFoo}, []string{Instance.DefaultOrg.Id})
|
||||
}
|
||||
|
||||
func addProjectRolesGrants(t *testing.T, userID, projectID string, roles ...string) {
|
||||
t.Helper()
|
||||
bulkRoles := make([]*management.BulkAddProjectRolesRequest_Role, len(roles))
|
||||
for i, role := range roles {
|
||||
bulkRoles[i] = &management.BulkAddProjectRolesRequest_Role{
|
||||
Key: role,
|
||||
DisplayName: role,
|
||||
}
|
||||
}
|
||||
_, err := Instance.Client.Mgmt.BulkAddProjectRoles(CTX, &management.BulkAddProjectRolesRequest{
|
||||
ProjectId: projectID,
|
||||
Roles: bulkRoles,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = Instance.Client.Mgmt.AddUserGrant(CTX, &management.AddUserGrantRequest{
|
||||
UserId: userID,
|
||||
ProjectId: projectID,
|
||||
RoleKeys: roles,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// addProjectOrgGrant adds a new organization which will be granted on the projectID with the specified roles.
|
||||
// The userID will be granted in the new organization to the project with the same roles.
|
||||
func addProjectOrgGrant(t *testing.T, userID, projectID string, roles ...string) (grantedOrgID string) {
|
||||
grantedOrg := Instance.CreateOrganization(CTXIAM, fmt.Sprintf("ZITADEL_GRANTED_%d", time.Now().UnixNano()), fmt.Sprintf("%d@mouse.com", time.Now().UnixNano()))
|
||||
projectGrant, err := Instance.Client.Mgmt.AddProjectGrant(CTX, &management.AddProjectGrantRequest{
|
||||
ProjectId: projectID,
|
||||
GrantedOrgId: grantedOrg.GetOrganizationId(),
|
||||
RoleKeys: roles,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
ctxOrg := metadata.AppendToOutgoingContext(CTXIAM, "x-zitadel-orgid", grantedOrg.GetOrganizationId())
|
||||
_, err = Instance.Client.Mgmt.AddUserGrant(ctxOrg, &management.AddUserGrantRequest{
|
||||
UserId: userID,
|
||||
ProjectId: projectID,
|
||||
ProjectGrantId: projectGrant.GetGrantId(),
|
||||
RoleKeys: roles,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return grantedOrg.GetOrganizationId()
|
||||
}
|
||||
|
||||
func getTokens(t *testing.T, clientID string, scope []string) *oidc.Tokens[*oidc.IDTokenClaims] {
|
||||
authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, scope...)
|
||||
sessionID, sessionToken, startTime, changeTime := Instance.CreateVerifiedWebAuthNSession(t, CTXLOGIN, User.GetUserId())
|
||||
linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{
|
||||
AuthRequestId: authRequestID,
|
||||
CallbackKind: &oidc_pb.CreateCallbackRequest_Session{
|
||||
Session: &oidc_pb.Session{
|
||||
SessionId: sessionID,
|
||||
SessionToken: sessionToken,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// code exchange
|
||||
code := assertCodeResponse(t, linkResp.GetCallbackUrl())
|
||||
tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI)
|
||||
require.NoError(t, err)
|
||||
assertTokens(t, tokens, true)
|
||||
assertIDTokenClaims(t, tokens.IDTokenClaims, User.GetUserId(), armPasskey, startTime, changeTime, sessionID)
|
||||
|
||||
return tokens
|
||||
}
|
||||
|
||||
func assertUserinfo(t *testing.T, userinfo *oidc.UserInfo) {
|
||||
t.Helper()
|
||||
assert.Equal(t, User.GetUserId(), userinfo.Subject)
|
||||
assert.Equal(t, "Mickey", userinfo.GivenName)
|
||||
assert.Equal(t, "Mouse", userinfo.FamilyName)
|
||||
assert.Equal(t, "Mickey Mouse", userinfo.Name)
|
||||
assert.NotEmpty(t, userinfo.PreferredUsername)
|
||||
assert.Equal(t, userinfo.PreferredUsername, userinfo.Email)
|
||||
assert.False(t, bool(userinfo.EmailVerified))
|
||||
assertOIDCTime(t, userinfo.UpdatedAt, User.GetDetails().GetChangeDate().AsTime())
|
||||
}
|
||||
|
||||
func assertNoReservedScopes(t *testing.T, claims map[string]any) {
|
||||
t.Helper()
|
||||
t.Log(claims)
|
||||
for claim := range claims {
|
||||
assert.Falsef(t, strings.HasPrefix(claim, oidc_api.ClaimPrefix), "claim %s has prefix %s", claim, oidc_api.ClaimPrefix)
|
||||
}
|
||||
}
|
||||
|
||||
// assertProjectRoleClaims asserts the projectRoles in the claims.
|
||||
// By default it searches for the [oidc_api.ClaimProjectRolesFormat] claim with a project ID,
|
||||
// and optionally for the [oidc_api.ClaimProjectRoles] claim if claimProjectRole is true.
|
||||
// Each claim should contain the roles expected by wantRoles and
|
||||
// each role should contain the org IDs expected by wantRoleOrgIDs.
|
||||
//
|
||||
// In the claim map, each project role claim is expected to be a map of multiple roles and
|
||||
// each role is expected to be a map of multiple Org IDs to Org Domains.
|
||||
func assertProjectRoleClaims(t *testing.T, projectID string, claims map[string]any, claimProjectRole bool, wantRoles, wantRoleOrgIDs []string) {
|
||||
t.Helper()
|
||||
projectRoleClaims := []string{fmt.Sprintf(oidc_api.ClaimProjectRolesFormat, projectID)}
|
||||
if claimProjectRole {
|
||||
projectRoleClaims = append(projectRoleClaims, oidc_api.ClaimProjectRoles)
|
||||
}
|
||||
for _, claim := range projectRoleClaims {
|
||||
roleMap, ok := claims[claim].(map[string]any) // map of multiple roles
|
||||
require.Truef(t, ok, "claim %s not found or wrong type %T", claim, claims[claim])
|
||||
|
||||
gotRoles := make([]string, 0, len(roleMap))
|
||||
for roleKey := range roleMap {
|
||||
role, ok := roleMap[roleKey].(map[string]any) // map of multiple org IDs to org domains
|
||||
require.Truef(t, ok, "role %s not found or wrong type %T", roleKey, roleMap[roleKey])
|
||||
gotRoles = append(gotRoles, roleKey)
|
||||
|
||||
gotRoleOrgIDs := make([]string, 0, len(role))
|
||||
for orgID := range role {
|
||||
gotRoleOrgIDs = append(gotRoleOrgIDs, orgID)
|
||||
}
|
||||
assert.ElementsMatch(t, wantRoleOrgIDs, gotRoleOrgIDs)
|
||||
}
|
||||
assert.ElementsMatch(t, wantRoles, gotRoles)
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user