From 6127b567e77dba46118d03c2293d64c4a341832e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tim=20M=C3=B6hlmann?= Date: Wed, 12 Aug 2026 09:36:56 +0200 Subject: [PATCH] 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 Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Wim Van Laer --- .gitignore | 3 +- .../content/apis/actions/complement-token.mdx | 10 + apps/docs/content/apis/actions/objects.mdx | 13 + .../integrate/actions/testing-function.mdx | 6 + .../guides/integrate/actions/usage.mdx | 32 +- internal/actions/object/token.go | 43 ++ .../integration_test/execution_target_test.go | 90 +++- .../integration_test/token_exchange_test.go | 27 +- internal/api/oidc/introspect.go | 2 +- internal/api/oidc/token.go | 10 +- internal/api/oidc/userinfo.go | 288 ++++++---- internal/api/oidc/userinfo_test.go | 300 +++++++++++ internal/integration/oidc.go | 29 ++ skills/README.md | 27 + skills/test-actor-in-action-v2.md | 174 +++++++ skills/test-actor-in-action-v2.sh | 493 ++++++++++++++++++ 16 files changed, 1406 insertions(+), 141 deletions(-) create mode 100644 internal/actions/object/token.go create mode 100644 skills/README.md create mode 100644 skills/test-actor-in-action-v2.md create mode 100755 skills/test-actor-in-action-v2.sh diff --git a/.gitignore b/.gitignore index ea1d19b54..75f6f2799 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/apps/docs/content/apis/actions/complement-token.mdx b/apps/docs/content/apis/actions/complement-token.mdx index b01c803bc..8a7565f57 100644 --- a/apps/docs/content/apis/actions/complement-token.mdx +++ b/apps/docs/content/apis/actions/complement-token.mdx @@ -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` diff --git a/apps/docs/content/apis/actions/objects.mdx b/apps/docs/content/apis/actions/objects.mdx index 834b46b9a..b14da1af4 100644 --- a/apps/docs/content/apis/actions/objects.mdx +++ b/apps/docs/content/apis/actions/objects.mdx @@ -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. diff --git a/apps/docs/content/guides/integrate/actions/testing-function.mdx b/apps/docs/content/guides/integrate/actions/testing-function.mdx index 1b7991a5d..a886699e1 100644 --- a/apps/docs/content/guides/integrate/actions/testing-function.mdx +++ b/apps/docs/content/guides/integrate/actions/testing-function.mdx @@ -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 diff --git a/apps/docs/content/guides/integrate/actions/usage.mdx b/apps/docs/content/guides/integrate/actions/usage.mdx index 0cc4364ba..844249136 100644 --- a/apps/docs/content/guides/integrate/actions/usage.mdx +++ b/apps/docs/content/guides/integrate/actions/usage.mdx @@ -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 diff --git a/internal/actions/object/token.go b/internal/actions/object/token.go new file mode 100644 index 000000000..c22e578ef --- /dev/null +++ b/internal/actions/object/token.go @@ -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 +} diff --git a/internal/api/grpc/action/v2/integration_test/execution_target_test.go b/internal/api/grpc/action/v2/integration_test/execution_target_test.go index 4fe7448aa..658a39fb5 100644 --- a/internal/api/grpc/action/v2/integration_test/execution_target_test.go +++ b/internal/api/grpc/action/v2/integration_test/execution_target_test.go @@ -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) diff --git a/internal/api/oidc/integration_test/token_exchange_test.go b/internal/api/oidc/integration_test/token_exchange_test.go index dfba1b8ed..7ba587f50 100644 --- a/internal/api/oidc/integration_test/token_exchange_test.go +++ b/internal/api/oidc/integration_test/token_exchange_test.go @@ -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) { diff --git a/internal/api/oidc/introspect.go b/internal/api/oidc/introspect.go index d451a0aa3..a1ca6afba 100644 --- a/internal/api/oidc/introspect.go +++ b/internal/api/oidc/introspect.go @@ -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 } diff --git a/internal/api/oidc/token.go b/internal/api/oidc/token.go index 41e3f21f1..c9f899def 100644 --- a/internal/api/oidc/token.go +++ b/internal/api/oidc/token.go @@ -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 } diff --git a/internal/api/oidc/userinfo.go b/internal/api/oidc/userinfo.go index ca3607f3d..a098677a1 100644 --- a/internal/api/oidc/userinfo.go +++ b/internal/api/oidc/userinfo.go @@ -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"` diff --git a/internal/api/oidc/userinfo_test.go b/internal/api/oidc/userinfo_test.go index 2bb8556b2..211f106b4 100644 --- a/internal/api/oidc/userinfo_test.go +++ b/internal/api/oidc/userinfo_test.go @@ -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") +} diff --git a/internal/integration/oidc.go b/internal/integration/oidc.go index 96af6c2be..3ac9c2a84 100644 --- a/internal/integration/oidc.go +++ b/internal/integration/oidc.go @@ -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 { diff --git a/skills/README.md b/skills/README.md new file mode 100644 index 000000000..a7ad1ffbc --- /dev/null +++ b/skills/README.md @@ -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. diff --git a/skills/test-actor-in-action-v2.md b/skills/test-actor-in-action-v2.md new file mode 100644 index 000000000..28f48e85b --- /dev/null +++ b/skills/test-actor-in-action-v2.md @@ -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. diff --git a/skills/test-actor-in-action-v2.sh b/skills/test-actor-in-action-v2.sh new file mode 100755 index 000000000..403b4b7a1 --- /dev/null +++ b/skills/test-actor-in-action-v2.sh @@ -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 + 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 </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 [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 [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 -- 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 + 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 + 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"