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]>
This commit is contained in:
Tim Möhlmann
2026-08-12 07:36:56 +00:00
committed by GitHub
co-authored by Claude Sonnet 5 Copilot Autofix powered by AI Wim Van Laer
parent 790244dd49
commit 6127b567e7
16 changed files with 1406 additions and 141 deletions
+43
View File
@@ -0,0 +1,43 @@
package object
import (
"github.com/dop251/goja"
"github.com/zitadel/zitadel/internal/actions"
"github.com/zitadel/zitadel/internal/domain"
)
// TokenActorField accepts a domain.TokenActor pointer and copies its content so scripts can't mutate the domain object.
func TokenActorField(actor *domain.TokenActor) func(c *actions.FieldConfig) interface{} {
return func(c *actions.FieldConfig) interface{} {
return TokenActorFromDomain(c, actor)
}
}
// TokenActorFromDomain returns the actor of a token, in case it was obtained through
// token exchange or impersonation. It returns null when there is no actor.
func TokenActorFromDomain(c *actions.FieldConfig, actor *domain.TokenActor) goja.Value {
if actor == nil {
return c.Runtime.ToValue(nil)
}
return c.Runtime.ToValue(tokenActorFromDomain(actor))
}
// tokenActorFromDomain copies the delegation chain, so scripts can't mutate the domain object.
func tokenActorFromDomain(actor *domain.TokenActor) *tokenActor {
if actor == nil {
return nil
}
return &tokenActor{
Actor: tokenActorFromDomain(actor.Actor),
UserId: actor.UserID,
Issuer: actor.Issuer,
}
}
type tokenActor struct {
// Actor is the previous actor in the delegation chain, null if there is none.
Actor *tokenActor
UserId string
Issuer string
}
@@ -16,6 +16,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/oidc/v3/pkg/client/rp"
"github.com/zitadel/oidc/v3/pkg/client/tokenexchange"
"github.com/zitadel/oidc/v3/pkg/oidc"
"github.com/zitadel/oidc/v3/pkg/op"
"golang.org/x/text/language"
@@ -781,7 +782,7 @@ func expectPreUserinfoExecution(ctx context.Context, t *testing.T, instance *int
SessionToken: sessionResp.GetSessionToken(),
},
}
expectedContextInfo := contextInfoForUserOIDC(instance, "function/preuserinfo", clientID, userResp, userEmail, userPhone)
expectedContextInfo := contextInfoForUserOIDC(instance, "function/preuserinfo", clientID, userResp, userEmail, userPhone, nil)
targetURL, closeF, _, _ := integration.TestServerCall(expectedContextInfo, 0, http.StatusOK, response)
@@ -859,7 +860,9 @@ func getAccessTokenClaims(ctx context.Context, t *testing.T, instance *integrati
return claims
}
func contextInfoForUserOIDC(instance *integration.Instance, function string, clientID string, userResp *user.AddHumanUserResponse, email, phone string) *oidc_api.ContextInfo {
// contextInfoForUserOIDC builds the payload a target is expected to receive. actor is nil for
// regular flows and only set when the token was obtained through impersonation.
func contextInfoForUserOIDC(instance *integration.Instance, function string, clientID string, userResp *user.AddHumanUserResponse, email, phone string, actor *domain.TokenActor) *oidc_api.ContextInfo {
return &oidc_api.ContextInfo{
Function: function,
UserInfo: &oidc.UserInfo{
@@ -901,6 +904,7 @@ func contextInfoForUserOIDC(instance *integration.Instance, function string, cli
PrimaryDomain: instance.DefaultOrg.GetPrimaryDomain(),
},
UserGrants: nil,
Actor: actor,
Response: nil,
}
}
@@ -1089,7 +1093,7 @@ func expectPreAccessTokenExecution(ctx context.Context, t *testing.T, instance *
SessionToken: sessionResp.GetSessionToken(),
},
}
expectedContextInfo := contextInfoForUserOIDC(instance, "function/preaccesstoken", clientID, userResp, userEmail, userPhone)
expectedContextInfo := contextInfoForUserOIDC(instance, "function/preaccesstoken", clientID, userResp, userEmail, userPhone, nil)
targetURL, closeF, _, _ := integration.TestServerCall(expectedContextInfo, 0, http.StatusOK, response)
@@ -1098,6 +1102,86 @@ func expectPreAccessTokenExecution(ctx context.Context, t *testing.T, instance *
return userResp.GetUserId(), closeF
}
// TestServer_ExecutionTargetPreUserinfoImpersonation checks that a target called for the
// preuserinfo function is told who impersonated the user.
func TestServer_ExecutionTargetPreUserinfoImpersonation(t *testing.T) {
testExecutionTargetImpersonation(t, "preuserinfo")
}
// TestServer_ExecutionTargetPreAccessTokenImpersonation checks the same for the
// preaccesstoken function.
func TestServer_ExecutionTargetPreAccessTokenImpersonation(t *testing.T) {
testExecutionTargetImpersonation(t, "preaccesstoken")
}
// testExecutionTargetImpersonation obtains a token through impersonation and asserts that the
// target registered for function receives the actor of that token.
//
// The counterpart is covered by TestServer_ExecutionTargetPreUserinfo and
// TestServer_ExecutionTargetPreAccessToken: their expected ContextInfo has no actor, and since
// the target server compares the received body byte for byte, they would fail if an actor was
// sent for a token that was not impersonated.
func testExecutionTargetImpersonation(t *testing.T, function string) {
instance := integration.NewInstance(CTX)
isolatedIAMCtx := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner)
instance.SetImpersonationPolicy(CTX, t, true)
// the user being impersonated, created the same way as in the non impersonated tests so
// that contextInfoForUserOIDC describes it correctly
userEmail := integration.Email()
userPhone := integration.Phone()
userResp := instance.CreateHumanUserVerified(isolatedIAMCtx, instance.DefaultOrg.Id, userEmail, userPhone)
// the impersonator, whose PAT is passed as actor token
impersonatorID, impersonatorPAT, err := instance.CreateMachineUserPATWithMembership(isolatedIAMCtx, "IAM_END_USER_IMPERSONATOR")
require.NoError(t, err)
client, keyData, err := instance.CreateOIDCTokenExchangeClient(isolatedIAMCtx, t)
require.NoError(t, err)
signer, err := rp.SignerFromKeyFile(keyData)()
require.NoError(t, err)
exchanger, err := tokenexchange.NewTokenExchangerJWTProfile(isolatedIAMCtx, instance.OIDCIssuer(), client.GetClientId(), signer)
require.NoError(t, err)
expectedContextInfo := contextInfoForUserOIDC(instance, "function/"+function, client.GetClientId(), userResp, userEmail, userPhone,
&domain.TokenActor{
UserID: impersonatorID,
Issuer: instance.OIDCIssuer(),
},
)
response := &oidc_api.ContextInfoResponse{
AppendClaims: []*oidc_api.AppendClaim{
{Key: "added", Value: "value"},
},
}
targetURL, closeF, calledF, _ := integration.TestServerCall(expectedContextInfo, 0, http.StatusOK, response)
defer closeF()
// interrupt on error, so a payload that does not match the expectation fails the token
// exchange below instead of passing silently
targetResp := waitForTarget(isolatedIAMCtx, t, instance, targetURL, target_domain.TargetTypeCall, true, action.PayloadType_PAYLOAD_TYPE_JSON)
waitForExecutionOnCondition(isolatedIAMCtx, t, instance, conditionFunction(function), []string{targetResp.GetId()})
// Impersonate by user ID. A JWT is requested so that the access token is signed by
// createJWT, which is what triggers preaccesstoken. The openid scope produces an ID token,
// which is what triggers preuserinfo. Only one of the two has an execution registered.
tokens, err := tokenexchange.ExchangeToken(isolatedIAMCtx, exchanger,
userResp.GetUserId(), oidc_api.UserIDTokenType,
impersonatorPAT, oidc.AccessTokenType,
nil, nil, []string{oidc.ScopeOpenID}, oidc.JWTTokenType,
)
require.NoError(t, err)
assert.Equal(t, 1, calledF())
verifier := op.NewAccessTokenVerifier(instance.OIDCIssuer(), rp.NewRemoteKeySet(http.DefaultClient, instance.OIDCIssuer()+"/oauth/v2/keys"))
claims, err := op.VerifyAccessToken[*oidc.AccessTokenClaims](isolatedIAMCtx, tokens.AccessToken, verifier)
require.NoError(t, err)
assert.Equal(t, userResp.GetUserId(), claims.Subject)
require.NotNil(t, claims.Actor)
assert.Equal(t, impersonatorID, claims.Actor.Subject)
}
func TestServer_ExecutionTargetPreSAMLResponse(t *testing.T) {
instance := integration.NewInstance(CTX)
isolatedIAMCtx := instance.WithAuthorizationToken(CTX, integration.UserTypeIAMOwner)
@@ -27,32 +27,7 @@ import (
)
func setImpersonationPolicy(t *testing.T, instance *integration.Instance, value bool) {
iamCTX := instance.WithAuthorization(CTX, integration.UserTypeIAMOwner)
policy, err := instance.Client.Admin.GetSecurityPolicy(iamCTX, &admin.GetSecurityPolicyRequest{})
require.NoError(t, err)
if policy.GetPolicy().GetEnableImpersonation() != value {
_, err = instance.Client.Admin.SetSecurityPolicy(iamCTX, &admin.SetSecurityPolicyRequest{
EnableImpersonation: value,
})
require.NoError(t, err)
}
retryDuration := time.Minute
if ctxDeadline, ok := iamCTX.Deadline(); ok {
retryDuration = time.Until(ctxDeadline)
}
require.EventuallyWithT(t,
func(ttt *assert.CollectT) {
f, err := instance.Client.Admin.GetSecurityPolicy(iamCTX, &admin.GetSecurityPolicyRequest{})
assert.NoError(ttt, err)
if f.GetPolicy().GetEnableImpersonation() != value {
return
}
},
retryDuration,
time.Second,
"timed out waiting for ensuring impersonation policy")
instance.SetImpersonationPolicy(CTX, t, value)
}
func createMachineUserPATWithMembership(ctx context.Context, t *testing.T, instance *integration.Instance, roles ...string) (userID, pat string) {
+1 -1
View File
@@ -104,7 +104,7 @@ func (s *Server) Introspect(ctx context.Context, r *op.Request[op.IntrospectionR
client.projectRoleAssertion,
true,
true,
)(ctx, true, domain.TriggerTypePreUserinfoCreation)
)(ctx, true, domain.TriggerTypePreUserinfoCreation, token.actor)
if err != nil {
return nil, err
}
+5 -5
View File
@@ -61,14 +61,14 @@ func (s *Server) getSignerOnce() sign.SignerFunc {
}
// userInfoFunc is a getter function that allows add-hoc retrieval of a user.
type userInfoFunc func(ctx context.Context, roleAssertion bool, triggerType domain.TriggerType) (*oidc.UserInfo, error)
type userInfoFunc func(ctx context.Context, roleAssertion bool, triggerType domain.TriggerType, actor *domain.TokenActor) (*oidc.UserInfo, error)
// getUserInfo returns a function which retrieves userinfo from the database once.
// However, each time, role claims are asserted and also action flows will trigger.
func (s *Server) getUserInfo(userID, projectID, clientID string, projectRoleAssertion, userInfoAssertion bool, scope []string) userInfoFunc {
userInfo := s.userInfo(userID, scope, projectID, clientID, projectRoleAssertion, userInfoAssertion, false)
return func(ctx context.Context, roleAssertion bool, triggerType domain.TriggerType) (*oidc.UserInfo, error) {
return userInfo(ctx, roleAssertion, triggerType)
return func(ctx context.Context, roleAssertion bool, triggerType domain.TriggerType, actor *domain.TokenActor) (*oidc.UserInfo, error) {
return userInfo(ctx, roleAssertion, triggerType, actor)
}
}
@@ -76,7 +76,7 @@ func (*Server) createIDToken(ctx context.Context, client op.Client, getUserInfo
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
userInfo, err := getUserInfo(ctx, roleAssertion, domain.TriggerTypePreUserinfoCreation)
userInfo, err := getUserInfo(ctx, roleAssertion, domain.TriggerTypePreUserinfoCreation, actor)
if err != nil {
return "", 0, err
}
@@ -120,7 +120,7 @@ func (s *Server) createJWT(ctx context.Context, client op.Client, session *comma
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
userInfo, err := getUserInfo(ctx, assertRoles, domain.TriggerTypePreAccessTokenCreation)
userInfo, err := getUserInfo(ctx, assertRoles, domain.TriggerTypePreAccessTokenCreation, session.Actor)
if err != nil {
return "", err
}
+186 -102
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"maps"
"net/http"
@@ -21,6 +22,7 @@ import (
"github.com/zitadel/zitadel/internal/api/authz"
"github.com/zitadel/zitadel/internal/domain"
"github.com/zitadel/zitadel/internal/execution"
target_domain "github.com/zitadel/zitadel/internal/execution/target"
"github.com/zitadel/zitadel/internal/query"
exec_repo "github.com/zitadel/zitadel/internal/repository/execution"
"github.com/zitadel/zitadel/internal/telemetry/tracing"
@@ -58,7 +60,7 @@ func (s *Server) UserInfo(ctx context.Context, r *op.Request[oidc.UserInfoReques
assertion,
true,
false,
)(ctx, true, domain.TriggerTypePreUserinfoCreation)
)(ctx, true, domain.TriggerTypePreUserinfoCreation, token.actor)
if err != nil {
if !zerrors.IsNotFound(err) {
return nil, err
@@ -89,14 +91,14 @@ func (s *Server) userInfo(
projectID string,
clientID string,
projectRoleAssertion, userInfoAssertion, currentProjectOnly bool,
) func(ctx context.Context, roleAssertion bool, triggerType domain.TriggerType) (_ *oidc.UserInfo, err error) {
) userInfoFunc {
var (
once sync.Once
rawUserInfo *oidc.UserInfo
qu *query.OIDCUserInfo
roleAudience, requestedRoles []string
)
return func(ctx context.Context, roleAssertion bool, triggerType domain.TriggerType) (_ *oidc.UserInfo, err error) {
return func(ctx context.Context, roleAssertion bool, triggerType domain.TriggerType, actor *domain.TokenActor) (_ *oidc.UserInfo, err error) {
once.Do(func() {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
@@ -122,7 +124,7 @@ func (s *Server) userInfo(
Claims: maps.Clone(rawUserInfo.Claims),
}
assertRoles(projectID, qu, roleAudience, requestedRoles, roleAssertion, userInfo)
return userInfo, s.userinfoFlows(ctx, qu, userInfo, triggerType, clientID)
return userInfo, s.userinfoFlows(ctx, qu, userInfo, triggerType, clientID, actor)
}
}
@@ -309,16 +311,49 @@ func setUserInfoUserGroups(userGroups []query.UserInfoUserGroup, out *oidc.UserI
out.AppendClaims(ClaimUserGroups, groups)
}
//nolint:gocognit
func (s *Server) userinfoFlows(ctx context.Context, qu *query.OIDCUserInfo, userInfo *oidc.UserInfo, triggerType domain.TriggerType, clientID string) (err error) {
func (s *Server) userinfoFlows(
ctx context.Context,
qu *query.OIDCUserInfo,
userInfo *oidc.UserInfo,
triggerType domain.TriggerType,
clientID string,
actor *domain.TokenActor,
) (err error) {
ctx, span := tracing.NewSpan(ctx)
defer func() { span.EndWithError(err) }()
if err := s.runUserinfoActionFlows(ctx, qu, userInfo, triggerType, clientID, actor); err != nil {
return err
}
return s.runUserinfoExecutionFlow(ctx, qu, userInfo, triggerType, clientID, actor)
}
// runUserinfoActionFlows loads the legacy, DB-configured Actions for triggerType and runs them.
func (s *Server) runUserinfoActionFlows(
ctx context.Context,
qu *query.OIDCUserInfo,
userInfo *oidc.UserInfo,
triggerType domain.TriggerType,
clientID string,
actor *domain.TokenActor,
) error {
queriedActions, err := s.query.GetActiveActionsByFlowAndTriggerType(ctx, domain.FlowTypeCustomiseToken, triggerType, qu.User.ResourceOwner)
if err != nil {
return err
}
return s.runUserinfoActions(ctx, qu, userInfo, clientID, actor, queriedActions)
}
// runUserinfoActions runs the given, already queried Actions. It is kept separate from
// runUserinfoActionFlows so it can be unit tested without a DB by injecting queriedActions directly.
func (s *Server) runUserinfoActions(
ctx context.Context,
qu *query.OIDCUserInfo,
userInfo *oidc.UserInfo,
clientID string,
actor *domain.TokenActor,
queriedActions []*query.Action,
) error {
ctxFields := actions.SetContextFields(
actions.SetFields("v1",
actions.SetFields("claims", userinfoClaims(userInfo)),
@@ -351,106 +386,154 @@ func (s *Server) userinfoFlows(ctx context.Context, qu *query.OIDCUserInfo, user
}
}),
),
// actor is null unless the token was obtained through token exchange / impersonation.
actions.SetFields("actor", object.TokenActorField(actor)),
),
)
for _, action := range queriedActions {
actionCtx, cancel := context.WithTimeout(ctx, action.Timeout())
claimLogs := []string{}
// Actions.contextFields/apiFields are unexported types in package actions, so ctxFields
// can't be passed as a typed parameter to a separate function - it's captured by this
// closure instead. The closure also gives each iteration its own deferred cancel() and
// recover(), so a timeout-bound context never outlives its iteration and a panic from
// building or running the script (whether from a goja-exposed closure or from
// constructing ctxFields/apiFields itself) is converted into a returned error instead
// of escaping to the caller.
runAction := func(action *query.Action) (err error) {
actionCtx, cancel := context.WithTimeout(ctx, action.Timeout())
defer cancel()
defer func() {
if r := recover(); r != nil {
err = errorFromRecover(r)
}
}()
apiFields := actions.WithAPIFields(
actions.SetFields("v1",
actions.SetFields("userinfo",
actions.SetFields("setClaim", func(key string, value interface{}) {
if strings.HasPrefix(key, ClaimPrefix) {
return
}
if userInfo.Claims[key] == nil {
userInfo.AppendClaims(key, value)
return
}
claimLogs = append(claimLogs, fmt.Sprintf("key %q already exists", key))
}),
actions.SetFields("appendLogIntoClaims", func(entry string) {
claimLogs = append(claimLogs, entry)
}),
claimLogs := []string{}
setClaim := func(key string, value interface{}) {
appendOrLogClaim(userInfo, key, value, &claimLogs)
}
appendLogIntoClaims := func(entry string) {
claimLogs = append(claimLogs, entry)
}
apiFields := actions.WithAPIFields(
actions.SetFields("v1",
actions.SetFields("userinfo",
actions.SetFields("setClaim", setClaim),
actions.SetFields("appendLogIntoClaims", appendLogIntoClaims),
),
actions.SetFields("claims",
actions.SetFields("setClaim", setClaim),
actions.SetFields("appendLogIntoClaims", appendLogIntoClaims),
),
actions.SetFields("user",
actions.SetFields("setMetadata", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) != 2 {
panic("exactly 2 (key, value) arguments expected")
}
key := call.Arguments[0].Export().(string)
val := call.Arguments[1].Export()
value, err := json.Marshal(val)
if err != nil {
logging.WithError(err).Debug("unable to marshal")
panic(err)
}
metadata := &domain.Metadata{
Key: key,
Value: value,
}
if _, err = s.command.SetUserMetadata(actionCtx, metadata, userInfo.Subject, qu.User.ResourceOwner, nil); err != nil {
logging.WithError(err).Info("unable to set md in action")
panic(err)
}
return nil
}),
),
),
actions.SetFields("claims",
actions.SetFields("setClaim", func(key string, value interface{}) {
if strings.HasPrefix(key, ClaimPrefix) {
return
}
if userInfo.Claims[key] == nil {
userInfo.AppendClaims(key, value)
return
}
claimLogs = append(claimLogs, fmt.Sprintf("key %q already exists", key))
}),
actions.SetFields("appendLogIntoClaims", func(entry string) {
claimLogs = append(claimLogs, entry)
}),
),
actions.SetFields("user",
actions.SetFields("setMetadata", func(call goja.FunctionCall) goja.Value {
if len(call.Arguments) != 2 {
panic("exactly 2 (key, value) arguments expected")
}
key := call.Arguments[0].Export().(string)
val := call.Arguments[1].Export()
)
value, err := json.Marshal(val)
if err != nil {
logging.WithError(err).Debug("unable to marshal")
panic(err)
}
if err := actions.Run(
actionCtx,
ctxFields,
apiFields,
action.Script,
action.Name,
append(actions.ActionToOptions(action), actions.WithHTTP(actionCtx, s.httpClient), actions.WithUUID(actionCtx))...,
); err != nil {
return err
}
if len(claimLogs) > 0 {
userInfo.AppendClaims(fmt.Sprintf(ClaimActionLogFormat, action.Name), claimLogs)
}
return nil
}
metadata := &domain.Metadata{
Key: key,
Value: value,
}
if _, err = s.command.SetUserMetadata(ctx, metadata, userInfo.Subject, qu.User.ResourceOwner, nil); err != nil {
logging.WithError(err).Info("unable to set md in action")
panic(err)
}
return nil
}),
),
),
)
err = actions.Run(
actionCtx,
ctxFields,
apiFields,
action.Script,
action.Name,
append(actions.ActionToOptions(action), actions.WithHTTP(actionCtx, s.httpClient), actions.WithUUID(actionCtx))...,
)
cancel()
if err != nil {
if err := runAction(action); err != nil {
return err
}
if len(claimLogs) > 0 {
userInfo.AppendClaims(fmt.Sprintf(ClaimActionLogFormat, action.Name), claimLogs)
}
}
return nil
}
var function string
switch triggerType {
case domain.TriggerTypePreUserinfoCreation:
function = exec_repo.ID(domain.ExecutionTypeFunction, domain.ActionFunctionPreUserinfo.LocalizationKey())
case domain.TriggerTypePreAccessTokenCreation:
function = exec_repo.ID(domain.ExecutionTypeFunction, domain.ActionFunctionPreAccessToken.LocalizationKey())
case domain.TriggerTypeUnspecified, domain.TriggerTypePostAuthentication, domain.TriggerTypePreCreation, domain.TriggerTypePostCreation, domain.TriggerTypePreSAMLResponseCreation:
// added for linting, there should never be any trigger type be used here besides PreUserinfo and PreAccessToken
// appendOrLogClaim adds value under key unless key is reserved (ClaimPrefix) or already set,
// in which case a conflict message is appended to logs instead. Shared by the setClaim
// goja closures and the execution-response AppendClaims merge.
func appendOrLogClaim(userInfo *oidc.UserInfo, key string, value any, logs *[]string) {
if strings.HasPrefix(key, ClaimPrefix) {
return
}
if userInfo.Claims[key] == nil {
userInfo.AppendClaims(key, value)
return
}
*logs = append(*logs, fmt.Sprintf("key %q already exists", key))
}
// errorFromRecover converts a recovered panic value into an error, mirroring the idiom
// internal/actions.executeFn already uses internally for panics from goja-exposed closures.
func errorFromRecover(r any) error {
if err, ok := r.(error); ok {
return err
}
if s, ok := r.(string); ok {
return errors.New(s)
}
return fmt.Errorf("unknown error occurred: %v", r)
}
// functionForTriggerType resolves the Execution function key for a trigger type, or ""
// if no Execution runs for that trigger type. The switch is kept exhaustive (no default)
// so the `exhaustive` linter forces a conscious decision whenever a new domain.TriggerType
// is added, rather than silently falling through.
func functionForTriggerType(triggerType domain.TriggerType) string {
switch triggerType {
case domain.TriggerTypePreUserinfoCreation:
return exec_repo.ID(domain.ExecutionTypeFunction, domain.ActionFunctionPreUserinfo.LocalizationKey())
case domain.TriggerTypePreAccessTokenCreation:
return exec_repo.ID(domain.ExecutionTypeFunction, domain.ActionFunctionPreAccessToken.LocalizationKey())
case domain.TriggerTypeUnspecified, domain.TriggerTypePostAuthentication, domain.TriggerTypePreCreation, domain.TriggerTypePostCreation, domain.TriggerTypePreSAMLResponseCreation:
// there should never be any trigger type used here besides PreUserinfo and PreAccessToken
return ""
}
return ""
}
// runUserinfoExecutionFlow resolves and runs the new-style Executions (webhook targets) for triggerType.
func (s *Server) runUserinfoExecutionFlow(ctx context.Context, qu *query.OIDCUserInfo, userInfo *oidc.UserInfo, triggerType domain.TriggerType, clientID string, actor *domain.TokenActor) error {
function := functionForTriggerType(triggerType)
if function == "" {
return nil
}
executionTargets := execution.QueryExecutionTargetsForFunction(ctx, function)
return s.runUserinfoExecutionTargets(ctx, qu, userInfo, clientID, actor, function, executionTargets)
}
// runUserinfoExecutionTargets calls the given, already resolved Execution targets. It is kept
// separate from runUserinfoExecutionFlow so it can be unit tested without authz/DB access by
// injecting executionTargets directly (e.g. pointing at httptest servers).
func (s *Server) runUserinfoExecutionTargets(ctx context.Context, qu *query.OIDCUserInfo, userInfo *oidc.UserInfo, clientID string, actor *domain.TokenActor, function string, executionTargets []target_domain.Target) error {
info := &ContextInfo{
Function: function,
UserInfo: userInfo,
@@ -459,6 +542,7 @@ func (s *Server) userinfoFlows(ctx context.Context, qu *query.OIDCUserInfo, user
Org: qu.Org,
Application: &ContextInfoApplication{ClientID: clientID},
UserGrants: qu.UserGrants,
Actor: actor,
}
resp, err := execution.CallTargets(ctx, executionTargets, info, s.targetEncryptionAlgorithm, s.query.GetActiveSigningWebKey, s.httpClient)
@@ -469,28 +553,26 @@ func (s *Server) userinfoFlows(ctx context.Context, qu *query.OIDCUserInfo, user
if !ok || contextInfoResponse == nil {
return nil
}
s.applyContextInfoResponse(ctx, qu, userInfo, function, contextInfoResponse)
return nil
}
// applyContextInfoResponse merges an Execution's response back into userInfo: applying
// requested user metadata writes and appending claims, logging any conflicts along the way.
func (s *Server) applyContextInfoResponse(ctx context.Context, qu *query.OIDCUserInfo, userInfo *oidc.UserInfo, function string, resp *ContextInfoResponse) {
claimLogs := make([]string, 0)
for _, metadata := range contextInfoResponse.SetUserMetadata {
if _, err = s.command.SetUserMetadata(ctx, metadata, userInfo.Subject, qu.User.ResourceOwner, nil); err != nil {
for _, metadata := range resp.SetUserMetadata {
if _, err := s.command.SetUserMetadata(ctx, metadata, userInfo.Subject, qu.User.ResourceOwner, nil); err != nil {
claimLogs = append(claimLogs, fmt.Sprintf("failed to set user metadata key %q", metadata.Key))
}
}
for _, claim := range contextInfoResponse.AppendClaims {
if strings.HasPrefix(claim.Key, ClaimPrefix) {
continue
}
if userInfo.Claims[claim.Key] == nil {
userInfo.AppendClaims(claim.Key, claim.Value)
continue
}
claimLogs = append(claimLogs, fmt.Sprintf("key %q already exists", claim.Key))
for _, claim := range resp.AppendClaims {
appendOrLogClaim(userInfo, claim.Key, claim.Value, &claimLogs)
}
claimLogs = append(claimLogs, contextInfoResponse.AppendLogClaims...)
claimLogs = append(claimLogs, resp.AppendLogClaims...)
if len(claimLogs) > 0 {
userInfo.AppendClaims(fmt.Sprintf(ClaimActionLogFormat, function), claimLogs)
}
return nil
}
type ContextInfo struct {
@@ -501,7 +583,9 @@ type ContextInfo struct {
Org *query.UserInfoOrg `json:"org,omitempty"`
UserGrants []query.UserGrant `json:"user_grants,omitempty"`
Application *ContextInfoApplication `json:"application,omitempty"`
Response *ContextInfoResponse `json:"response,omitempty"`
// Actor is only set when the token was obtained through token exchange / impersonation.
Actor *domain.TokenActor `json:"actor,omitempty"`
Response *ContextInfoResponse `json:"response,omitempty"`
}
type ContextInfoApplication struct {
ClientID string `json:"client_id,omitempty"`
+300
View File
@@ -3,15 +3,27 @@ package oidc
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/oidc/v3/pkg/oidc"
"golang.org/x/text/language"
"github.com/zitadel/zitadel/internal/actions"
"github.com/zitadel/zitadel/internal/domain"
target_domain "github.com/zitadel/zitadel/internal/execution/target"
"github.com/zitadel/zitadel/internal/logstore"
"github.com/zitadel/zitadel/internal/logstore/record"
"github.com/zitadel/zitadel/internal/query"
exec_repo "github.com/zitadel/zitadel/internal/repository/execution"
)
func Test_prepareRoles(t *testing.T) {
@@ -558,3 +570,291 @@ func Test_userInfoToOIDC(t *testing.T) {
})
}
}
func Test_functionForTriggerType(t *testing.T) {
tests := []struct {
name string
triggerType domain.TriggerType
want string
}{
{
name: "pre userinfo creation",
triggerType: domain.TriggerTypePreUserinfoCreation,
want: exec_repo.ID(domain.ExecutionTypeFunction, domain.ActionFunctionPreUserinfo.LocalizationKey()),
},
{
name: "pre access token creation",
triggerType: domain.TriggerTypePreAccessTokenCreation,
want: exec_repo.ID(domain.ExecutionTypeFunction, domain.ActionFunctionPreAccessToken.LocalizationKey()),
},
{
name: "unspecified",
triggerType: domain.TriggerTypeUnspecified,
want: "",
},
{
name: "post authentication",
triggerType: domain.TriggerTypePostAuthentication,
want: "",
},
{
name: "pre creation",
triggerType: domain.TriggerTypePreCreation,
want: "",
},
{
name: "post creation",
triggerType: domain.TriggerTypePostCreation,
want: "",
},
{
name: "pre saml response creation",
triggerType: domain.TriggerTypePreSAMLResponseCreation,
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, functionForTriggerType(tt.triggerType))
})
}
}
func Test_appendOrLogClaim(t *testing.T) {
tests := []struct {
name string
userInfo *oidc.UserInfo
key string
value any
wantClaims map[string]any
wantLogs []string
}{
{
name: "new claim is added",
userInfo: &oidc.UserInfo{Claims: map[string]any{}},
key: "foo",
value: "bar",
wantClaims: map[string]any{"foo": "bar"},
},
{
name: "reserved prefix is skipped",
userInfo: &oidc.UserInfo{Claims: map[string]any{}},
key: ClaimPrefix + ":something",
value: "bar",
wantClaims: map[string]any{},
},
{
name: "existing claim is not overwritten and logs a conflict",
userInfo: &oidc.UserInfo{Claims: map[string]any{"foo": "existing"}},
key: "foo",
value: "bar",
wantClaims: map[string]any{"foo": "existing"},
wantLogs: []string{`key "foo" already exists`},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var logs []string
appendOrLogClaim(tt.userInfo, tt.key, tt.value, &logs)
assert.Equal(t, tt.wantClaims, tt.userInfo.Claims)
assert.Equal(t, tt.wantLogs, logs)
})
}
}
func Test_errorFromRecover(t *testing.T) {
tests := []struct {
name string
r any
wantMsg string
}{
{
name: "error value is passed through",
r: errors.New("boom"),
wantMsg: "boom",
},
{
name: "string value is wrapped",
r: "boom",
wantMsg: "boom",
},
{
name: "other value falls back to a generic message",
r: 42,
wantMsg: "unknown error occurred: 42",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := errorFromRecover(tt.r)
require.Error(t, err)
assert.Equal(t, tt.wantMsg, err.Error())
})
}
}
func Test_runUserinfoActions(t *testing.T) {
actions.SetLogstoreService(logstore.New[*record.ExecutionLog](nil, nil))
t.Run("happy path sets claim from script", func(t *testing.T) {
s := &Server{}
qu := &query.OIDCUserInfo{User: &query.User{ID: "user1", ResourceOwner: "org1"}}
userInfo := &oidc.UserInfo{Subject: "user1", Claims: map[string]any{}}
queriedActions := []*query.Action{
{
Name: "testFunc",
Script: `function testFunc(ctx, api) {
api.v1.userinfo.setClaim("foo", "bar")
}`,
},
}
err := s.runUserinfoActions(context.Background(), qu, userInfo, "clientID", nil, queriedActions)
require.NoError(t, err)
assert.Equal(t, "bar", userInfo.Claims["foo"])
})
t.Run("panic while building ctxFields is recovered, not left uncaught", func(t *testing.T) {
s := &Server{}
qu := &query.OIDCUserInfo{User: &query.User{ID: "user1", ResourceOwner: "org1"}}
// A non-JSON-marshalable claim value makes userinfoClaims' eager json.Marshal panic
// while ctxFields are being built for this (or a later) action - a phase that runs
// before internal/actions.executeScript's own recover is armed. Without our own
// recover in the loop, this panic would escape uncaught and skip cancel().
userInfo := &oidc.UserInfo{Subject: "user1", Claims: map[string]any{"bad": make(chan int)}}
queriedActions := []*query.Action{
{Name: "testFunc", Script: `function testFunc(ctx, api) {}`},
}
err := s.runUserinfoActions(context.Background(), qu, userInfo, "clientID", nil, queriedActions)
require.Error(t, err)
})
t.Run("panic from setMetadata bad arg count is recovered", func(t *testing.T) {
s := &Server{}
qu := &query.OIDCUserInfo{User: &query.User{ID: "user1", ResourceOwner: "org1"}}
userInfo := &oidc.UserInfo{Subject: "user1", Claims: map[string]any{}}
queriedActions := []*query.Action{
{
Name: "testFunc",
Script: `function testFunc(ctx, api) {
api.v1.user.setMetadata("onlyonearg")
}`,
},
}
err := s.runUserinfoActions(context.Background(), qu, userInfo, "clientID", nil, queriedActions)
require.Error(t, err)
assert.Contains(t, err.Error(), "exactly 2")
})
t.Run("error from a script stops the loop and is returned", func(t *testing.T) {
s := &Server{}
qu := &query.OIDCUserInfo{User: &query.User{ID: "user1", ResourceOwner: "org1"}}
userInfo := &oidc.UserInfo{Subject: "user1", Claims: map[string]any{}}
queriedActions := []*query.Action{
{
Name: "testFunc",
Script: `function testFunc(ctx, api) { throw new Error("script failure") }`,
},
{
Name: "shouldNotRun",
Script: `function shouldNotRun(ctx, api) {
api.v1.userinfo.setClaim("unreachable", "value")
}`,
},
}
err := s.runUserinfoActions(context.Background(), qu, userInfo, "clientID", nil, queriedActions)
require.Error(t, err)
assert.NotContains(t, userInfo.Claims, "unreachable")
})
t.Run("actor delegation chain is readable from the script", func(t *testing.T) {
s := &Server{}
qu := &query.OIDCUserInfo{User: &query.User{ID: "user1", ResourceOwner: "org1"}}
userInfo := &oidc.UserInfo{Subject: "user1", Claims: map[string]any{}}
actor := &domain.TokenActor{
UserID: "actor1",
Issuer: "https://issuer1.example.com",
Actor: &domain.TokenActor{
UserID: "actor2",
Issuer: "https://issuer2.example.com",
},
}
queriedActions := []*query.Action{
{
Name: "testFunc",
Script: `function testFunc(ctx, api) {
api.v1.claims.setClaim("actor_user", ctx.v1.actor.userId)
api.v1.claims.setClaim("actor_issuer", ctx.v1.actor.issuer)
api.v1.claims.setClaim("nested_actor_user", ctx.v1.actor.actor.userId)
api.v1.claims.setClaim("chain_end", ctx.v1.actor.actor.actor === null)
}`,
},
}
err := s.runUserinfoActions(context.Background(), qu, userInfo, "clientID", actor, queriedActions)
require.NoError(t, err)
assert.Equal(t, "actor1", userInfo.Claims["actor_user"])
assert.Equal(t, "https://issuer1.example.com", userInfo.Claims["actor_issuer"])
assert.Equal(t, "actor2", userInfo.Claims["nested_actor_user"])
assert.Equal(t, true, userInfo.Claims["chain_end"])
})
t.Run("missing actor is null in the script", func(t *testing.T) {
s := &Server{}
qu := &query.OIDCUserInfo{User: &query.User{ID: "user1", ResourceOwner: "org1"}}
userInfo := &oidc.UserInfo{Subject: "user1", Claims: map[string]any{}}
queriedActions := []*query.Action{
{
Name: "testFunc",
Script: `function testFunc(ctx, api) {
api.v1.claims.setClaim("no_actor", ctx.v1.actor === null)
}`,
},
}
err := s.runUserinfoActions(context.Background(), qu, userInfo, "clientID", nil, queriedActions)
require.NoError(t, err)
assert.Equal(t, true, userInfo.Claims["no_actor"])
})
}
func Test_runUserinfoExecutionTargets(t *testing.T) {
respBody := []byte(`{"append_claims":[{"key":"foo","value":"bar"},{"key":"foo","value":"baz"}],"append_log_claims":["extra log entry"]}`)
var gotBody []byte
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotBody, _ = io.ReadAll(r.Body)
w.WriteHeader(http.StatusOK)
_, _ = w.Write(respBody)
}))
defer server.Close()
s := &Server{httpClient: server.Client()}
qu := &query.OIDCUserInfo{User: &query.User{ID: "user1", ResourceOwner: "org1"}}
userInfo := &oidc.UserInfo{Subject: "user1", Claims: map[string]any{}}
actor := &domain.TokenActor{
UserID: "actor1",
Issuer: "https://issuer1.example.com",
Actor: &domain.TokenActor{UserID: "actor2"},
}
targets := []target_domain.Target{
{TargetType: target_domain.TargetTypeCall, Endpoint: server.URL, Timeout: 5 * time.Second},
}
err := s.runUserinfoExecutionTargets(context.Background(), qu, userInfo, "clientID", actor, "function/test", targets)
require.NoError(t, err)
var sent ContextInfo
require.NoError(t, json.Unmarshal(gotBody, &sent))
assert.Equal(t, actor, sent.Actor)
assert.Equal(t, "bar", userInfo.Claims["foo"])
logClaim, ok := userInfo.Claims[fmt.Sprintf(ClaimActionLogFormat, "function/test")]
require.True(t, ok)
logs, ok := logClaim.([]string)
require.True(t, ok)
assert.Contains(t, logs, `key "foo" already exists`)
assert.Contains(t, logs, "extra log entry")
}
+29
View File
@@ -10,6 +10,8 @@ import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/zitadel/oidc/v3/pkg/client"
"github.com/zitadel/oidc/v3/pkg/client/rp"
"github.com/zitadel/oidc/v3/pkg/client/rs"
@@ -18,6 +20,7 @@ import (
http_util "github.com/zitadel/zitadel/internal/api/http"
oidc_internal "github.com/zitadel/zitadel/internal/api/oidc"
"github.com/zitadel/zitadel/pkg/grpc/admin"
"github.com/zitadel/zitadel/pkg/grpc/app"
"github.com/zitadel/zitadel/pkg/grpc/authn"
"github.com/zitadel/zitadel/pkg/grpc/management"
@@ -444,6 +447,32 @@ func (i *Instance) CreateOIDCJWTProfileClient(ctx context.Context, keyLifetime t
return machine, name, keyResp.GetKeyDetails(), nil
}
// SetImpersonationPolicy sets EnableImpersonation on the instance security policy and waits
// until the change is visible again, so a subsequent token exchange doesn't race the projection.
func (i *Instance) SetImpersonationPolicy(ctx context.Context, t *testing.T, value bool) {
iamCTX := i.WithAuthorization(ctx, UserTypeIAMOwner)
policy, err := i.Client.Admin.GetSecurityPolicy(iamCTX, &admin.GetSecurityPolicyRequest{})
require.NoError(t, err)
if policy.GetPolicy().GetEnableImpersonation() != value {
_, err = i.Client.Admin.SetSecurityPolicy(iamCTX, &admin.SetSecurityPolicyRequest{
EnableImpersonation: value,
})
require.NoError(t, err)
}
retryDuration, tick := WaitForAndTickWithMaxDuration(iamCTX, time.Minute)
require.EventuallyWithT(t,
func(ttt *assert.CollectT) {
f, err := i.Client.Admin.GetSecurityPolicy(iamCTX, &admin.GetSecurityPolicyRequest{})
assert.NoError(ttt, err)
assert.Equal(ttt, value, f.GetPolicy().GetEnableImpersonation())
},
retryDuration,
tick,
"timed out waiting for ensuring impersonation policy")
}
func (i *Instance) CreateDeviceAuthorizationRequest(ctx context.Context, clientID string, scopes ...string) (*oidc.DeviceAuthorizationResponse, error) {
provider, err := i.CreateRelyingParty(ctx, clientID, "", scopes...)
if err != nil {