mirror of
https://github.com/zitadel/zitadel.git
synced 2026-08-17 16:35:14 -05:00
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
- dba6261c8e: 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 <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Wim Van Laer <wim+github@zitadel.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
Copilot Autofix powered by AI
Wim Van Laer
parent
790244dd49
commit
6127b567e7
+1
-2
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"`
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Executable
+493
@@ -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 <description> <expected> <actual>
|
||||
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 <<EOF
|
||||
|
||||
${BOLD}This will modify the Zitadel instance at ${URL}${RESET}
|
||||
|
||||
- enable impersonation in the instance security policy
|
||||
- create a machine user (impersonator) with a PAT and IAM_END_USER_IMPERSONATOR
|
||||
- create a human user to impersonate
|
||||
- create a project and an OIDC client with the token exchange grant
|
||||
- create an Actions v2 target and two executions
|
||||
- receive webhooks via: ${RECEIVER}
|
||||
EOF
|
||||
if [[ $KEEP -eq 1 ]]; then
|
||||
echo " - keep everything afterwards (--keep)"
|
||||
else
|
||||
echo " - delete all of the above afterwards"
|
||||
fi
|
||||
echo
|
||||
printf 'Continue? [y/N] '
|
||||
read -r reply < /dev/tty 2>/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 <method> <path> [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 <method> <path> [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 <jwt> -- 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 <function> <payloads>
|
||||
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 <payloads>
|
||||
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"
|
||||
Reference in New Issue
Block a user