feat(oidc): provide actor information in userinfo actions (#12566)

# Which Problems Are Solved

Expose impersonating actor to `preAccessToken` and `preUserinfo`
actions.

# How the Problems Are Solved

Provide the `actor` information already present in the OIDC session
model of JWT token to the relevant actions. Both goja-based actions "v1"
and webhook based execution targets carry the actor information now.
Actor remains null in case of non-impersonated tokens. Actor may contain
nested actors to display a delegation chain of impersonators.

# Additional Changes

- dba6261c8e: refactor `userinfoFlows` to
reduce complexity, add test coverage and solve a couple of potential
bugs.
- `getClientId` in actions was previously undocumented. Added to
documentation.
- A skill that reproduces manual testing using webhook.site or a local
sink.

# Additional Context

- Closes https://github.com/zitadel/zitadel/issues/12097

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Wim Van Laer <wim+github@zitadel.com>
This commit is contained in:
Tim Möhlmann
2026-08-12 07:36:56 +00:00
committed by GitHub
co-authored by Claude Sonnet 5 Copilot Autofix powered by AI Wim Van Laer
parent 790244dd49
commit 6127b567e7
16 changed files with 1406 additions and 141 deletions
+1 -2
View File
@@ -33,6 +33,7 @@ google-credentials
key.json
.keys/*
load-test/.keys
*.pat
# dumps
.backups
@@ -82,8 +83,6 @@ build/local/*.env
/zitadel
node_modules/
.kreya
login-client.pat
admin.pat
.env.*local
go.work
@@ -25,6 +25,11 @@ The trigger is represented by the following Ids in the API: `4`
- `grants` [*UserGrantList*](./objects#user-grant-list)
- `org`
- `getMetadata()` [*metadataResult*](./objects#metadata-result)
- `application`
- `getClientId()` *string*
The client id of the application the token was requested for
- `actor` [*token actor*](./objects#token-actor)
The party which obtained the token on behalf of the user, `null` unless the token was obtained through token exchange or impersonation
- `api`
The second parameter contains the following fields:
- `v1`
@@ -59,6 +64,11 @@ The trigger is represented by the following Ids in the API: `5`
- `grants` [*UserGrantList*](./objects#user-grant-list)
- `org`
- `getMetadata()` [*metadataResult*](./objects#metadata-result)
- `application`
- `getClientId()` *string*
The client id of the application the token was requested for
- `actor` [*token actor*](./objects#token-actor)
The party which obtained the token on behalf of the user, `null` unless the token was obtained through token exchange or impersonation
- `api`
The second parameter contains the following fields:
- `v1`
@@ -187,6 +187,19 @@ This object represents [the claims](../openidoauth/claims) which will be written
There could be additional fields depending on the settings of your [project](../../guides/manage/console/projects-overview#role-settings) and your [application](../../guides/manage/console/applications-overview#token-settings)
## token actor
This object represents the actor of a token, this is the party which obtained the token on behalf of the
subject through [token exchange](/guides/integrate/token-exchange#actor-token) or impersonation.
It corresponds to the [`act` claim](../openidoauth/claims) of the token and is `null` if the token has no actor.
- `userId` *string*
The id of the user acting on behalf of the subject
- `issuer` *string*
The issuer of the token the actor was authenticated with
- `actor` *token actor*
The previous actor in the delegation chain, `null` if there is none
## user grant list
This object represents a list of user grants (role assignments) stored in ZITADEL.
@@ -163,10 +163,16 @@ Your server should now print out something like the following. Check out the [Se
"id" : "312909075211944344",
"name" : "ZITADEL",
"primary_domain" : "example.com"
},
"application" : {
"client_id" : "312909075212534168"
}
}
```
This login was a regular OIDC flow, so no `actor` is included. The `actor` object is only sent if the
token was obtained through [token exchange](/guides/integrate/token-exchange#actor-token) or impersonation.
For any further information related to [the OIDC Flow, refer to our documentation.](/guides/integrate/login/oidc/login-users)
## Conclusion
@@ -138,10 +138,24 @@ The information sent to the Endpoint is structured as JSON:
"projectId": "",
"projectName": ""
}
]
],
"application": {
"client_id": "The client_id of the application the token was requested for"
},
"actor": {
"user_id": "The ID of the user acting on behalf of the token subject",
"issuer": "The issuer of the token the actor was authenticated with",
"actor": {
"user_id": "The previous actor in the delegation chain, if there is one",
"issuer": ""
}
}
}
```
The `actor` object corresponds to the `act` claim of the token. It is only sent if the token was
obtained through [token exchange](/guides/integrate/token-exchange#actor-token) or impersonation.
The expected structure of the JSON as response:
```json
@@ -232,10 +246,24 @@ The information sent to the Endpoint is structured as JSON:
"projectId": "",
"projectName": ""
}
]
],
"application": {
"client_id": "The client_id of the application the token was requested for"
},
"actor": {
"user_id": "The ID of the user acting on behalf of the token subject",
"issuer": "The issuer of the token the actor was authenticated with",
"actor": {
"user_id": "The previous actor in the delegation chain, if there is one",
"issuer": ""
}
}
}
```
The `actor` object corresponds to the `act` claim of the token. It is only sent if the token was
obtained through [token exchange](/guides/integrate/token-exchange#actor-token) or impersonation.
The expected structure of the JSON as response:
```json
+43
View File
@@ -0,0 +1,43 @@
package object
import (
"github.com/dop251/goja"
"github.com/zitadel/zitadel/internal/actions"
"github.com/zitadel/zitadel/internal/domain"
)
// TokenActorField accepts a domain.TokenActor pointer and copies its content so scripts can't mutate the domain object.
func TokenActorField(actor *domain.TokenActor) func(c *actions.FieldConfig) interface{} {
return func(c *actions.FieldConfig) interface{} {
return TokenActorFromDomain(c, actor)
}
}
// TokenActorFromDomain returns the actor of a token, in case it was obtained through
// token exchange or impersonation. It returns null when there is no actor.
func TokenActorFromDomain(c *actions.FieldConfig, actor *domain.TokenActor) goja.Value {
if actor == nil {
return c.Runtime.ToValue(nil)
}
return c.Runtime.ToValue(tokenActorFromDomain(actor))
}
// tokenActorFromDomain copies the delegation chain, so scripts can't mutate the domain object.
func tokenActorFromDomain(actor *domain.TokenActor) *tokenActor {
if actor == nil {
return nil
}
return &tokenActor{
Actor: tokenActorFromDomain(actor.Actor),
UserId: actor.UserID,
Issuer: actor.Issuer,
}
}
type tokenActor struct {
// Actor is the previous actor in the delegation chain, null if there is none.
Actor *tokenActor
UserId string
Issuer string
}
@@ -16,6 +16,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/oidc/v3/pkg/client/rp"
"github.com/zitadel/oidc/v3/pkg/client/tokenexchange"
"github.com/zitadel/oidc/v3/pkg/oidc"
"github.com/zitadel/oidc/v3/pkg/op"
"golang.org/x/text/language"
@@ -781,7 +782,7 @@ func expectPreUserinfoExecution(ctx context.Context, t *testing.T, instance *int
SessionToken: sessionResp.GetSessionToken(),
},
}
expectedContextInfo := contextInfoForUserOIDC(instance, "function/preuserinfo", clientID, userResp, userEmail, userPhone)
expectedContextInfo := contextInfoForUserOIDC(instance, "function/preuserinfo", clientID, userResp, userEmail, userPhone, nil)
targetURL, closeF, _, _ := integration.TestServerCall(expectedContextInfo, 0, http.StatusOK, response)
@@ -859,7 +860,9 @@ func getAccessTokenClaims(ctx context.Context, t *testing.T, instance *integrati
return claims
}
func contextInfoForUserOIDC(instance *integration.Instance, function string, clientID string, userResp *user.AddHumanUserResponse, email, phone string) *oidc_api.ContextInfo {
// contextInfoForUserOIDC builds the payload a target is expected to receive. actor is nil for
// regular flows and only set when the token was obtained through impersonation.
func contextInfoForUserOIDC(instance *integration.Instance, function string, clientID string, userResp *user.AddHumanUserResponse, email, phone string, actor *domain.TokenActor) *oidc_api.ContextInfo {
return &oidc_api.ContextInfo{
Function: function,
UserInfo: &oidc.UserInfo{
@@ -901,6 +904,7 @@ func contextInfoForUserOIDC(instance *integration.Instance, function string, cli
PrimaryDomain: instance.DefaultOrg.GetPrimaryDomain(),
},
UserGrants: nil,
Actor: actor,
Response: nil,
}
}
@@ -1089,7 +1093,7 @@ func expectPreAccessTokenExecution(ctx context.Context, t *testing.T, instance *
SessionToken: sessionResp.GetSessionToken(),
},
}
expectedContextInfo := contextInfoForUserOIDC(instance, "function/preaccesstoken", clientID, userResp, userEmail, userPhone)
expectedContextInfo := contextInfoForUserOIDC(instance, "function/preaccesstoken", clientID, userResp, userEmail, userPhone, nil)
targetURL, closeF, _, _ := integration.TestServerCall(expectedContextInfo, 0, http.StatusOK, response)
@@ -1098,6 +1102,86 @@ func expectPreAccessTokenExecution(ctx context.Context, t *testing.T, instance *
return userResp.GetUserId(), closeF
}
// TestServer_ExecutionTargetPreUserinfoImpersonation checks that a target called for the
// preuserinfo function is told who impersonated the user.
func TestServer_ExecutionTargetPreUserinfoImpersonation(t *testing.T) {
testExecutionTargetImpersonation(t, "preuserinfo")
}
// TestServer_ExecutionTargetPreAccessTokenImpersonation checks the same for the
// preaccesstoken function.
func TestServer_ExecutionTargetPreAccessTokenImpersonation(t *testing.T) {
testExecutionTargetImpersonation(t, "preaccesstoken")
}
// testExecutionTargetImpersonation obtains a token through impersonation and asserts that the
// target registered for function receives the actor of that token.
//
// The counterpart is covered by TestServer_ExecutionTargetPreUserinfo and
// TestServer_ExecutionTargetPreAccessToken: their expected ContextInfo has no actor, and since
// the target server compares the received body byte for byte, they would fail if an actor was
// sent for a token that was not impersonated.
func testExecutionTargetImpersonation(t *testing.T, function string) {
instance := integration.NewInstance(CTX)
isolatedIAMCtx := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner)
instance.SetImpersonationPolicy(CTX, t, true)
// the user being impersonated, created the same way as in the non impersonated tests so
// that contextInfoForUserOIDC describes it correctly
userEmail := integration.Email()
userPhone := integration.Phone()
userResp := instance.CreateHumanUserVerified(isolatedIAMCtx, instance.DefaultOrg.Id, userEmail, userPhone)
// the impersonator, whose PAT is passed as actor token
impersonatorID, impersonatorPAT, err := instance.CreateMachineUserPATWithMembership(isolatedIAMCtx, "IAM_END_USER_IMPERSONATOR")
require.NoError(t, err)
client, keyData, err := instance.CreateOIDCTokenExchangeClient(isolatedIAMCtx, t)
require.NoError(t, err)
signer, err := rp.SignerFromKeyFile(keyData)()
require.NoError(t, err)
exchanger, err := tokenexchange.NewTokenExchangerJWTProfile(isolatedIAMCtx, instance.OIDCIssuer(), client.GetClientId(), signer)
require.NoError(t, err)
expectedContextInfo := contextInfoForUserOIDC(instance, "function/"+function, client.GetClientId(), userResp, userEmail, userPhone,
&domain.TokenActor{
UserID: impersonatorID,
Issuer: instance.OIDCIssuer(),
},
)
response := &oidc_api.ContextInfoResponse{
AppendClaims: []*oidc_api.AppendClaim{
{Key: "added", Value: "value"},
},
}
targetURL, closeF, calledF, _ := integration.TestServerCall(expectedContextInfo, 0, http.StatusOK, response)
defer closeF()
// interrupt on error, so a payload that does not match the expectation fails the token
// exchange below instead of passing silently
targetResp := waitForTarget(isolatedIAMCtx, t, instance, targetURL, target_domain.TargetTypeCall, true, action.PayloadType_PAYLOAD_TYPE_JSON)
waitForExecutionOnCondition(isolatedIAMCtx, t, instance, conditionFunction(function), []string{targetResp.GetId()})
// Impersonate by user ID. A JWT is requested so that the access token is signed by
// createJWT, which is what triggers preaccesstoken. The openid scope produces an ID token,
// which is what triggers preuserinfo. Only one of the two has an execution registered.
tokens, err := tokenexchange.ExchangeToken(isolatedIAMCtx, exchanger,
userResp.GetUserId(), oidc_api.UserIDTokenType,
impersonatorPAT, oidc.AccessTokenType,
nil, nil, []string{oidc.ScopeOpenID}, oidc.JWTTokenType,
)
require.NoError(t, err)
assert.Equal(t, 1, calledF())
verifier := op.NewAccessTokenVerifier(instance.OIDCIssuer(), rp.NewRemoteKeySet(http.DefaultClient, instance.OIDCIssuer()+"/oauth/v2/keys"))
claims, err := op.VerifyAccessToken[*oidc.AccessTokenClaims](isolatedIAMCtx, tokens.AccessToken, verifier)
require.NoError(t, err)
assert.Equal(t, userResp.GetUserId(), claims.Subject)
require.NotNil(t, claims.Actor)
assert.Equal(t, impersonatorID, claims.Actor.Subject)
}
func TestServer_ExecutionTargetPreSAMLResponse(t *testing.T) {
instance := integration.NewInstance(CTX)
isolatedIAMCtx := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner)
@@ -27,32 +27,7 @@ import (
)
func setImpersonationPolicy(t *testing.T, instance *integration.Instance, 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)
}
retryDuration := time.Minute
if ctxDeadline, ok := iamCTX.Deadline(); ok {
retryDuration = time.Until(ctxDeadline)
}
require.EventuallyWithT(t,
func(ttt *assert.CollectT) {
f, err := instance.Client.Admin.GetSecurityPolicy(iamCTX, &admin.GetSecurityPolicyRequest{})
assert.NoError(ttt, err)
if f.GetPolicy().GetEnableImpersonation() != value {
return
}
},
retryDuration,
time.Second,
"timed out waiting for ensuring impersonation policy")
instance.SetImpersonationPolicy(CTX, t, value)
}
func createMachineUserPATWithMembership(ctx context.Context, t *testing.T, instance *integration.Instance, roles ...string) (userID, pat string) {
+1 -1
View File
@@ -104,7 +104,7 @@ func (s *Server) Introspect(ctx context.Context, r *op.Request[op.IntrospectionR
client.projectRoleAssertion,
true,
true,
)(ctx, true, domain.TriggerTypePreUserinfoCreation)
)(ctx, true, domain.TriggerTypePreUserinfoCreation, token.actor)
if err != nil {
return nil, err
}
+5 -5
View File
@@ -61,14 +61,14 @@ func (s *Server) getSignerOnce() sign.SignerFunc {
}
// userInfoFunc is a getter function that allows add-hoc retrieval of a user.
type userInfoFunc func(ctx context.Context, roleAssertion bool, triggerType domain.TriggerType) (*oidc.UserInfo, error)
type userInfoFunc func(ctx context.Context, roleAssertion bool, triggerType domain.TriggerType, actor *domain.TokenActor) (*oidc.UserInfo, error)
// getUserInfo returns a function which retrieves userinfo from the database once.
// However, each time, role claims are asserted and also action flows will trigger.
func (s *Server) getUserInfo(userID, projectID, clientID string, projectRoleAssertion, userInfoAssertion bool, scope []string) userInfoFunc {
userInfo := s.userInfo(userID, scope, projectID, clientID, projectRoleAssertion, userInfoAssertion, false)
return func(ctx context.Context, roleAssertion bool, triggerType domain.TriggerType) (*oidc.UserInfo, error) {
return userInfo(ctx, roleAssertion, triggerType)
return func(ctx context.Context, roleAssertion bool, triggerType domain.TriggerType, actor *domain.TokenActor) (*oidc.UserInfo, error) {
return userInfo(ctx, roleAssertion, triggerType, actor)
}
}
@@ -76,7 +76,7 @@ func (*Server) createIDToken(ctx context.Context, client op.Client, getUserInfo
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
userInfo, err := getUserInfo(ctx, roleAssertion, domain.TriggerTypePreUserinfoCreation)
userInfo, err := getUserInfo(ctx, roleAssertion, domain.TriggerTypePreUserinfoCreation, actor)
if err != nil {
return "", 0, err
}
@@ -120,7 +120,7 @@ func (s *Server) createJWT(ctx context.Context, client op.Client, session *comma
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
userInfo, err := getUserInfo(ctx, assertRoles, domain.TriggerTypePreAccessTokenCreation)
userInfo, err := getUserInfo(ctx, assertRoles, domain.TriggerTypePreAccessTokenCreation, session.Actor)
if err != nil {
return "", err
}
+186 -102
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"maps"
"net/http"
@@ -21,6 +22,7 @@ import (
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/execution"
target_domain "github.com/zitadel/zitadel/internal/execution/target"
"github.com/zitadel/zitadel/internal/query"
exec_repo "github.com/zitadel/zitadel/internal/repository/execution"
"github.com/zitadel/zitadel/internal/telemetry/tracing"
@@ -58,7 +60,7 @@ func (s *Server) UserInfo(ctx context.Context, r *op.Request[oidc.UserInfoReques
assertion,
true,
false,
)(ctx, true, domain.TriggerTypePreUserinfoCreation)
)(ctx, true, domain.TriggerTypePreUserinfoCreation, token.actor)
if err != nil {
if !zerrors.IsNotFound(err) {
return nil, err
@@ -89,14 +91,14 @@ func (s *Server) userInfo(
projectID string,
clientID string,
projectRoleAssertion, userInfoAssertion, currentProjectOnly bool,
) func(ctx context.Context, roleAssertion bool, triggerType domain.TriggerType) (_ *oidc.UserInfo, err error) {
) userInfoFunc {
var (
once sync.Once
rawUserInfo *oidc.UserInfo
qu *query.OIDCUserInfo
roleAudience, requestedRoles []string
)
return func(ctx context.Context, roleAssertion bool, triggerType domain.TriggerType) (_ *oidc.UserInfo, err error) {
return func(ctx context.Context, roleAssertion bool, triggerType domain.TriggerType, actor *domain.TokenActor) (_ *oidc.UserInfo, err error) {
once.Do(func() {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
@@ -122,7 +124,7 @@ func (s *Server) userInfo(
Claims: maps.Clone(rawUserInfo.Claims),
}
assertRoles(projectID, qu, roleAudience, requestedRoles, roleAssertion, userInfo)
return userInfo, s.userinfoFlows(ctx, qu, userInfo, triggerType, clientID)
return userInfo, s.userinfoFlows(ctx, qu, userInfo, triggerType, clientID, actor)
}
}
@@ -309,16 +311,49 @@ func setUserInfoUserGroups(userGroups []query.UserInfoUserGroup, out *oidc.UserI
out.AppendClaims(ClaimUserGroups, groups)
}
//nolint:gocognit
func (s *Server) userinfoFlows(ctx context.Context, qu *query.OIDCUserInfo, userInfo *oidc.UserInfo, triggerType domain.TriggerType, clientID string) (err error) {
func (s *Server) userinfoFlows(
ctx context.Context,
qu *query.OIDCUserInfo,
userInfo *oidc.UserInfo,
triggerType domain.TriggerType,
clientID string,
actor *domain.TokenActor,
) (err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
if err := s.runUserinfoActionFlows(ctx, qu, userInfo, triggerType, clientID, actor); err != nil {
return err
}
return s.runUserinfoExecutionFlow(ctx, qu, userInfo, triggerType, clientID, actor)
}
// runUserinfoActionFlows loads the legacy, DB-configured Actions for triggerType and runs them.
func (s *Server) runUserinfoActionFlows(
ctx context.Context,
qu *query.OIDCUserInfo,
userInfo *oidc.UserInfo,
triggerType domain.TriggerType,
clientID string,
actor *domain.TokenActor,
) error {
queriedActions, err := s.query.GetActiveActionsByFlowAndTriggerType(ctx, domain.FlowTypeCustomiseToken, triggerType, qu.User.ResourceOwner)
if err != nil {
return err
}
return s.runUserinfoActions(ctx, qu, userInfo, clientID, actor, queriedActions)
}
// runUserinfoActions runs the given, already queried Actions. It is kept separate from
// runUserinfoActionFlows so it can be unit tested without a DB by injecting queriedActions directly.
func (s *Server) runUserinfoActions(
ctx context.Context,
qu *query.OIDCUserInfo,
userInfo *oidc.UserInfo,
clientID string,
actor *domain.TokenActor,
queriedActions []*query.Action,
) error {
ctxFields := actions.SetContextFields(
actions.SetFields("v1",
actions.SetFields("claims", userinfoClaims(userInfo)),
@@ -351,106 +386,154 @@ func (s *Server) userinfoFlows(ctx context.Context, qu *query.OIDCUserInfo, user
}
}),
),
// actor is null unless the token was obtained through token exchange / impersonation.
actions.SetFields("actor", object.TokenActorField(actor)),
),
)
for _, action := range queriedActions {
actionCtx, cancel := context.WithTimeout(ctx, action.Timeout())
claimLogs := []string{}
// Actions.contextFields/apiFields are unexported types in package actions, so ctxFields
// can't be passed as a typed parameter to a separate function - it's captured by this
// closure instead. The closure also gives each iteration its own deferred cancel() and
// recover(), so a timeout-bound context never outlives its iteration and a panic from
// building or running the script (whether from a goja-exposed closure or from
// constructing ctxFields/apiFields itself) is converted into a returned error instead
// of escaping to the caller.
runAction := func(action *query.Action) (err error) {
actionCtx, cancel := context.WithTimeout(ctx, action.Timeout())
defer cancel()
defer func() {
if r := recover(); r != nil {
err = errorFromRecover(r)
}
}()
apiFields := actions.WithAPIFields(
actions.SetFields("v1",
actions.SetFields("userinfo",
actions.SetFields("setClaim", func(key string, value interface{}) {
if strings.HasPrefix(key, ClaimPrefix) {
return
}
if userInfo.Claims[key] == nil {
userInfo.AppendClaims(key, value)
return
}
claimLogs = append(claimLogs, fmt.Sprintf("key %q already exists", key))
}),
actions.SetFields("appendLogIntoClaims", func(entry string) {
claimLogs = append(claimLogs, entry)
}),
claimLogs := []string{}
setClaim := func(key string, value interface{}) {
appendOrLogClaim(userInfo, key, value, &claimLogs)
}
appendLogIntoClaims := func(entry string) {
claimLogs = append(claimLogs, entry)
}
apiFields := actions.WithAPIFields(
actions.SetFields("v1",
actions.SetFields("userinfo",
actions.SetFields("setClaim", setClaim),
actions.SetFields("appendLogIntoClaims", appendLogIntoClaims),
),
actions.SetFields("claims",
actions.SetFields("setClaim", setClaim),
actions.SetFields("appendLogIntoClaims", appendLogIntoClaims),
),
actions.SetFields("user",
actions.SetFields("setMetadata", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) != 2 {
panic("exactly 2 (key, value) arguments expected")
}
key := call.Arguments[0].Export().(string)
val := call.Arguments[1].Export()
value, err := json.Marshal(val)
if err != nil {
logging.WithError(err).Debug("unable to marshal")
panic(err)
}
metadata := &domain.Metadata{
Key: key,
Value: value,
}
if _, err = s.command.SetUserMetadata(actionCtx, metadata, userInfo.Subject, qu.User.ResourceOwner, nil); err != nil {
logging.WithError(err).Info("unable to set md in action")
panic(err)
}
return nil
}),
),
),
actions.SetFields("claims",
actions.SetFields("setClaim", func(key string, value interface{}) {
if strings.HasPrefix(key, ClaimPrefix) {
return
}
if userInfo.Claims[key] == nil {
userInfo.AppendClaims(key, value)
return
}
claimLogs = append(claimLogs, fmt.Sprintf("key %q already exists", key))
}),
actions.SetFields("appendLogIntoClaims", func(entry string) {
claimLogs = append(claimLogs, entry)
}),
),
actions.SetFields("user",
actions.SetFields("setMetadata", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) != 2 {
panic("exactly 2 (key, value) arguments expected")
}
key := call.Arguments[0].Export().(string)
val := call.Arguments[1].Export()
)
value, err := json.Marshal(val)
if err != nil {
logging.WithError(err).Debug("unable to marshal")
panic(err)
}
if err := actions.Run(
actionCtx,
ctxFields,
apiFields,
action.Script,
action.Name,
append(actions.ActionToOptions(action), actions.WithHTTP(actionCtx, s.httpClient), actions.WithUUID(actionCtx))...,
); err != nil {
return err
}
if len(claimLogs) > 0 {
userInfo.AppendClaims(fmt.Sprintf(ClaimActionLogFormat, action.Name), claimLogs)
}
return nil
}
metadata := &domain.Metadata{
Key: key,
Value: value,
}
if _, err = s.command.SetUserMetadata(ctx, metadata, userInfo.Subject, qu.User.ResourceOwner, nil); err != nil {
logging.WithError(err).Info("unable to set md in action")
panic(err)
}
return nil
}),
),
),
)
err = actions.Run(
actionCtx,
ctxFields,
apiFields,
action.Script,
action.Name,
append(actions.ActionToOptions(action), actions.WithHTTP(actionCtx, s.httpClient), actions.WithUUID(actionCtx))...,
)
cancel()
if err != nil {
if err := runAction(action); err != nil {
return err
}
if len(claimLogs) > 0 {
userInfo.AppendClaims(fmt.Sprintf(ClaimActionLogFormat, action.Name), claimLogs)
}
}
return nil
}
var function string
switch triggerType {
case domain.TriggerTypePreUserinfoCreation:
function = exec_repo.ID(domain.ExecutionTypeFunction, domain.ActionFunctionPreUserinfo.LocalizationKey())
case domain.TriggerTypePreAccessTokenCreation:
function = exec_repo.ID(domain.ExecutionTypeFunction, domain.ActionFunctionPreAccessToken.LocalizationKey())
case domain.TriggerTypeUnspecified, domain.TriggerTypePostAuthentication, domain.TriggerTypePreCreation, domain.TriggerTypePostCreation, domain.TriggerTypePreSAMLResponseCreation:
// added for linting, there should never be any trigger type be used here besides PreUserinfo and PreAccessToken
// appendOrLogClaim adds value under key unless key is reserved (ClaimPrefix) or already set,
// in which case a conflict message is appended to logs instead. Shared by the setClaim
// goja closures and the execution-response AppendClaims merge.
func appendOrLogClaim(userInfo *oidc.UserInfo, key string, value any, logs *[]string) {
if strings.HasPrefix(key, ClaimPrefix) {
return
}
if userInfo.Claims[key] == nil {
userInfo.AppendClaims(key, value)
return
}
*logs = append(*logs, fmt.Sprintf("key %q already exists", key))
}
// errorFromRecover converts a recovered panic value into an error, mirroring the idiom
// internal/actions.executeFn already uses internally for panics from goja-exposed closures.
func errorFromRecover(r any) error {
if err, ok := r.(error); ok {
return err
}
if s, ok := r.(string); ok {
return errors.New(s)
}
return fmt.Errorf("unknown error occurred: %v", r)
}
// functionForTriggerType resolves the Execution function key for a trigger type, or ""
// if no Execution runs for that trigger type. The switch is kept exhaustive (no default)
// so the `exhaustive` linter forces a conscious decision whenever a new domain.TriggerType
// is added, rather than silently falling through.
func functionForTriggerType(triggerType domain.TriggerType) string {
switch triggerType {
case domain.TriggerTypePreUserinfoCreation:
return exec_repo.ID(domain.ExecutionTypeFunction, domain.ActionFunctionPreUserinfo.LocalizationKey())
case domain.TriggerTypePreAccessTokenCreation:
return exec_repo.ID(domain.ExecutionTypeFunction, domain.ActionFunctionPreAccessToken.LocalizationKey())
case domain.TriggerTypeUnspecified, domain.TriggerTypePostAuthentication, domain.TriggerTypePreCreation, domain.TriggerTypePostCreation, domain.TriggerTypePreSAMLResponseCreation:
// there should never be any trigger type used here besides PreUserinfo and PreAccessToken
return ""
}
return ""
}
// runUserinfoExecutionFlow resolves and runs the new-style Executions (webhook targets) for triggerType.
func (s *Server) runUserinfoExecutionFlow(ctx context.Context, qu *query.OIDCUserInfo, userInfo *oidc.UserInfo, triggerType domain.TriggerType, clientID string, actor *domain.TokenActor) error {
function := functionForTriggerType(triggerType)
if function == "" {
return nil
}
executionTargets := execution.QueryExecutionTargetsForFunction(ctx, function)
return s.runUserinfoExecutionTargets(ctx, qu, userInfo, clientID, actor, function, executionTargets)
}
// runUserinfoExecutionTargets calls the given, already resolved Execution targets. It is kept
// separate from runUserinfoExecutionFlow so it can be unit tested without authz/DB access by
// injecting executionTargets directly (e.g. pointing at httptest servers).
func (s *Server) runUserinfoExecutionTargets(ctx context.Context, qu *query.OIDCUserInfo, userInfo *oidc.UserInfo, clientID string, actor *domain.TokenActor, function string, executionTargets []target_domain.Target) error {
info := &ContextInfo{
Function: function,
UserInfo: userInfo,
@@ -459,6 +542,7 @@ func (s *Server) userinfoFlows(ctx context.Context, qu *query.OIDCUserInfo, user
Org: qu.Org,
Application: &ContextInfoApplication{ClientID: clientID},
UserGrants: qu.UserGrants,
Actor: actor,
}
resp, err := execution.CallTargets(ctx, executionTargets, info, s.targetEncryptionAlgorithm, s.query.GetActiveSigningWebKey, s.httpClient)
@@ -469,28 +553,26 @@ func (s *Server) userinfoFlows(ctx context.Context, qu *query.OIDCUserInfo, user
if !ok || contextInfoResponse == nil {
return nil
}
s.applyContextInfoResponse(ctx, qu, userInfo, function, contextInfoResponse)
return nil
}
// applyContextInfoResponse merges an Execution's response back into userInfo: applying
// requested user metadata writes and appending claims, logging any conflicts along the way.
func (s *Server) applyContextInfoResponse(ctx context.Context, qu *query.OIDCUserInfo, userInfo *oidc.UserInfo, function string, resp *ContextInfoResponse) {
claimLogs := make([]string, 0)
for _, metadata := range contextInfoResponse.SetUserMetadata {
if _, err = s.command.SetUserMetadata(ctx, metadata, userInfo.Subject, qu.User.ResourceOwner, nil); err != nil {
for _, metadata := range resp.SetUserMetadata {
if _, err := s.command.SetUserMetadata(ctx, metadata, userInfo.Subject, qu.User.ResourceOwner, nil); err != nil {
claimLogs = append(claimLogs, fmt.Sprintf("failed to set user metadata key %q", metadata.Key))
}
}
for _, claim := range contextInfoResponse.AppendClaims {
if strings.HasPrefix(claim.Key, ClaimPrefix) {
continue
}
if userInfo.Claims[claim.Key] == nil {
userInfo.AppendClaims(claim.Key, claim.Value)
continue
}
claimLogs = append(claimLogs, fmt.Sprintf("key %q already exists", claim.Key))
for _, claim := range resp.AppendClaims {
appendOrLogClaim(userInfo, claim.Key, claim.Value, &claimLogs)
}
claimLogs = append(claimLogs, contextInfoResponse.AppendLogClaims...)
claimLogs = append(claimLogs, resp.AppendLogClaims...)
if len(claimLogs) > 0 {
userInfo.AppendClaims(fmt.Sprintf(ClaimActionLogFormat, function), claimLogs)
}
return nil
}
type ContextInfo struct {
@@ -501,7 +583,9 @@ type ContextInfo struct {
Org *query.UserInfoOrg `json:"org,omitempty"`
UserGrants []query.UserGrant `json:"user_grants,omitempty"`
Application *ContextInfoApplication `json:"application,omitempty"`
Response *ContextInfoResponse `json:"response,omitempty"`
// Actor is only set when the token was obtained through token exchange / impersonation.
Actor *domain.TokenActor `json:"actor,omitempty"`
Response *ContextInfoResponse `json:"response,omitempty"`
}
type ContextInfoApplication struct {
ClientID string `json:"client_id,omitempty"`
+300
View File
@@ -3,15 +3,27 @@ package oidc
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/oidc/v3/pkg/oidc"
"golang.org/x/text/language"
"github.com/zitadel/zitadel/internal/actions"
"github.com/zitadel/zitadel/internal/domain"
target_domain "github.com/zitadel/zitadel/internal/execution/target"
"github.com/zitadel/zitadel/internal/logstore"
"github.com/zitadel/zitadel/internal/logstore/record"
"github.com/zitadel/zitadel/internal/query"
exec_repo "github.com/zitadel/zitadel/internal/repository/execution"
)
func Test_prepareRoles(t *testing.T) {
@@ -558,3 +570,291 @@ func Test_userInfoToOIDC(t *testing.T) {
})
}
}
func Test_functionForTriggerType(t *testing.T) {
tests := []struct {
name string
triggerType domain.TriggerType
want string
}{
{
name: "pre userinfo creation",
triggerType: domain.TriggerTypePreUserinfoCreation,
want: exec_repo.ID(domain.ExecutionTypeFunction, domain.ActionFunctionPreUserinfo.LocalizationKey()),
},
{
name: "pre access token creation",
triggerType: domain.TriggerTypePreAccessTokenCreation,
want: exec_repo.ID(domain.ExecutionTypeFunction, domain.ActionFunctionPreAccessToken.LocalizationKey()),
},
{
name: "unspecified",
triggerType: domain.TriggerTypeUnspecified,
want: "",
},
{
name: "post authentication",
triggerType: domain.TriggerTypePostAuthentication,
want: "",
},
{
name: "pre creation",
triggerType: domain.TriggerTypePreCreation,
want: "",
},
{
name: "post creation",
triggerType: domain.TriggerTypePostCreation,
want: "",
},
{
name: "pre saml response creation",
triggerType: domain.TriggerTypePreSAMLResponseCreation,
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, functionForTriggerType(tt.triggerType))
})
}
}
func Test_appendOrLogClaim(t *testing.T) {
tests := []struct {
name string
userInfo *oidc.UserInfo
key string
value any
wantClaims map[string]any
wantLogs []string
}{
{
name: "new claim is added",
userInfo: &oidc.UserInfo{Claims: map[string]any{}},
key: "foo",
value: "bar",
wantClaims: map[string]any{"foo": "bar"},
},
{
name: "reserved prefix is skipped",
userInfo: &oidc.UserInfo{Claims: map[string]any{}},
key: ClaimPrefix + ":something",
value: "bar",
wantClaims: map[string]any{},
},
{
name: "existing claim is not overwritten and logs a conflict",
userInfo: &oidc.UserInfo{Claims: map[string]any{"foo": "existing"}},
key: "foo",
value: "bar",
wantClaims: map[string]any{"foo": "existing"},
wantLogs: []string{`key "foo" already exists`},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var logs []string
appendOrLogClaim(tt.userInfo, tt.key, tt.value, &logs)
assert.Equal(t, tt.wantClaims, tt.userInfo.Claims)
assert.Equal(t, tt.wantLogs, logs)
})
}
}
func Test_errorFromRecover(t *testing.T) {
tests := []struct {
name string
r any
wantMsg string
}{
{
name: "error value is passed through",
r: errors.New("boom"),
wantMsg: "boom",
},
{
name: "string value is wrapped",
r: "boom",
wantMsg: "boom",
},
{
name: "other value falls back to a generic message",
r: 42,
wantMsg: "unknown error occurred: 42",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := errorFromRecover(tt.r)
require.Error(t, err)
assert.Equal(t, tt.wantMsg, err.Error())
})
}
}
func Test_runUserinfoActions(t *testing.T) {
actions.SetLogstoreService(logstore.New[*record.ExecutionLog](nil, nil))
t.Run("happy path sets claim from script", func(t *testing.T) {
s := &Server{}
qu := &query.OIDCUserInfo{User: &query.User{ID: "user1", ResourceOwner: "org1"}}
userInfo := &oidc.UserInfo{Subject: "user1", Claims: map[string]any{}}
queriedActions := []*query.Action{
{
Name: "testFunc",
Script: `function testFunc(ctx, api) {
api.v1.userinfo.setClaim("foo", "bar")
}`,
},
}
err := s.runUserinfoActions(context.Background(), qu, userInfo, "clientID", nil, queriedActions)
require.NoError(t, err)
assert.Equal(t, "bar", userInfo.Claims["foo"])
})
t.Run("panic while building ctxFields is recovered, not left uncaught", func(t *testing.T) {
s := &Server{}
qu := &query.OIDCUserInfo{User: &query.User{ID: "user1", ResourceOwner: "org1"}}
// A non-JSON-marshalable claim value makes userinfoClaims' eager json.Marshal panic
// while ctxFields are being built for this (or a later) action - a phase that runs
// before internal/actions.executeScript's own recover is armed. Without our own
// recover in the loop, this panic would escape uncaught and skip cancel().
userInfo := &oidc.UserInfo{Subject: "user1", Claims: map[string]any{"bad": make(chan int)}}
queriedActions := []*query.Action{
{Name: "testFunc", Script: `function testFunc(ctx, api) {}`},
}
err := s.runUserinfoActions(context.Background(), qu, userInfo, "clientID", nil, queriedActions)
require.Error(t, err)
})
t.Run("panic from setMetadata bad arg count is recovered", func(t *testing.T) {
s := &Server{}
qu := &query.OIDCUserInfo{User: &query.User{ID: "user1", ResourceOwner: "org1"}}
userInfo := &oidc.UserInfo{Subject: "user1", Claims: map[string]any{}}
queriedActions := []*query.Action{
{
Name: "testFunc",
Script: `function testFunc(ctx, api) {
api.v1.user.setMetadata("onlyonearg")
}`,
},
}
err := s.runUserinfoActions(context.Background(), qu, userInfo, "clientID", nil, queriedActions)
require.Error(t, err)
assert.Contains(t, err.Error(), "exactly 2")
})
t.Run("error from a script stops the loop and is returned", func(t *testing.T) {
s := &Server{}
qu := &query.OIDCUserInfo{User: &query.User{ID: "user1", ResourceOwner: "org1"}}
userInfo := &oidc.UserInfo{Subject: "user1", Claims: map[string]any{}}
queriedActions := []*query.Action{
{
Name: "testFunc",
Script: `function testFunc(ctx, api) { throw new Error("script failure") }`,
},
{
Name: "shouldNotRun",
Script: `function shouldNotRun(ctx, api) {
api.v1.userinfo.setClaim("unreachable", "value")
}`,
},
}
err := s.runUserinfoActions(context.Background(), qu, userInfo, "clientID", nil, queriedActions)
require.Error(t, err)
assert.NotContains(t, userInfo.Claims, "unreachable")
})
t.Run("actor delegation chain is readable from the script", func(t *testing.T) {
s := &Server{}
qu := &query.OIDCUserInfo{User: &query.User{ID: "user1", ResourceOwner: "org1"}}
userInfo := &oidc.UserInfo{Subject: "user1", Claims: map[string]any{}}
actor := &domain.TokenActor{
UserID: "actor1",
Issuer: "https://issuer1.example.com",
Actor: &domain.TokenActor{
UserID: "actor2",
Issuer: "https://issuer2.example.com",
},
}
queriedActions := []*query.Action{
{
Name: "testFunc",
Script: `function testFunc(ctx, api) {
api.v1.claims.setClaim("actor_user", ctx.v1.actor.userId)
api.v1.claims.setClaim("actor_issuer", ctx.v1.actor.issuer)
api.v1.claims.setClaim("nested_actor_user", ctx.v1.actor.actor.userId)
api.v1.claims.setClaim("chain_end", ctx.v1.actor.actor.actor === null)
}`,
},
}
err := s.runUserinfoActions(context.Background(), qu, userInfo, "clientID", actor, queriedActions)
require.NoError(t, err)
assert.Equal(t, "actor1", userInfo.Claims["actor_user"])
assert.Equal(t, "https://issuer1.example.com", userInfo.Claims["actor_issuer"])
assert.Equal(t, "actor2", userInfo.Claims["nested_actor_user"])
assert.Equal(t, true, userInfo.Claims["chain_end"])
})
t.Run("missing actor is null in the script", func(t *testing.T) {
s := &Server{}
qu := &query.OIDCUserInfo{User: &query.User{ID: "user1", ResourceOwner: "org1"}}
userInfo := &oidc.UserInfo{Subject: "user1", Claims: map[string]any{}}
queriedActions := []*query.Action{
{
Name: "testFunc",
Script: `function testFunc(ctx, api) {
api.v1.claims.setClaim("no_actor", ctx.v1.actor === null)
}`,
},
}
err := s.runUserinfoActions(context.Background(), qu, userInfo, "clientID", nil, queriedActions)
require.NoError(t, err)
assert.Equal(t, true, userInfo.Claims["no_actor"])
})
}
func Test_runUserinfoExecutionTargets(t *testing.T) {
respBody := []byte(`{"append_claims":[{"key":"foo","value":"bar"},{"key":"foo","value":"baz"}],"append_log_claims":["extra log entry"]}`)
var gotBody []byte
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotBody, _ = io.ReadAll(r.Body)
w.WriteHeader(http.StatusOK)
_, _ = w.Write(respBody)
}))
defer server.Close()
s := &Server{httpClient: server.Client()}
qu := &query.OIDCUserInfo{User: &query.User{ID: "user1", ResourceOwner: "org1"}}
userInfo := &oidc.UserInfo{Subject: "user1", Claims: map[string]any{}}
actor := &domain.TokenActor{
UserID: "actor1",
Issuer: "https://issuer1.example.com",
Actor: &domain.TokenActor{UserID: "actor2"},
}
targets := []target_domain.Target{
{TargetType: target_domain.TargetTypeCall, Endpoint: server.URL, Timeout: 5 * time.Second},
}
err := s.runUserinfoExecutionTargets(context.Background(), qu, userInfo, "clientID", actor, "function/test", targets)
require.NoError(t, err)
var sent ContextInfo
require.NoError(t, json.Unmarshal(gotBody, &sent))
assert.Equal(t, actor, sent.Actor)
assert.Equal(t, "bar", userInfo.Claims["foo"])
logClaim, ok := userInfo.Claims[fmt.Sprintf(ClaimActionLogFormat, "function/test")]
require.True(t, ok)
logs, ok := logClaim.([]string)
require.True(t, ok)
assert.Contains(t, logs, `key "foo" already exists`)
assert.Contains(t, logs, "extra log entry")
}
+29
View File
@@ -10,6 +10,8 @@ import (
"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"
@@ -18,6 +20,7 @@ import (
http_util "github.com/zitadel/zitadel/internal/api/http"
oidc_internal "github.com/zitadel/zitadel/internal/api/oidc"
"github.com/zitadel/zitadel/pkg/grpc/admin"
"github.com/zitadel/zitadel/pkg/grpc/app"
"github.com/zitadel/zitadel/pkg/grpc/authn"
"github.com/zitadel/zitadel/pkg/grpc/management"
@@ -444,6 +447,32 @@ func (i *Instance) CreateOIDCJWTProfileClient(ctx context.Context, keyLifetime t
return machine, name, keyResp.GetKeyDetails(), nil
}
// SetImpersonationPolicy sets EnableImpersonation on the instance security policy and waits
// until the change is visible again, so a subsequent token exchange doesn't race the projection.
func (i *Instance) SetImpersonationPolicy(ctx context.Context, t *testing.T, value bool) {
iamCTX := i.WithAuthorization(ctx, UserTypeIAMOwner)
policy, err := i.Client.Admin.GetSecurityPolicy(iamCTX, &admin.GetSecurityPolicyRequest{})
require.NoError(t, err)
if policy.GetPolicy().GetEnableImpersonation() != value {
_, err = i.Client.Admin.SetSecurityPolicy(iamCTX, &admin.SetSecurityPolicyRequest{
EnableImpersonation: value,
})
require.NoError(t, err)
}
retryDuration, tick := WaitForAndTickWithMaxDuration(iamCTX, time.Minute)
require.EventuallyWithT(t,
func(ttt *assert.CollectT) {
f, err := i.Client.Admin.GetSecurityPolicy(iamCTX, &admin.GetSecurityPolicyRequest{})
assert.NoError(ttt, err)
assert.Equal(ttt, value, f.GetPolicy().GetEnableImpersonation())
},
retryDuration,
tick,
"timed out waiting for ensuring impersonation policy")
}
func (i *Instance) CreateDeviceAuthorizationRequest(ctx context.Context, clientID string, scopes ...string) (*oidc.DeviceAuthorizationResponse, error) {
provider, err := i.CreateRelyingParty(ctx, clientID, "", scopes...)
if err != nil {
+27
View File
@@ -0,0 +1,27 @@
# Skills
Self-contained runbooks for manual verification of Zitadel features against a **local** test
instance. Each skill is a Markdown file describing what it verifies, what it needs and how to
run it, usually next to a script that does the work.
They are deliberately tool neutral: plain Markdown and plain shell, no assistant-specific
frontmatter or directory layout. Read one and follow it yourself, or hand it to whichever AI
assistant you use.
| Skill | Verifies |
| --- | --- |
| [test-actor-in-action-v2.md](test-actor-in-action-v2.md) | The impersonation actor is passed to Actions v2 execution targets and appears in the token claims. |
## Conventions for new skills
- **Local only.** A skill may create users, flip instance settings and delete things. Refuse to
run against anything but `localhost`, and enforce it in the script rather than only saying it
in the prose.
- **Ask first.** Print what will be created and require a confirmation, with a `--yes` escape
hatch for non-interactive use.
- **Clean up.** Remove what you created, including on failure, and restore settings you changed.
Offer a `--keep` flag for debugging.
- **Assume a clean slate.** Create the users, projects and clients you need instead of relying
on fixtures that happen to exist on the author's machine.
- **Fail loudly and usefully.** Turn Zitadel's error envelopes into messages that say what to do
next, and assert explicitly rather than leaving output for a human to eyeball.
+174
View File
@@ -0,0 +1,174 @@
# Verify the token actor reaches Actions v2 execution targets
> One of the runbooks in [`skills/`](README.md). Read [skills/README.md](README.md) first for the
> ground rules that apply to all of them: local instances only, ask before changing anything,
> clean up afterwards.
Impersonated tokens carry an *actor*: the user who performed the token exchange. Zitadel
exposes that actor to Actions, so a target can tell "this request is Bob acting as Alice"
apart from "this request is Alice".
This runbook proves the whole chain end to end against a local instance:
| Surface | What is checked |
| --- | --- |
| Token exchange | the exchanged access token and id token carry the `act` claim |
| `function/preaccesstoken` | the webhook payload carries `actor` when a JWT is minted |
| `function/preuserinfo` | the webhook payload carries `actor`, both when the id token is created and when `/oidc/v1/userinfo` is called |
| Negative control | a non impersonated token produces a payload with **no** `actor` key at all |
Relevant code: `ContextInfo.Actor` in `internal/api/oidc/userinfo.go`, populated from
`token.actor` (userinfo endpoint) and `session.Actor` (`internal/api/oidc/token.go`).
Actions v1 gets the same data as `ctx.v1.actor` via `internal/actions/object/token.go`.
## Prerequisites
- A Zitadel instance **already running locally** on the branch you want to test.
- An admin personal access token in a file, `admin.pat` in the repository root by default.
It must hold exactly the token: no quotes, no backticks, no extra lines.
- `curl` and `jq` on `PATH`.
- Outbound HTTPS to `webhook.site`, or see [Receiving webhooks locally](#receiving-webhooks-locally).
- The `oidcTokenExchange` instance feature enabled. The script checks this and prints the
command to enable it if it is off.
Everything else is created and removed by the script. No pre-existing impersonator, end user,
project or client is required.
## Safety
**Local test instances only.** The script refuses any `--url` whose host is not `localhost`,
`127.0.0.1` or `[::1]`, because it:
- enables impersonation instance wide (`enableImpersonation` in the security policy),
- creates a machine user holding `IAM_END_USER_IMPERSONATOR`,
- creates a human user, a project and an OIDC client.
It asks for confirmation before touching anything. If you are driving this with an AI
assistant, have it show you the plan and get your explicit go-ahead before it passes `--yes`.
By default everything created is deleted again on the way out, including on failure, and the
original `enableImpersonation` value is restored.
## Run it
```bash
./skills/test-actor-in-action-v2.sh
```
Options:
| Flag | Meaning |
| --- | --- |
| `--url URL` | instance to test, default `http://localhost:8080` |
| `--pat FILE` | admin PAT file, default `admin.pat` |
| `--local` | use a local HTTP sink instead of webhook.site, see below |
| `--keep` | keep everything that was created, for debugging |
| `--yes` | skip the confirmation prompt |
A successful run ends with:
```
==> Results
PASS access token sub is the impersonated user
PASS access token act.sub is the impersonator
PASS access token act.iss is the issuer
PASS id token sub is the impersonated user
PASS id token act.sub is the impersonator
PASS userinfo sub is the impersonated user
PASS function/preaccesstoken payload carries the actor
PASS both function/preuserinfo payloads carry the actor
PASS non-impersonated payload omits the actor key entirely
All assertions passed.
```
The exit status is non-zero if any assertion fails, and the received payloads are dumped so you
can see what actually arrived.
## What the payload looks like
Note the keys are snake_case, they come straight from `domain.TokenActor`:
```json
{
"function": "function/preuserinfo",
"userinfo": { "sub": "385694975679005038" },
"actor": {
"user_id": "385694975578341742",
"issuer": "http://localhost:8080"
}
}
```
`actor` is `omitempty`: for a normal, non impersonated token the key is absent entirely rather
than present and null. Existing targets therefore see no change in their payload.
For a delegation chain, `actor.actor` holds the previous actor.
## Receiving webhooks locally
`--local` starts a throwaway HTTP sink on `127.0.0.1` and points the target at it. This is only
usable if the instance was started with its SSRF guard relaxed: `HTTPClient.DenyList` in
`cmd/defaults.yaml` blocks `localhost` and the loopback and private CIDR ranges by default, and
the target is rejected with `Errors.Target.DeniedURL` otherwise.
To use it, start the instance with the deny list cleared, for example:
```bash
ZITADEL_HTTPCLIENT_DENYLIST= zitadel start-from-init --masterkeyFromEnv
```
Never do that on anything but a local test instance. Without that, use the default
webhook.site receiver; the script creates an anonymous token, uses it, reads it back through
the webhook.site API and deletes it again.
## Troubleshooting
| Symptom | Cause |
| --- | --- |
| `the PAT ... was rejected` | the PAT file has extra characters. A trailing backtick picked up from a copy-paste is the classic one, and only shows up much later as `token contains an invalid number of segments`. |
| `unsupported_grant_type` | the `oidcTokenExchange` instance feature is off. |
| `Errors.TokenExchange.Impersonation.PolicyDisabled` | `enableImpersonation` is off in the security policy. |
| `actor_token invalid` | the impersonator token is malformed or expired. |
| `Errors.Target.DeniedURL` | the endpoint is in `HTTPClient.DenyList`, see above. |
| `could not create a webhook.site token` | webhook.site rate limit, ten per minute for anonymous use. Wait a minute or use `--local`. |
| no payloads arrive | the instance cannot reach the endpoint. Check outbound network access. |
The token endpoint hides the underlying error by default. To see it, enable the
`debugOidcParentError` instance feature, reproduce, then turn it back off:
```bash
curl -X PUT http://localhost:8080/v2beta/features/instance \
-H "Authorization: Bearer $(cat admin.pat)" -H 'Content-Type: application/json' \
-d '{"debugOidcParentError": true}'
```
## Doing it by hand
If the script is broken or you want to poke at a single step, these are the two calls that
matter. Everything else is setup.
Impersonate by user id and ask for a JWT:
```bash
curl -X POST http://localhost:8080/oauth/v2/token \
-H 'Content-Type: application/x-www-form-urlencoded' \
-u "${CLIENT_ID}:${CLIENT_SECRET}" \
-d 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \
-d "subject_token=${END_USER_ID}" \
-d 'subject_token_type=urn:zitadel:params:oauth:token-type:user_id' \
-d "actor_token=${IMPERSONATOR_PAT}" \
-d 'actor_token_type=urn:ietf:params:oauth:token-type:access_token' \
-d 'requested_token_type=urn:ietf:params:oauth:token-type:jwt' \
-d 'scope=openid profile email'
```
Then use the returned token on the userinfo endpoint:
```bash
curl http://localhost:8080/oidc/v1/userinfo -H "Authorization: Bearer ${ACCESS_TOKEN}"
```
See [the token exchange guide](../apps/docs/content/guides/integrate/token-exchange.mdx) for the
other impersonation flavours, including impersonation with a real subject token and refreshing
an impersonated token.
+493
View File
@@ -0,0 +1,493 @@
#!/usr/bin/env bash
#
# test-actor-in-action-v2.sh
#
# End-to-end check that the impersonating actor is handed to Actions v2 execution
# targets. See skills/test-actor-in-action-v2.md for the full runbook.
#
# Safety: refuses to run against anything but a local Zitadel instance. It enables
# impersonation instance wide and creates users, a project and an OIDC client.
#
set -euo pipefail
URL="http://localhost:8080"
PAT_FILE="admin.pat"
RECEIVER="webhook.site"
KEEP=0
ASSUME_YES=0
usage() {
cat <<'EOF'
Usage: skills/test-actor-in-action-v2.sh [options]
--url URL Zitadel instance to test (default: http://localhost:8080).
Only localhost / 127.0.0.1 / [::1] are accepted.
--pat FILE File holding an admin personal access token (default: admin.pat).
--local Receive webhooks with a local HTTP sink instead of webhook.site.
Use this when offline or rate limited. Requires python3.
--keep Do not clean up the resources this script creates.
--yes Skip the interactive confirmation.
-h, --help Show this help.
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--url) URL="${2:?--url needs a value}"; shift 2 ;;
--pat) PAT_FILE="${2:?--pat needs a value}"; shift 2 ;;
--local) RECEIVER="local"; shift ;;
--keep) KEEP=1; shift ;;
--yes|-y) ASSUME_YES=1; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "unknown option: $1" >&2; usage >&2; exit 2 ;;
esac
done
URL="${URL%/}"
# ---------------------------------------------------------------- output helpers
RED=''; GREEN=''; YELLOW=''; BOLD=''; RESET=''
if [[ -t 1 ]]; then
RED=$'\033[31m'; GREEN=$'\033[32m'; YELLOW=$'\033[33m'; BOLD=$'\033[1m'; RESET=$'\033[0m'
fi
FAILURES=0
step() { printf '\n%s==> %s%s\n' "$BOLD" "$*" "$RESET"; }
info() { printf ' %s\n' "$*"; }
warn() { printf ' %sWARN%s %s\n' "$YELLOW" "$RESET" "$*"; }
die() { printf '\n%sERROR%s %s\n' "$RED" "$RESET" "$*" >&2; exit 1; }
pass() { printf ' %sPASS%s %s\n' "$GREEN" "$RESET" "$*"; }
fail() { printf ' %sFAIL%s %s\n' "$RED" "$RESET" "$*"; FAILURES=$((FAILURES + 1)); }
skip() { printf ' %sSKIP%s %s\n' "$YELLOW" "$RESET" "$*"; }
check_eq() { # check_eq <description> <expected> <actual>
if [[ "$2" == "$3" ]]; then pass "$1"; else fail "$1 (expected '$2', got '$3')"; fi
}
# ---------------------------------------------------------------- safety guards
# Only ever run this against a local test instance. It flips an instance wide
# security policy and creates users.
host="${URL#*://}"; host="${host%%/*}"; host="${host%%:*}"
case "$host" in
localhost|127.0.0.1|'[::1]'|::1) ;;
*) die "refusing to run against '$URL': only localhost, 127.0.0.1 and [::1] are allowed." ;;
esac
for dep in curl jq; do
command -v "$dep" >/dev/null 2>&1 || die "missing required dependency: $dep"
done
if [[ "$RECEIVER" == "local" ]]; then
command -v python3 >/dev/null 2>&1 || die "--local requires python3"
fi
[[ -f "$PAT_FILE" ]] || die "PAT file '$PAT_FILE' not found. Pass --pat FILE or create it (see the runbook)."
# Strip whitespace, quotes and backticks. Copying a PAT out of chat or markdown
# very easily drags a stray backtick along, which surfaces much later as an
# opaque "token contains an invalid number of segments".
ADMIN_PAT="$(tr -d ' \t\r\n`"'"'" < "$PAT_FILE")"
[[ -n "$ADMIN_PAT" ]] || die "PAT file '$PAT_FILE' is empty"
if [[ $ASSUME_YES -ne 1 ]]; then
cat <<EOF
${BOLD}This will modify the Zitadel instance at ${URL}${RESET}
- enable impersonation in the instance security policy
- create a machine user (impersonator) with a PAT and IAM_END_USER_IMPERSONATOR
- create a human user to impersonate
- create a project and an OIDC client with the token exchange grant
- create an Actions v2 target and two executions
- receive webhooks via: ${RECEIVER}
EOF
if [[ $KEEP -eq 1 ]]; then
echo " - keep everything afterwards (--keep)"
else
echo " - delete all of the above afterwards"
fi
echo
printf 'Continue? [y/N] '
read -r reply < /dev/tty 2>/dev/null || reply=""
case "$reply" in [yY]|[yY][eE][sS]) ;; *) die "aborted by user; nothing was created." ;; esac
fi
# ---------------------------------------------------------------- api helpers
RUN_ID="$$-$(date +%s)"
api() { # api <method> <path> [json-body]
local method="$1" path="$2" body="${3:-}"
local args=(-sS -X "$method" "$URL$path"
-H "Authorization: Bearer $ADMIN_PAT"
-H 'Content-Type: application/json'
-H 'Accept: application/json')
[[ -n "$body" ]] && args+=(--data "$body")
curl "${args[@]}"
}
api_ok() { # api_ok <method> <path> [json-body] -- dies on a Zitadel error envelope
local out
out="$(api "$@")"
if jq -e 'type == "object" and has("code") and has("message")' >/dev/null 2>&1 <<<"$out"; then
die "$1 $2 failed: $(jq -c . <<<"$out")"
fi
printf '%s' "$out"
}
jwt_payload() { # jwt_payload <jwt> -- prints the decoded payload as JSON
local seg pad
seg="${1#*.}"; seg="${seg%%.*}"
pad=$(( (4 - ${#seg} % 4) % 4 ))
while (( pad-- > 0 )); do seg="${seg}="; done
printf '%s' "$seg" | tr '_-' '/+' | base64 -d 2>/dev/null
}
# ---------------------------------------------------------------- cleanup
CLEAN_EXECUTIONS=0
TARGET_ID=""
PROJECT_ID=""
IMPERSONATOR_ID=""
ENDUSER_ID=""
WEBHOOK_TOKEN=""
SINK_PID=""
SINK_DIR=""
IMPERSONATION_WAS=""
cleanup() {
local code=$?
set +e
if [[ -n "$SINK_PID" ]]; then kill "$SINK_PID" >/dev/null 2>&1; wait "$SINK_PID" 2>/dev/null; fi
if [[ $KEEP -eq 1 ]]; then
step "Leaving resources in place (--keep)"
info "project: ${PROJECT_ID:-}"
info "impersonator: ${IMPERSONATOR_ID:-}"
info "end user: ${ENDUSER_ID:-}"
info "target: ${TARGET_ID:-}"
[[ -n "$WEBHOOK_TOKEN" ]] && info "webhook.site: https://webhook.site/#!/view/$WEBHOOK_TOKEN"
[[ -n "$SINK_DIR" ]] && info "sink log: $SINK_DIR/payloads.jsonl"
exit "$code"
fi
step "Cleaning up"
if [[ $CLEAN_EXECUTIONS -eq 1 ]]; then
# There is no DeleteExecution endpoint; setting an empty target list removes it.
for fn in preuserinfo preaccesstoken; do
api PUT /v2/actions/executions "{\"condition\":{\"function\":{\"name\":\"$fn\"}},\"targets\":[]}" >/dev/null
done
info "removed executions"
fi
[[ -n "$TARGET_ID" ]] && api DELETE "/v2/actions/targets/$TARGET_ID" >/dev/null && info "removed target"
[[ -n "$PROJECT_ID" ]] && api DELETE "/management/v1/projects/$PROJECT_ID" >/dev/null && info "removed project and client"
[[ -n "$IMPERSONATOR_ID" ]] && api DELETE "/v2/users/$IMPERSONATOR_ID" >/dev/null && info "removed impersonator"
[[ -n "$ENDUSER_ID" ]] && api DELETE "/v2/users/$ENDUSER_ID" >/dev/null && info "removed end user"
if [[ -n "$IMPERSONATION_WAS" ]]; then
api PUT /admin/v1/policies/security "{\"enableImpersonation\":$IMPERSONATION_WAS}" >/dev/null
info "restored enableImpersonation=$IMPERSONATION_WAS"
fi
if [[ -n "$WEBHOOK_TOKEN" ]]; then
curl -sS -X DELETE "https://webhook.site/token/$WEBHOOK_TOKEN" >/dev/null 2>&1
info "removed webhook.site token"
fi
[[ -n "$SINK_DIR" ]] && rm -rf "$SINK_DIR"
exit "$code"
}
trap cleanup EXIT
# ---------------------------------------------------------------- preflight
step "Preflight"
whoami_json="$(api GET /auth/v1/users/me)"
if ! jq -e '.user.id' >/dev/null 2>&1 <<<"$whoami_json"; then
die "the PAT in '$PAT_FILE' was rejected by $URL: $(jq -c . <<<"$whoami_json" 2>/dev/null || printf '%s' "$whoami_json")
Check that the file holds exactly the token, with no surrounding quotes, backticks or extra lines."
fi
info "authenticated as $(jq -r '.user.userName' <<<"$whoami_json")"
ISSUER="$(curl -sS "$URL/.well-known/openid-configuration" | jq -r '.issuer')"
[[ -n "$ISSUER" && "$ISSUER" != "null" ]] || die "could not read the issuer from $URL/.well-known/openid-configuration"
info "issuer $ISSUER"
features="$(api GET /v2beta/features/instance)"
if [[ "$(jq -r '.oidcTokenExchange.enabled // false' <<<"$features")" != "true" ]]; then
die "the oidcTokenExchange instance feature is disabled. Enable it with:
curl -X PUT $URL/v2beta/features/instance \\
-H \"Authorization: Bearer \\\$(cat $PAT_FILE)\" -H 'Content-Type: application/json' \\
-d '{\"oidcTokenExchange\": true}'"
fi
info "oidcTokenExchange feature enabled"
# ---------------------------------------------------------------- receiver
step "Setting up the webhook receiver ($RECEIVER)"
if [[ "$RECEIVER" == "local" ]]; then
SINK_DIR="$(mktemp -d)"
python3 -u -c '
import http.server, sys, threading
out = sys.argv[1]
lock = threading.Lock()
class Handler(http.server.BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def do_POST(self):
length = int(self.headers.get("Content-Length") or 0)
body = self.rfile.read(length)
with lock, open(out, "ab") as fh:
fh.write(body.replace(b"\n", b"") + b"\n")
self.send_response(200)
self.send_header("Content-Length", "0")
self.end_headers()
def log_message(self, *args):
pass
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
print(server.server_address[1], flush=True)
server.serve_forever()
' "$SINK_DIR/payloads.jsonl" > "$SINK_DIR/port" &
SINK_PID=$!
for _ in $(seq 1 50); do
[[ -s "$SINK_DIR/port" ]] && break
sleep 0.1
done
SINK_PORT="$(cat "$SINK_DIR/port")"
[[ -n "$SINK_PORT" ]] || die "the local sink failed to start"
ENDPOINT="http://127.0.0.1:$SINK_PORT"
receiver_payloads() { [[ -f "$SINK_DIR/payloads.jsonl" ]] && cat "$SINK_DIR/payloads.jsonl" || true; }
else
token_json="$(curl -sS -X POST https://webhook.site/token -H 'Content-Type: application/json' -d '{}')"
WEBHOOK_TOKEN="$(jq -r '.uuid // empty' <<<"$token_json")"
[[ -n "$WEBHOOK_TOKEN" ]] || die "could not create a webhook.site token (rate limited?): $token_json
Retry in a minute or run with --local."
ENDPOINT="https://webhook.site/$WEBHOOK_TOKEN"
receiver_payloads() {
curl -sS "https://webhook.site/token/$WEBHOOK_TOKEN/requests?sorting=oldest&per_page=50" \
| jq -r '.data[]?.content // empty'
}
info "inbox https://webhook.site/#!/view/$WEBHOOK_TOKEN"
fi
info "endpoint $ENDPOINT"
# ---------------------------------------------------------------- setup
step "Configuring the instance"
IMPERSONATION_WAS="$(api GET /admin/v1/policies/security | jq -r '.policy.enableImpersonation // false')"
api_ok PUT /admin/v1/policies/security '{"enableImpersonation":true}' >/dev/null
info "enableImpersonation true (was $IMPERSONATION_WAS)"
IMPERSONATOR_NAME="actor-test-impersonator-$RUN_ID"
IMPERSONATOR_ID="$(api_ok POST /management/v1/users/machine \
"{\"userName\":\"$IMPERSONATOR_NAME\",\"name\":\"actor test impersonator\",\"description\":\"created by skills/test-actor-in-action-v2.sh\",\"accessTokenType\":\"ACCESS_TOKEN_TYPE_BEARER\"}" \
| jq -r '.userId')"
info "impersonator $IMPERSONATOR_ID ($IMPERSONATOR_NAME)"
IMPERSONATOR_PAT="$(api_ok POST "/management/v1/users/$IMPERSONATOR_ID/pats" '{}' | jq -r '.token')"
[[ -n "$IMPERSONATOR_PAT" && "$IMPERSONATOR_PAT" != "null" ]] || die "could not create a PAT for the impersonator"
# IAM_END_USER_IMPERSONATOR carries the "impersonation" permission that
# CreateOIDCSession checks (internal/command/oidc_session.go).
api_ok POST /admin/v1/members \
"{\"userId\":\"$IMPERSONATOR_ID\",\"roles\":[\"IAM_END_USER_IMPERSONATOR\"]}" >/dev/null
info "granted IAM_END_USER_IMPERSONATOR"
ENDUSER_NAME="actor-test-enduser-$RUN_ID"
ENDUSER_ID="$(api_ok POST /v2/users/human "$(jq -n --arg u "$ENDUSER_NAME" '{
username: $u,
profile: {givenName: "Actor", familyName: "Test"},
email: {email: ($u + "@example.com"), isVerified: true}
}')" | jq -r '.userId')"
info "end user $ENDUSER_ID ($ENDUSER_NAME)"
# The client must not live in the ZITADEL project: impersonated tokens are
# rejected on the Zitadel API itself.
PROJECT_ID="$(api_ok POST /management/v1/projects "{\"name\":\"actor-test-$RUN_ID\"}" | jq -r '.id')"
app_json="$(api_ok POST "/management/v1/projects/$PROJECT_ID/apps/oidc" '{
"name": "actor-test-client",
"redirectUris": ["https://example.com/callback"],
"responseTypes": ["OIDC_RESPONSE_TYPE_CODE"],
"grantTypes": ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE", "OIDC_GRANT_TYPE_TOKEN_EXCHANGE"],
"appType": "OIDC_APP_TYPE_WEB",
"authMethodType": "OIDC_AUTH_METHOD_TYPE_BASIC",
"accessTokenType": "OIDC_TOKEN_TYPE_BEARER",
"devMode": true
}')"
CLIENT_ID="$(jq -r '.clientId' <<<"$app_json")"
CLIENT_SECRET="$(jq -r '.clientSecret' <<<"$app_json")"
info "project $PROJECT_ID, client $CLIENT_ID"
target_json="$(api POST /v2/actions/targets "$(jq -n --arg e "$ENDPOINT" --arg n "actor-test-$RUN_ID" '{
name: $n, restWebhook: {interruptOnError: false}, timeout: "10s", endpoint: $e
}')")"
if [[ "$(jq -r '.message // empty' <<<"$target_json")" == *DeniedURL* ]]; then
if [[ "$RECEIVER" == "local" ]]; then
die "the instance refuses $ENDPOINT as a target.
Zitadel's SSRF guard denies loopback and private ranges by default
(HTTPClient.DenyList in cmd/defaults.yaml). To use --local, restart the
instance with the deny list relaxed, for example:
ZITADEL_HTTPCLIENT_DENYLIST= zitadel start-from-init ...
Otherwise drop --local and use the webhook.site receiver."
fi
die "the instance refuses $ENDPOINT as a target: $(jq -c . <<<"$target_json")"
fi
TARGET_ID="$(jq -r '.id // empty' <<<"$target_json")"
[[ -n "$TARGET_ID" ]] || die "could not create the target: $(jq -c . <<<"$target_json")"
info "target $TARGET_ID"
for fn in preuserinfo preaccesstoken; do
api_ok PUT /v2/actions/executions \
"{\"condition\":{\"function\":{\"name\":\"$fn\"}},\"targets\":[\"$TARGET_ID\"]}" >/dev/null
done
CLEAN_EXECUTIONS=1
info "executions function/preuserinfo and function/preaccesstoken -> target"
# ---------------------------------------------------------------- exercise
step "Impersonating $ENDUSER_ID via token exchange"
exchange="$(curl -sS -X POST "$URL/oauth/v2/token" \
-H 'Content-Type: application/x-www-form-urlencoded' -H 'Accept: application/json' \
-u "$CLIENT_ID:$CLIENT_SECRET" \
--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \
--data-urlencode "subject_token=$ENDUSER_ID" \
--data-urlencode 'subject_token_type=urn:zitadel:params:oauth:token-type:user_id' \
--data-urlencode "actor_token=$IMPERSONATOR_PAT" \
--data-urlencode 'actor_token_type=urn:ietf:params:oauth:token-type:access_token' \
--data-urlencode 'requested_token_type=urn:ietf:params:oauth:token-type:jwt' \
--data-urlencode 'scope=openid profile email')"
if [[ "$(jq -r '.error // empty' <<<"$exchange")" != "" ]]; then
die "token exchange failed: $(jq -c . <<<"$exchange")
See the troubleshooting section of skills/test-actor-in-action-v2.md."
fi
ACCESS_TOKEN="$(jq -r '.access_token' <<<"$exchange")"
ID_TOKEN="$(jq -r '.id_token // empty' <<<"$exchange")"
at_claims="$(jwt_payload "$ACCESS_TOKEN")"
info "issued_token_type $(jq -r '.issued_token_type' <<<"$exchange")"
step "Calling the userinfo endpoint with the impersonated token"
userinfo="$(curl -sS "$URL/oidc/v1/userinfo" -H "Authorization: Bearer $ACCESS_TOKEN")"
info "sub $(jq -r '.sub // "none"' <<<"$userinfo")"
step "Negative control: userinfo without impersonation"
CONTROL_RAN=0
control_secret="$(api POST "/v2/users/$IMPERSONATOR_ID/secret" '{}' | jq -r '.clientSecret // empty')"
if [[ -n "$control_secret" ]]; then
# client_credentials resolves the client by login name, not by user id
# (see clientCredentialsAuth in internal/api/oidc/client_credentials.go).
control_login="$(api GET "/v2/users/$IMPERSONATOR_ID" | jq -r '.user.loginNames[0] // empty')"
control_token="$(curl -sS -X POST "$URL/oauth/v2/token" \
-H 'Content-Type: application/x-www-form-urlencoded' -H 'Accept: application/json' \
-u "$control_login:$control_secret" \
--data-urlencode 'grant_type=client_credentials' \
--data-urlencode "scope=openid urn:zitadel:iam:org:project:id:$PROJECT_ID:aud" \
| jq -r '.access_token // empty')"
if [[ -n "$control_token" ]]; then
control_userinfo="$(curl -sS "$URL/oidc/v1/userinfo" -H "Authorization: Bearer $control_token")"
if [[ "$(jq -r '.sub // empty' <<<"$control_userinfo")" == "$IMPERSONATOR_ID" ]]; then
CONTROL_RAN=1
info "control userinfo returned the impersonator's own sub"
else
warn "control userinfo call did not return the expected subject: $control_userinfo"
fi
else
warn "could not obtain a client_credentials token for the control"
fi
else
warn "could not create a machine secret for the control"
fi
# ---------------------------------------------------------------- collect
step "Waiting for webhook payloads"
# Count payloads per function that carry the expected actor. The JSON keys come
# from domain.TokenActor, so they are snake_case: user_id / issuer.
count_with_actor() { # count_with_actor <function> <payloads>
jq -s --arg fn "$1" --arg uid "$IMPERSONATOR_ID" --arg iss "$ISSUER" \
'[.[] | select(.function == $fn and .actor.user_id == $uid and .actor.issuer == $iss)] | length' \
<<<"$2"
}
count_without_actor() { # count_without_actor <payloads>
jq -s --arg uid "$IMPERSONATOR_ID" \
'[.[] | select(.function == "function/preuserinfo" and (has("actor") | not) and .userinfo.sub == $uid)] | length' \
<<<"$1"
}
# Poll for the payloads we actually assert on rather than for a raw count: the
# negative control emits payloads too, so a total would be satisfied too early.
payloads=""
for i in $(seq 1 30); do
payloads="$(receiver_payloads)"
if [[ -n "$payloads" ]]; then
[[ "$(count_with_actor function/preaccesstoken "$payloads")" -ge 1 &&
"$(count_with_actor function/preuserinfo "$payloads")" -ge 2 &&
( $CONTROL_RAN -eq 0 || "$(count_without_actor "$payloads")" -ge 1 ) ]] && break
fi
sleep 1
done
count="$(grep -c . <<<"$payloads" || true)"
info "received $count payload(s) after ${i}s"
if [[ "$count" -eq 0 ]]; then
die "no payloads reached the receiver. Is the instance able to reach $ENDPOINT?"
fi
# ---------------------------------------------------------------- assertions
step "Results"
check_eq "access token sub is the impersonated user" \
"$ENDUSER_ID" "$(jq -r '.sub // "none"' <<<"$at_claims")"
check_eq "access token act.sub is the impersonator" \
"$IMPERSONATOR_ID" "$(jq -r '.act.sub // "none"' <<<"$at_claims")"
check_eq "access token act.iss is the issuer" \
"$ISSUER" "$(jq -r '.act.iss // "none"' <<<"$at_claims")"
if [[ -n "$ID_TOKEN" ]]; then
id_claims="$(jwt_payload "$ID_TOKEN")"
check_eq "id token sub is the impersonated user" \
"$ENDUSER_ID" "$(jq -r '.sub // "none"' <<<"$id_claims")"
check_eq "id token act.sub is the impersonator" \
"$IMPERSONATOR_ID" "$(jq -r '.act.sub // "none"' <<<"$id_claims")"
else
fail "the token exchange response carried no id_token"
fi
check_eq "userinfo sub is the impersonated user" \
"$ENDUSER_ID" "$(jq -r '.sub // "none"' <<<"$userinfo")"
check_eq "function/preaccesstoken payload carries the actor" \
"true" "$([[ "$(count_with_actor function/preaccesstoken "$payloads")" -ge 1 ]] && echo true || echo false)"
check_eq "both function/preuserinfo payloads carry the actor" \
"true" "$([[ "$(count_with_actor function/preuserinfo "$payloads")" -ge 2 ]] && echo true || echo false)"
if [[ $CONTROL_RAN -eq 1 ]]; then
check_eq "non-impersonated payload omits the actor key entirely" \
"true" "$([[ "$(count_without_actor "$payloads")" -ge 1 ]] && echo true || echo false)"
else
skip "negative control (could not mint a non-impersonated token)"
fi
if [[ $FAILURES -gt 0 ]]; then
step "Payloads received"
jq -c '{function, sub: .userinfo.sub, actor}' <<<"$payloads" || printf '%s\n' "$payloads"
printf '\n%sFAILED%s: %d assertion(s)\n' "$RED" "$RESET" "$FAILURES"
exit 1
fi
printf '\n%sAll assertions passed.%s\n' "$GREEN" "$RESET"