mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00:00
# 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]>
175 lines
7.1 KiB
Markdown
175 lines
7.1 KiB
Markdown
# 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.
|