mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-25 00:32:15 +00:00
Closes #11369 # Which Problems Are Solved During SSO login with an external IdP, Actions v2 lets you manipulate the `RetrieveIdentityProviderIntent` response to shape the user that ZITADEL creates or updates. There was an inconsistency between the two flows: - **First login (user does not exist):** the response carried an `addHumanUser` object (mirroring the deprecated `AddHumanUser` API), which **does** allow setting user `metadata`. - **Subsequent logins (user exists):** the response carried an `updateHumanUser` object (mirroring the deprecated `UpdateHumanUser` API), which does **not** support metadata. As a result, actions could set metadata when creating a user but not when updating one. Customers doing SSO attribute mapping had to make a separate `SetUserMetadata` call on every subsequent login — extra latency and a non-atomic update. The proto/backend already gained a non-deprecated `user_action` oneof (`create_user` → `CreateUserRequest`, `update_user` → `UpdateUserRequest`, both supporting metadata), but the login app was still reading the deprecated flat fields, so the new capability was unreachable from the frontend. # How the Problems Are Solved Migrate the login app's IDP intent handler to consume the new `user_action` oneof, with a fallback to the deprecated fields so older API responses keep working during the transition. - **`zitadel.ts`** — added `createUser` / `updateUser` client wrappers calling the non-deprecated `UserService.CreateUser` / `UserService.UpdateUser` endpoints. - **`idp-intent.ts`** — added three helpers, each preferring `user_action` and falling back to `add_human_user` / `update_human_user`: - `resolveCreateUser` — flat read view for org resolution, required-field checks, and registration-form pre-fill. - `buildCreateUserRequest` — passes the action's `CreateUserRequest` through and injects the resolved `organizationId`; maps the deprecated flat payload into the nested shape on fallback. - `buildUpdateUserRequest` — builds an `UpdateUserRequest` **including metadata** (the fix); deliberately syncs only profile/email/phone/metadata (not username) to preserve existing auto-update behavior and avoid invalidating sessions. - Rewired all handlers (`handleUserExists`, `handleAutoLinking`, `handleAutoCreation`, `handleManualCreation`, `resolveOrganizationForUser`) to use these, and switched auto-create to read `CreateUserResponse.id`. - **Tests** — updated mocks/assertions to the new request shapes and added two cases exercising the `user_action` oneof with metadata (create + update). 817/817 login unit tests pass; no new type errors. # Additional Changes Updated the Actions v2 guide `guides/integrate/actions/testing-response-manipulation.mdx` (the unreleased/`latest` docs) to reflect the new response shape: - Go handler example now manipulates `resp.GetCreateUser()` / `resp.GetUpdateUser()` and appends `user.Metadata`, demonstrating metadata on both flows. - Both JSON payloads switched from `addHumanUser` to the nested `createUser` shape (`human.profile`, `human.email`, `human.idpLinks`, top-level `metadata`). - Added a Callout explaining first-login → `createUser` vs. existing-user → `updateUser`, that both support metadata, and that `addHumanUser`/`updateHumanUser` are deprecated. - Updated the claim-mapping debugging section to the new `createUser.human.profile.givenName` path. Versioned snapshots (`v4.12`/`v4.13`/`v4.14`) were intentionally left unchanged, as they document releases where the old API was correct. --------- Co-authored-by: gayathri <[email protected]>
337 lines
12 KiB
Plaintext
337 lines
12 KiB
Plaintext
---
|
|
title: Test Actions Response Manipulation
|
|
description: "Manipulate API responses in your ZITADEL instance by creating targets that handle specific response events with custom logic"
|
|
---
|
|
|
|
This guide shows you how to leverage the ZITADEL actions feature to manipulate API responses in your ZITADEL instance.
|
|
You can use the actions feature to create a target that will be called when a specific API response occurs.
|
|
This is useful for triggering workflows based on API responses in ZITADEL. You can even use this to provide data necessary data to the new login UI as shown in this example.
|
|
|
|
## Prerequisites
|
|
|
|
Before you start, make sure you have everything set up correctly.
|
|
|
|
- You need to be at least a ZITADEL [_IAM_OWNER_](/guides/manage/console/administrators)
|
|
- Your ZITADEL instance needs to have the actions feature enabled.
|
|
|
|
<Callout>
|
|
Note that this guide assumes that ZITADEL is running on the same machine as the target and can be reached via `localhost`.
|
|
In case you are using a different setup, you need to adjust the target URL accordingly and will need to make sure that the target is reachable from ZITADEL.
|
|
</Callout>
|
|
|
|
<Callout>
|
|
To marshal and unmarshal the request and response please use a package like [protojson](https://pkg.go.dev/google.golang.org/protobuf/encoding/protojson),
|
|
as the request and response are protocol buffer messages, to avoid potential problems with the attribute names.
|
|
</Callout>
|
|
|
|
## Start example target
|
|
|
|
To test the actions feature, you need to create a target that will be called when an API endpoint is called.
|
|
You will need to implement a listener that can receive HTTP requests, process the request and returns the manipulated request.
|
|
For this example, we will use a simple Go HTTP server that will return the request with added metadata.
|
|
|
|
<Callout>
|
|
The signature of the received request can be checked, [please refer to the example for more information on how to](/guides/integrate/actions/testing-request-signature).
|
|
</Callout>
|
|
|
|
```go
|
|
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
|
|
"github.com/zitadel/zitadel-go/v3/pkg/client/zitadel/user/v2"
|
|
"google.golang.org/protobuf/encoding/protojson"
|
|
)
|
|
|
|
type contextResponse struct {
|
|
Request *retrieveIdentityProviderIntentRequestWrapper `json:"request"`
|
|
Response *retrieveIdentityProviderIntentResponseWrapper `json:"response"`
|
|
}
|
|
|
|
// RetrieveIdentityProviderIntentRequestWrapper necessary to marshal and unmarshal the JSON into the proto message correctly
|
|
type retrieveIdentityProviderIntentRequestWrapper struct {
|
|
user.RetrieveIdentityProviderIntentRequest
|
|
}
|
|
|
|
func (r *retrieveIdentityProviderIntentRequestWrapper) MarshalJSON() ([]byte, error) {
|
|
data, err := protojson.Marshal(r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
func (r *retrieveIdentityProviderIntentRequestWrapper) UnmarshalJSON(data []byte) error {
|
|
return protojson.Unmarshal(data, r)
|
|
}
|
|
|
|
// RetrieveIdentityProviderIntentResponseWrapper necessary to marshal and unmarshal the JSON into the proto message correctly
|
|
type retrieveIdentityProviderIntentResponseWrapper struct {
|
|
user.RetrieveIdentityProviderIntentResponse
|
|
}
|
|
|
|
func (r *retrieveIdentityProviderIntentResponseWrapper) MarshalJSON() ([]byte, error) {
|
|
data, err := protojson.Marshal(r)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
func (r *retrieveIdentityProviderIntentResponseWrapper) UnmarshalJSON(data []byte) error {
|
|
return protojson.Unmarshal(data, r)
|
|
}
|
|
|
|
// call HandleFunc to read the response body, manipulate the content and return the response
|
|
func call(w http.ResponseWriter, req *http.Request) {
|
|
// read the body content
|
|
sentBody, err := io.ReadAll(req.Body)
|
|
if err != nil {
|
|
// if there was an error while reading the body return an error
|
|
http.Error(w, "error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer req.Body.Close()
|
|
|
|
// read the response into the expected structure
|
|
request := new(contextResponse)
|
|
if err := json.Unmarshal(sentBody, request); err != nil {
|
|
http.Error(w, "error", http.StatusInternalServerError)
|
|
}
|
|
|
|
// build the response from the received response
|
|
resp := request.Response
|
|
// manipulate the received response to send back as response
|
|
if resp != nil {
|
|
// On the first login the user does not exist yet, so the response contains a CreateUser
|
|
// action. On subsequent logins the user already exists and the response contains an
|
|
// UpdateUser action instead. Both support setting user metadata directly.
|
|
metadata := &user.Metadata{Key: "organization", Value: []byte("company")}
|
|
if createUser := resp.GetCreateUser(); createUser != nil {
|
|
createUser.Metadata = append(createUser.Metadata, metadata)
|
|
}
|
|
if updateUser := resp.GetUpdateUser(); updateUser != nil {
|
|
updateUser.Metadata = append(updateUser.Metadata, metadata)
|
|
}
|
|
}
|
|
|
|
// marshal the response into json
|
|
data, err := json.Marshal(resp)
|
|
if err != nil {
|
|
// if there was an error while marshalling the json
|
|
http.Error(w, "error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
// return the manipulated response
|
|
w.Write(data)
|
|
}
|
|
|
|
func main() {
|
|
// handle the HTTP call under "/call"
|
|
http.HandleFunc("/call", call)
|
|
|
|
// start an HTTP server with the before defined function to handle the endpoint under "http://localhost:8090"
|
|
http.ListenAndServe(":8090", nil)
|
|
}
|
|
```
|
|
|
|
<Callout title="">
|
|
The example above runs only on your local machine (`localhost`).
|
|
To test it with Zitadel, you must make your listener reachable from the internet.
|
|
You can do this by using **Webhook.site** (see [Creating a Listener with Webhook.site](./webhook-site-setup)).
|
|
</Callout>
|
|
|
|
## Create target
|
|
|
|
As you see in the example above the target is created with HTTP and port '8090' and if we want to use it as call, the
|
|
target can be created as follows:
|
|
|
|
See [Create a target](/reference/api/action/zitadel.action.v2.ActionService.CreateTarget) for more detailed information.
|
|
|
|
```shell
|
|
curl -L -X POST 'https://${CUSTOM_DOMAIN}/v2/actions/targets' \
|
|
-H 'Content-Type: application/json' \
|
|
-H 'Accept: application/json' \
|
|
-H 'Authorization: Bearer <TOKEN>' \
|
|
--data-raw '{
|
|
"name": "local call",
|
|
"restCall": {
|
|
"interruptOnError": true
|
|
},
|
|
"endpoint": "http://localhost:8090/call",
|
|
"timeout": "10s"
|
|
}'
|
|
```
|
|
|
|
Save the returned ID to set in the execution.
|
|
|
|
## Set execution
|
|
|
|
To call the target just created before, with the intention to manipulate the retrieve of an intent by the user V2 API,
|
|
we define an execution with a response condition.
|
|
|
|
See [Set an execution](/reference/api/action/zitadel.action.v2.ActionService.SetExecution) for more detailed information.
|
|
|
|
```shell
|
|
curl -L -X PUT 'https://${CUSTOM_DOMAIN}/v2/actions/executions' \
|
|
-H 'Content-Type: application/json' \
|
|
-H 'Accept: application/json' \
|
|
-H 'Authorization: Bearer <TOKEN>' \
|
|
--data-raw '{
|
|
"condition": {
|
|
"response": {
|
|
"method": "/zitadel.user.v2.UserService/RetrieveIdentityProviderIntent"
|
|
}
|
|
},
|
|
"targets": [
|
|
"<TargetID returned>"
|
|
]
|
|
}'
|
|
```
|
|
|
|
## Example call
|
|
|
|
Now that you have set up the target and execution, you can test it by using a login-flow in the typescript login with an external IDP.
|
|
|
|
<Callout>
|
|
When the user logs in for the first time and does not exist yet, the response contains a `createUser` object (matching the [Create User](/reference/api/user/zitadel.user.v2.UserService.CreateUser) request).
|
|
When the user already exists, the response instead contains an `updateUser` object (matching the [Update User](/reference/api/user/zitadel.user.v2.UserService.UpdateUser) request).
|
|
Both allow setting user `metadata` directly in the same response.
|
|
|
|
The previously used `addHumanUser` and `updateHumanUser` objects are deprecated and replaced by `createUser` and `updateUser` respectively.
|
|
</Callout>
|
|
|
|
```json
|
|
{
|
|
"details": {
|
|
"sequence": "599",
|
|
"changeDate": "2023-06-15T06:44:26.039444Z",
|
|
"resourceOwner": "163840776835432705"
|
|
},
|
|
"idpInformation": {
|
|
"oauth": {
|
|
"accessToken": "ya29...",
|
|
"idToken": "ey..."
|
|
},
|
|
"idpId": "218528353504723201",
|
|
"userId": "218528353504723202",
|
|
"username": "test-user@localhost",
|
|
"rawInformation": {
|
|
"User": {
|
|
"email": "test-user@localhost",
|
|
"email_verified": true,
|
|
"family_name": "User",
|
|
"given_name": "Test",
|
|
"hd": "mouse.com",
|
|
"locale": "de",
|
|
"name": "Minnie Mouse",
|
|
"picture": "https://lh3.googleusercontent.com/a/AAcKTtf973Q7NH8KzKTMEZELPU9lx45WpQ9FRBuxFdPb=s96-c",
|
|
"sub": "111392805975715856637"
|
|
}
|
|
}
|
|
},
|
|
"createUser": {
|
|
"username": "test-user@localhost",
|
|
"human": {
|
|
"idpLinks": [
|
|
{"idpId": "218528353504723201", "userId": "218528353504723202", "userName": "test-user@localhost"}
|
|
],
|
|
"profile": {
|
|
"givenName": "Test",
|
|
"familyName": "User",
|
|
"displayName": "Test User",
|
|
"preferredLanguage": "de"
|
|
},
|
|
"email": {
|
|
"email": "[email protected]",
|
|
"isVerified": true
|
|
}
|
|
},
|
|
"metadata": []
|
|
}
|
|
}
|
|
```
|
|
|
|
Your server should now manipulate the response to something like the following. Check out
|
|
the [Sent information Response](./usage#sent-information-response) payload description.
|
|
|
|
```json
|
|
{
|
|
"details": {
|
|
"sequence": "599",
|
|
"changeDate": "2023-06-15T06:44:26.039444Z",
|
|
"resourceOwner": "163840776835432705"
|
|
},
|
|
"idpInformation": {
|
|
"oauth": {
|
|
"accessToken": "ya29...",
|
|
"idToken": "ey..."
|
|
},
|
|
"idpId": "218528353504723201",
|
|
"userId": "218528353504723202",
|
|
"username": "test-user@localhost",
|
|
"rawInformation": {
|
|
"User": {
|
|
"email": "test-user@localhost",
|
|
"email_verified": true,
|
|
"family_name": "User",
|
|
"given_name": "Test",
|
|
"hd": "mouse.com",
|
|
"locale": "de",
|
|
"name": "Minnie Mouse",
|
|
"picture": "https://lh3.googleusercontent.com/a/AAcKTtf973Q7NH8KzKTMEZELPU9lx45WpQ9FRBuxFdPb=s96-c",
|
|
"sub": "111392805975715856637"
|
|
}
|
|
}
|
|
},
|
|
"createUser": {
|
|
"username": "test-user@localhost",
|
|
"human": {
|
|
"idpLinks": [
|
|
{"idpId": "218528353504723201", "userId": "218528353504723202", "userName": "test-user@localhost"}
|
|
],
|
|
"profile": {
|
|
"givenName": "Test",
|
|
"familyName": "User",
|
|
"displayName": "Test User",
|
|
"preferredLanguage": "de"
|
|
},
|
|
"email": {
|
|
"email": "[email protected]",
|
|
"isVerified": true
|
|
}
|
|
},
|
|
"metadata": [
|
|
{"key": "organization", "value": "Y29tcGFueQ=="}
|
|
]
|
|
}
|
|
}
|
|
```
|
|
|
|
## Debugging claim mapping issues with external IdPs
|
|
|
|
The same `RetrieveIdentityProviderIntent` webhook is useful for diagnosing auto-user-creation failures caused by unexpected or missing claims from an external IdP.
|
|
|
|
A common symptom is an error like:
|
|
|
|
```
|
|
invalid CreateUserRequest.Human.Profile: embedded message failed validation |
|
|
caused by: invalid SetHumanProfile.GivenName: value length must be between 1 and 200 runes, inclusive
|
|
```
|
|
|
|
This means ZITADEL received an empty value for `givenName` during auto-creation. The IdP may be sending the claim under a different key than ZITADEL expects (e.g. `Givenname`, `firstname`, or a provider-specific claim name).
|
|
|
|
**To inspect the raw claims ZITADEL receives from your IdP**, point the execution at a logging endpoint (such as [Webhook.site](./webhook-site-setup)) instead of a manipulation handler. Trigger a login with the external IdP and look at the `rawInformation` field in the payload — this contains the unmodified claims exactly as ZITADEL received them.
|
|
|
|
Compare the claim keys in `rawInformation` against what ZITADEL maps to standard profile fields. If the IdP uses a non-standard key, you can remap it in the same webhook by setting `createUser.human.profile.givenName` from the correct source field before returning the response.
|
|
|
|
## Conclusion
|
|
|
|
You have successfully set up a target and execution to manipulate API responses in your ZITADEL instance.
|
|
This feature can now be used to add necessary information for applications including the new login UI.
|
|
Find more information about the actions feature in the [API documentation](/concepts/features/actions_v2).
|