Files
zitadel/skills/test-actor-in-action-v2.md
6127b567e7 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

- 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]>
2026-08-12 07:36:56 +00:00

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.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.
  • 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

./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.