# 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 - dba6261c8ed0dfd00ffde6193910b1405b17432f: 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 <[email protected]> Co-authored-by: Copilot Autofix powered by AI <[email protected]> Co-authored-by: Wim Van Laer <[email protected]>
7.1 KiB
Verify the token actor reaches Actions v2 execution targets
One of the runbooks in
skills/. Read skills/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.patin the repository root by default. It must hold exactly the token: no quotes, no backticks, no extra lines. curlandjqonPATH.- Outbound HTTPS to
webhook.site, or see Receiving webhooks locally. - The
oidcTokenExchangeinstance 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 (
enableImpersonationin 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
./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:
{
"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:
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:
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:
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:
curl http://localhost:8080/oidc/v1/userinfo -H "Authorization: Bearer ${ACCESS_TOKEN}"
See the token exchange guide for the other impersonation flavours, including impersonation with a real subject token and refreshing an impersonated token.