feat(login): idp userAction oneof (#12433)

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]>
This commit is contained in:
Max Peintner
2026-07-16 12:01:07 +02:00
committed by GitHub
co-authored by gayathri
parent 8395d4326e
commit 6bd46c5fb6
4 changed files with 475 additions and 119 deletions
@@ -105,9 +105,17 @@ func call(w http.ResponseWriter, req *http.Request) {
// build the response from the received response
resp := request.Response
// manipulate the received response to send back as response
if resp != nil && resp.AddHumanUser != nil {
// manipulate the response
resp.AddHumanUser.Metadata = append(resp.AddHumanUser.Metadata, &user.SetMetadataEntry{Key: "organization", Value: []byte("company")})
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
@@ -189,6 +197,14 @@ curl -L -X PUT 'https://${CUSTOM_DOMAIN}/v2/actions/executions' \
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": {
@@ -218,20 +234,22 @@ Now that you have set up the target and execution, you can test it by using a lo
}
}
},
"addHumanUser": {
"idpLinks": [
{"idpId": "218528353504723201", "userId": "218528353504723202", "userName": "test-user@localhost"}
],
"createUser": {
"username": "test-user@localhost",
"profile": {
"givenName": "Test",
"familyName": "User",
"displayName": "Test User",
"preferredLanguage": "de"
},
"email": {
"email": "test-user@zitadel.ch",
"isVerified": true
"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": []
}
@@ -270,20 +288,22 @@ the [Sent information Response](./usage#sent-information-response) payload descr
}
}
},
"addHumanUser": {
"idpLinks": [
{"idpId": "218528353504723201", "userId": "218528353504723202", "userName": "test-user@localhost"}
],
"createUser": {
"username": "test-user@localhost",
"profile": {
"givenName": "Test",
"familyName": "User",
"displayName": "Test User",
"preferredLanguage": "de"
},
"email": {
"email": "test-user@zitadel.ch",
"isVerified": true
"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=="}
@@ -299,7 +319,7 @@ The same `RetrieveIdentityProviderIntent` webhook is useful for diagnosing auto-
A common symptom is an error like:
```
invalid AddHumanUserRequest.Profile: embedded message failed validation |
invalid CreateUserRequest.Human.Profile: embedded message failed validation |
caused by: invalid SetHumanProfile.GivenName: value length must be between 1 and 200 runes, inclusive
```
@@ -307,7 +327,7 @@ This means ZITADEL received an empty value for `givenName` during auto-creation.
**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 `addHumanUser.profile.givenName` from the correct source field before returning the response.
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
+214 -40
View File
@@ -29,10 +29,10 @@ vi.mock("../service-url", () => ({
vi.mock("../zitadel", () => ({
retrieveIDPIntent: vi.fn(),
getIDPByID: vi.fn(),
updateHuman: vi.fn(),
updateUser: vi.fn(),
addIDPLink: vi.fn(),
listUsers: vi.fn(),
addHuman: vi.fn(),
createUser: vi.fn(),
getLoginSettings: vi.fn(),
getOrgsByDomain: vi.fn(),
getActiveIdentityProviders: vi.fn(),
@@ -63,10 +63,10 @@ describe("processIDPCallback", () => {
let mockGetServiceUrlFromHeaders: any;
let mockRetrieveIDPIntent: any;
let mockGetIDPByID: any;
let mockUpdateHuman: any;
let mockUpdateUser: any;
let mockAddIDPLink: any;
let mockListUsers: any;
let mockAddHuman: any;
let mockCreateUser: any;
let mockGetLoginSettings: any;
let mockGetOrgsByDomain: any;
let mockGetActiveIdentityProviders: any;
@@ -146,10 +146,10 @@ describe("processIDPCallback", () => {
const {
retrieveIDPIntent,
getIDPByID,
updateHuman,
updateUser,
addIDPLink,
listUsers,
addHuman,
createUser,
getLoginSettings,
getOrgsByDomain,
getActiveIdentityProviders,
@@ -166,10 +166,10 @@ describe("processIDPCallback", () => {
mockGetServiceUrlFromHeaders = vi.mocked(getServiceConfig);
mockRetrieveIDPIntent = vi.mocked(retrieveIDPIntent);
mockGetIDPByID = vi.mocked(getIDPByID);
mockUpdateHuman = vi.mocked(updateHuman);
mockUpdateUser = vi.mocked(updateUser);
mockAddIDPLink = vi.mocked(addIDPLink);
mockListUsers = vi.mocked(listUsers);
mockAddHuman = vi.mocked(addHuman);
mockCreateUser = vi.mocked(createUser);
mockGetLoginSettings = vi.mocked(getLoginSettings);
mockGetOrgsByDomain = vi.mocked(getOrgsByDomain);
mockGetActiveIdentityProviders = vi.mocked(getActiveIdentityProviders);
@@ -324,16 +324,143 @@ describe("processIDPCallback", () => {
await processIDPCallback(defaultParams);
expect(mockUpdateHuman).toHaveBeenCalledWith({
// Falls back to the deprecated update_human_user and maps it into the new UpdateUser shape.
expect(mockUpdateUser).toHaveBeenCalledWith({
serviceConfig: { baseUrl: "https://api.example.com" },
request: expect.objectContaining({
userId: "user123",
profile: defaultIntent.updateHumanUser.profile,
email: defaultIntent.updateHumanUser.email,
userType: expect.objectContaining({
case: "human",
value: expect.objectContaining({
profile: expect.objectContaining({
givenName: "Test",
familyName: "User 1",
}),
email: defaultIntent.updateHumanUser.email,
}),
}),
}),
});
});
test("should auto-update user from the new update_user action including metadata", async () => {
const metadata = [{ key: "role", value: new Uint8Array([1, 2, 3]) }];
mockRetrieveIDPIntent.mockResolvedValue({
...defaultIntent,
// New user_action oneof: takes precedence over the deprecated update_human_user field.
addHumanUser: undefined,
updateHumanUser: undefined,
userAction: {
case: "updateUser",
value: {
userId: "user123",
userType: {
case: "human",
value: {
profile: { givenName: "Test", familyName: "User 1" },
email: { email: "[email protected]" },
},
},
metadata,
},
},
});
mockGetIDPByID.mockResolvedValue({
...defaultIdp,
config: {
options: {
...defaultIdp.config.options,
isAutoUpdate: true,
},
},
});
await processIDPCallback(defaultParams);
expect(mockUpdateUser).toHaveBeenCalledWith({
serviceConfig: { baseUrl: "https://api.example.com" },
request: expect.objectContaining({
userId: "user123",
userType: expect.objectContaining({
case: "human",
value: expect.objectContaining({
profile: { givenName: "Test", familyName: "User 1" },
email: { email: "[email protected]" },
}),
}),
metadata,
}),
});
});
test("should auto-update metadata even when the update_user action has no human userType", async () => {
const metadata = [{ key: "role", value: new Uint8Array([1, 2, 3]) }];
mockRetrieveIDPIntent.mockResolvedValue({
...defaultIntent,
addHumanUser: undefined,
updateHumanUser: undefined,
// Metadata-only update: user_type is unset, only metadata is provided.
userAction: {
case: "updateUser",
value: {
userId: "user123",
userType: { case: undefined, value: undefined },
metadata,
},
},
});
mockGetIDPByID.mockResolvedValue({
...defaultIdp,
config: {
options: {
...defaultIdp.config.options,
isAutoUpdate: true,
},
},
});
await processIDPCallback(defaultParams);
// Metadata must still be applied, without forcing a human userType.
expect(mockUpdateUser).toHaveBeenCalledWith({
serviceConfig: { baseUrl: "https://api.example.com" },
request: expect.objectContaining({
userId: "user123",
metadata,
}),
});
expect(mockUpdateUser.mock.calls[0][0].request.userType).toBeUndefined();
});
test("should skip auto-update when update_user action has neither human data nor metadata", async () => {
mockRetrieveIDPIntent.mockResolvedValue({
...defaultIntent,
addHumanUser: undefined,
updateHumanUser: undefined,
userAction: {
case: "updateUser",
value: {
userId: "user123",
userType: { case: undefined, value: undefined },
metadata: [],
},
},
});
mockGetIDPByID.mockResolvedValue({
...defaultIdp,
config: {
options: {
...defaultIdp.config.options,
isAutoUpdate: true,
},
},
});
await processIDPCallback(defaultParams);
expect(mockUpdateUser).not.toHaveBeenCalled();
});
test("should continue session creation even if auto-update fails", async () => {
mockGetIDPByID.mockResolvedValue({
...defaultIdp,
@@ -344,7 +471,7 @@ describe("processIDPCallback", () => {
},
},
});
mockUpdateHuman.mockRejectedValue(new Error("Update failed"));
mockUpdateUser.mockRejectedValue(new Error("Update failed"));
const result = await processIDPCallback(defaultParams);
@@ -616,20 +743,24 @@ describe("processIDPCallback", () => {
});
test("should auto-create user and create session", async () => {
mockAddHuman.mockResolvedValue({
userId: "newuser123",
mockCreateUser.mockResolvedValue({
id: "newuser123",
});
const result = await processIDPCallback(defaultParams);
expect(mockAddHuman).toHaveBeenCalledWith({
// Falls back to the deprecated add_human_user and maps it into the new CreateUser shape.
expect(mockCreateUser).toHaveBeenCalledWith({
serviceConfig: { baseUrl: "https://api.example.com" },
request: expect.objectContaining({
organizationId: "org123",
username: "testuser",
profile: defaultIntent.addHumanUser.profile,
email: defaultIntent.addHumanUser.email,
organization: expect.objectContaining({
org: { case: "orgId", value: "org123" },
userType: expect.objectContaining({
case: "human",
value: expect.objectContaining({
profile: defaultIntent.addHumanUser.profile,
email: defaultIntent.addHumanUser.email,
}),
}),
}),
});
@@ -645,6 +776,53 @@ describe("processIDPCallback", () => {
expect(result.redirect).toBe("https://app.example.com/success");
});
test("should auto-create user from the new create_user action including metadata", async () => {
const metadata = [{ key: "role", value: new Uint8Array([1, 2, 3]) }];
mockRetrieveIDPIntent.mockResolvedValue({
...defaultIntent,
userId: undefined,
// New user_action oneof: takes precedence over the deprecated add_human_user field.
addHumanUser: undefined,
updateHumanUser: undefined,
userAction: {
case: "createUser",
value: {
username: "testuser",
userType: {
case: "human",
value: {
profile: { givenName: "Test", familyName: "User" },
email: { email: "[email protected]" },
idpLinks: [{ idpId: "idp123", userId: "ext123", userName: "testuser" }],
},
},
metadata,
},
},
});
mockCreateUser.mockResolvedValue({ id: "newuser123" });
const result = await processIDPCallback(defaultParams);
// The action's request is passed through and only the resolved organization is injected.
expect(mockCreateUser).toHaveBeenCalledWith({
serviceConfig: { baseUrl: "https://api.example.com" },
request: expect.objectContaining({
organizationId: "org123",
username: "testuser",
userType: expect.objectContaining({
case: "human",
value: expect.objectContaining({
idpLinks: [{ idpId: "idp123", userId: "ext123", userName: "testuser" }],
}),
}),
metadata,
}),
});
expect(mockCreateNewSessionFromIdpIntent).toHaveBeenCalledWith(expect.objectContaining({ userId: "newuser123" }));
expect(result.redirect).toBe("https://app.example.com/success");
});
test("should resolve organization from username domain", async () => {
mockRetrieveIDPIntent.mockResolvedValue({
...defaultIntent,
@@ -660,7 +838,7 @@ describe("processIDPCallback", () => {
mockGetLoginSettings.mockResolvedValue({
allowDomainDiscovery: true,
});
mockAddHuman.mockResolvedValue({ userId: "newuser123" });
mockCreateUser.mockResolvedValue({ id: "newuser123" });
await processIDPCallback({
...defaultParams,
@@ -671,19 +849,17 @@ describe("processIDPCallback", () => {
serviceConfig: { baseUrl: "https://api.example.com" },
domain: "example.com",
});
expect(mockAddHuman).toHaveBeenCalledWith({
expect(mockCreateUser).toHaveBeenCalledWith({
serviceConfig: { baseUrl: "https://api.example.com" },
request: expect.objectContaining({
organization: expect.objectContaining({
org: { case: "orgId", value: "org-from-domain" },
}),
organizationId: "org-from-domain",
}),
});
});
test("should fallback to default organization when not resolved", async () => {
mockGetDefaultOrg.mockResolvedValue({ id: "default-org" });
mockAddHuman.mockResolvedValue({ userId: "newuser123" });
mockCreateUser.mockResolvedValue({ id: "newuser123" });
await processIDPCallback({
...defaultParams,
@@ -693,12 +869,10 @@ describe("processIDPCallback", () => {
expect(mockGetDefaultOrg).toHaveBeenCalledWith({
serviceConfig: { baseUrl: "https://api.example.com" },
});
expect(mockAddHuman).toHaveBeenCalledWith({
expect(mockCreateUser).toHaveBeenCalledWith({
serviceConfig: { baseUrl: "https://api.example.com" },
request: expect.objectContaining({
organization: expect.objectContaining({
org: { case: "orgId", value: "default-org" },
}),
organizationId: "default-org",
}),
});
});
@@ -712,13 +886,13 @@ describe("processIDPCallback", () => {
});
expect(mockGetDefaultOrg).toHaveBeenCalled();
expect(mockAddHuman).not.toHaveBeenCalled();
expect(mockCreateUser).not.toHaveBeenCalled();
expect(result.redirect).toContain("/idp/google/failure");
expect(result.redirect).toContain("error=no_organization_context");
});
test("should return error redirect when user creation fails", async () => {
mockAddHuman.mockRejectedValue(new Error("Creation failed"));
mockCreateUser.mockRejectedValue(new Error("Creation failed"));
const result = await processIDPCallback(defaultParams);
@@ -727,7 +901,7 @@ describe("processIDPCallback", () => {
});
test("should return error when session creation fails after user creation", async () => {
mockAddHuman.mockResolvedValue({ userId: "newuser123" });
mockCreateUser.mockResolvedValue({ id: "newuser123" });
mockCreateNewSessionFromIdpIntent.mockResolvedValue({
error: "Session error",
});
@@ -753,7 +927,7 @@ describe("processIDPCallback", () => {
const result = await processIDPCallback(defaultParams);
expect(mockAddHuman).not.toHaveBeenCalled();
expect(mockCreateUser).not.toHaveBeenCalled();
expect(result.redirect).toContain("/idp/google/complete-registration");
expect(result.redirect).toContain("id=intent123");
expect(result.redirect).toContain("token=token123");
@@ -777,7 +951,7 @@ describe("processIDPCallback", () => {
const result = await processIDPCallback(defaultParams);
expect(mockAddHuman).not.toHaveBeenCalled();
expect(mockCreateUser).not.toHaveBeenCalled();
expect(result.redirect).toContain("/idp/google/complete-registration");
expect(result.redirect).toContain("id=intent123");
expect(result.redirect).toContain("token=token123");
@@ -801,7 +975,7 @@ describe("processIDPCallback", () => {
const result = await processIDPCallback(defaultParams);
expect(mockAddHuman).not.toHaveBeenCalled();
expect(mockCreateUser).not.toHaveBeenCalled();
expect(result.redirect).toContain("/idp/google/complete-registration");
expect(result.redirect).toContain("id=intent123");
expect(result.redirect).toContain("token=token123");
@@ -820,7 +994,7 @@ describe("processIDPCallback", () => {
const result = await processIDPCallback(defaultParams);
expect(mockAddHuman).not.toHaveBeenCalled();
expect(mockCreateUser).not.toHaveBeenCalled();
expect(result.redirect).toContain("/idp/google/complete-registration");
expect(result.redirect).toContain("id=intent123");
expect(result.redirect).toContain("token=token123");
@@ -847,7 +1021,7 @@ describe("processIDPCallback", () => {
});
expect(mockGetDefaultOrg).toHaveBeenCalled();
expect(mockAddHuman).not.toHaveBeenCalled();
expect(mockCreateUser).not.toHaveBeenCalled();
expect(result.redirect).toContain("/idp/google/complete-registration");
expect(result.redirect).toContain("organization=default-org");
});
@@ -1009,7 +1183,7 @@ describe("processIDPCallback", () => {
// Should link, not create
expect(mockAddIDPLink).toHaveBeenCalled();
expect(mockAddHuman).not.toHaveBeenCalled();
expect(mockCreateUser).not.toHaveBeenCalled();
});
test("should prioritize auto-creation over manual creation", async () => {
@@ -1027,12 +1201,12 @@ describe("processIDPCallback", () => {
},
},
});
mockAddHuman.mockResolvedValue({ userId: "newuser123" });
mockCreateUser.mockResolvedValue({ id: "newuser123" });
const result = await processIDPCallback(defaultParams);
// Should auto-create, not redirect to manual form
expect(mockAddHuman).toHaveBeenCalled();
expect(mockCreateUser).toHaveBeenCalled();
expect(result.redirect).toBe("https://app.example.com/success");
expect(result.redirect).not.toContain("complete-registration");
});
+192 -48
View File
@@ -5,8 +5,8 @@ import { isClassifiedError } from "@/lib/grpc/interceptors/error-classification"
import { createLogger } from "@/lib/logger";
import { getServiceConfig } from "@/lib/service-url";
import {
addHuman,
addIDPLink,
createUser,
getActiveIdentityProviders,
getDefaultOrg,
getIDPByID,
@@ -17,15 +17,17 @@ import {
listUsers,
retrieveIDPIntent,
ServiceConfig,
updateHuman,
updateUser,
} from "@/lib/zitadel";
import { Code, create } from "@zitadel/client";
import { AutoLinkingOption } from "@zitadel/proto/zitadel/idp/v2/idp_pb";
import { OrganizationSchema } from "@zitadel/proto/zitadel/object/v2/object_pb";
import { SetHumanEmail } from "@zitadel/proto/zitadel/user/v2/email_pb";
import { SetHumanProfile } from "@zitadel/proto/zitadel/user/v2/user_pb";
import {
AddHumanUserRequest,
AddHumanUserRequestSchema,
UpdateHumanUserRequestSchema,
CreateUserRequest,
CreateUserRequestSchema,
UpdateUserRequest,
UpdateUserRequestSchema,
} from "@zitadel/proto/zitadel/user/v2/user_service_pb";
import crypto from "crypto";
import { getTranslations } from "next-intl/server";
@@ -37,19 +39,166 @@ const logger = createLogger("idp-intent");
const ORG_SUFFIX_REGEX = /(?<=@)(.+)/;
type IDPIntentResult = Awaited<ReturnType<typeof retrieveIDPIntent>>;
/**
* Flattened view of the "create user" information contained in an IDP intent response,
* used for reads (organization resolution, required-field checks and form pre-filling).
*/
interface ResolvedCreateUser {
username?: string;
profile?: SetHumanProfile;
email?: SetHumanEmail;
}
/**
* Reads the "create user" information from an IDP intent response.
*
* Prefers the new `user_action.create_user` oneof (CreateUserRequest, which supports metadata).
* Falls back to the deprecated `add_human_user` field so that responses from older API versions
* keep working during the transition.
*/
function resolveCreateUser(intent: IDPIntentResult): ResolvedCreateUser | undefined {
if (intent.userAction?.case === "createUser") {
const request = intent.userAction.value;
const human = request.userType?.case === "human" ? request.userType.value : undefined;
return {
username: request.username,
profile: human?.profile,
email: human?.email,
};
}
// Fallback: deprecated add_human_user (does not support the new top-level metadata).
if (intent.addHumanUser) {
return {
username: intent.addHumanUser.username,
profile: intent.addHumanUser.profile,
email: intent.addHumanUser.email,
};
}
return undefined;
}
/**
* Builds the request for the non-deprecated CreateUser endpoint from an IDP intent response.
*
* Prefers the new `user_action.create_user` oneof: the action's request is passed through as-is
* and only the resolved organization is injected. Falls back to mapping the deprecated
* `add_human_user` (flat) fields into the new nested shape for older API responses.
*/
function buildCreateUserRequest(intent: IDPIntentResult, organizationId: string): CreateUserRequest | undefined {
if (intent.userAction?.case === "createUser") {
return create(CreateUserRequestSchema, {
...intent.userAction.value,
organizationId,
});
}
// Fallback: map the deprecated flat add_human_user payload into the CreateUserRequest shape.
if (intent.addHumanUser) {
const add = intent.addHumanUser;
return create(CreateUserRequestSchema, {
organizationId,
username: add.username,
userType: {
case: "human",
value: {
profile: add.profile,
email: add.email,
phone: add.phone,
idpLinks: add.idpLinks,
},
},
// SetMetadataEntry and Metadata share the same {key, value} shape; map explicitly.
metadata: add.metadata?.map((entry) => ({ key: entry.key, value: entry.value })),
});
}
return undefined;
}
/**
* Builds the request for the non-deprecated UpdateUser endpoint from an IDP intent response.
*
* Prefers the new `user_action.update_user` oneof (UpdateUserRequest, which supports metadata).
* Falls back to the deprecated `update_human_user` field for older API responses.
*
* Note: we intentionally sync only profile, email, phone and metadata here (not the username) to
* preserve the existing auto-update behaviour and avoid invalidating sessions on every login.
*/
function buildUpdateUserRequest(intent: IDPIntentResult, userId: string): UpdateUserRequest | undefined {
if (intent.userAction?.case === "updateUser") {
const request = intent.userAction.value;
// UpdateUserRequest.user_type is optional and also supports machine updates. Only sync a
// human profile (IDP users are human); for any other case we still carry the metadata over,
// since metadata is a top-level field independent of the user type.
const human = request.userType?.case === "human" ? request.userType.value : undefined;
// Nothing to apply: neither human profile data nor metadata. Skip the update entirely.
if (!human && (request.metadata?.length ?? 0) === 0) {
return undefined;
}
return create(UpdateUserRequestSchema, {
userId,
userType: human
? {
case: "human",
value: {
profile: human.profile,
email: human.email,
phone: human.phone,
},
}
: undefined,
metadata: request.metadata,
});
}
// Fallback: deprecated update_human_user (has no metadata support).
if (intent.updateHumanUser) {
const update = intent.updateHumanUser;
return create(UpdateUserRequestSchema, {
userId,
userType: {
case: "human",
value: {
// The deprecated profile is a SetHumanProfile; map it to the Update profile shape.
profile: update.profile
? {
givenName: update.profile.givenName,
familyName: update.profile.familyName,
nickName: update.profile.nickName,
displayName: update.profile.displayName,
preferredLanguage: update.profile.preferredLanguage,
gender: update.profile.gender,
}
: undefined,
email: update.email,
phone: update.phone,
},
},
});
}
return undefined;
}
async function resolveOrganizationForUser({
organization,
addHumanUser,
createUserData,
serviceConfig,
}: {
organization?: string;
addHumanUser?: AddHumanUserRequest;
createUserData?: ResolvedCreateUser;
serviceConfig: ServiceConfig;
}): Promise<string | undefined> {
if (organization) return organization;
if (addHumanUser?.username && ORG_SUFFIX_REGEX.test(addHumanUser.username)) {
const matched = ORG_SUFFIX_REGEX.exec(addHumanUser.username);
if (createUserData?.username && ORG_SUFFIX_REGEX.test(createUserData.username)) {
const matched = ORG_SUFFIX_REGEX.exec(createUserData.username);
const suffix = matched?.[1] ?? "";
const orgs = await getOrgsByDomain({ serviceConfig, domain: suffix });
@@ -103,7 +252,6 @@ export async function validateIDPLinkingPermissions({
return true;
}
type IDPIntentResult = Awaited<ReturnType<typeof retrieveIDPIntent>>;
type IDPConfig = Awaited<ReturnType<typeof getIDPByID>>;
interface IDPHandlerContext {
@@ -292,23 +440,17 @@ async function handleExplicitLinking(ctx: IDPHandlerContext): Promise<IDPHandler
*/
async function handleUserExists(ctx: IDPHandlerContext): Promise<IDPHandlerResult> {
const { sessionId } = ctx.params;
const { userId, updateHumanUser } = ctx.intent;
const { userId } = ctx.intent;
const { options, serviceConfig, t } = ctx;
if (userId && !sessionId) {
// Auto-update user if enabled
if (options?.isAutoUpdate && updateHumanUser) {
// Auto-update user if enabled. Uses the non-deprecated UpdateUser endpoint so that
// metadata provided by the action response is applied in the same request.
const updateUserRequest = options?.isAutoUpdate ? buildUpdateUserRequest(ctx.intent, userId) : undefined;
if (updateUserRequest) {
try {
logger.debug("Auto-updating user profile");
await updateHuman({
serviceConfig,
request: create(UpdateHumanUserRequestSchema, {
userId,
profile: updateHumanUser.profile,
email: updateHumanUser.email,
phone: updateHumanUser.phone,
}),
});
await updateUser({ serviceConfig, request: updateUserRequest });
} catch (error) {
logger.warn("Failed to auto-update user", { error });
// Continue with login even if update fails
@@ -353,14 +495,15 @@ async function handleUserExists(ctx: IDPHandlerContext): Promise<IDPHandlerResul
*/
async function handleAutoLinking(ctx: IDPHandlerContext): Promise<IDPHandlerResult> {
const { options, intent, serviceConfig, buildRedirectParams, t } = ctx;
const { addHumanUser, idpInformation } = intent;
const { idpInformation } = intent;
const createUserData = resolveCreateUser(intent);
const { organization, provider } = ctx.params;
if (options?.autoLinking) {
let foundUser;
const email = addHumanUser?.email?.email;
const email = createUserData?.email?.email;
const emailVerified =
addHumanUser?.email?.verification?.case === "isVerified" && addHumanUser?.email?.verification?.value;
createUserData?.email?.verification?.case === "isVerified" && createUserData?.email?.verification?.value;
if (options.autoLinking === AutoLinkingOption.EMAIL && email && emailVerified) {
foundUser = await listUsers({ serviceConfig, email, organizationId: organization }).then((response) => {
@@ -452,13 +595,14 @@ async function handleAutoLinking(ctx: IDPHandlerContext): Promise<IDPHandlerResu
*/
async function handleAutoCreation(ctx: IDPHandlerContext): Promise<IDPHandlerResult> {
const { options, intent, serviceConfig, buildRedirectParams, t } = ctx;
const { addHumanUser, idpInformation } = intent;
const { idpInformation } = intent;
const createUserData = resolveCreateUser(intent);
const { organization, provider } = ctx.params;
if (options?.isAutoCreation && addHumanUser) {
if (options?.isAutoCreation && createUserData) {
const orgToRegisterOn = await resolveOrganizationForUser({
organization,
addHumanUser,
createUserData,
serviceConfig,
});
@@ -469,7 +613,7 @@ async function handleAutoCreation(ctx: IDPHandlerContext): Promise<IDPHandlerRes
}
// Check if required profile fields are present
if (!addHumanUser.profile?.givenName || !addHumanUser.profile?.familyName) {
if (!createUserData.profile?.givenName || !createUserData.profile?.familyName) {
logger.info("Missing required profile fields (givenName or familyName), redirecting to complete registration");
if (!idpInformation!.userId) {
@@ -485,31 +629,30 @@ async function handleAutoCreation(ctx: IDPHandlerContext): Promise<IDPHandlerRes
idpUserId: idpInformation!.userId,
idpUserName: idpInformation!.userName || "",
// User data for pre-filling form
givenName: addHumanUser.profile?.givenName || "",
familyName: addHumanUser.profile?.familyName || "",
email: addHumanUser.email?.email || "",
givenName: createUserData.profile?.givenName || "",
familyName: createUserData.profile?.familyName || "",
email: createUserData.email?.email || "",
},
true,
);
return { redirect: `/idp/${provider}/complete-registration?${params}` };
}
const organizationSchema = create(OrganizationSchema, {
org: { case: "orgId", value: orgToRegisterOn },
});
const createUserRequest = buildCreateUserRequest(intent, orgToRegisterOn);
const addHumanUserWithOrganization = create(AddHumanUserRequestSchema, {
...addHumanUser,
organization: organizationSchema,
});
if (!createUserRequest) {
logger.error("Could not build create user request from intent");
const params = buildRedirectParams();
return { redirect: `/idp/${provider}/failure?${params}&error=user_creation_failed` };
}
try {
const newUser = await addHuman({ serviceConfig, request: addHumanUserWithOrganization });
const newUser = await createUser({ serviceConfig, request: createUserRequest });
logger.info("User auto-created successfully, creating session");
// Create session for newly created user
const sessionResult = await createNewSessionFromIdpIntent({
userId: newUser.userId,
userId: newUser.id,
idpIntent: {
idpIntentId: ctx.params.id,
idpIntentToken: ctx.params.token,
@@ -549,13 +692,14 @@ async function handleAutoCreation(ctx: IDPHandlerContext): Promise<IDPHandlerRes
*/
async function handleManualCreation(ctx: IDPHandlerContext): Promise<IDPHandlerResult> {
const { options, intent, serviceConfig, buildRedirectParams } = ctx;
const { addHumanUser, idpInformation } = intent;
const { idpInformation } = intent;
const createUserData = resolveCreateUser(intent);
const { organization, provider } = ctx.params;
if (options?.isCreationAllowed && addHumanUser) {
if (options?.isCreationAllowed && createUserData) {
const orgToRegisterOn = await resolveOrganizationForUser({
organization,
addHumanUser,
createUserData,
serviceConfig,
});
@@ -581,9 +725,9 @@ async function handleManualCreation(ctx: IDPHandlerContext): Promise<IDPHandlerR
idpUserId: idpInformation!.userId,
idpUserName: idpInformation!.userName || "",
// User data for pre-filling form
givenName: addHumanUser.profile?.givenName || "",
familyName: addHumanUser.profile?.familyName || "",
email: addHumanUser.email?.email || "",
givenName: createUserData.profile?.givenName || "",
familyName: createUserData.profile?.familyName || "",
email: createUserData.email?.email || "",
},
true,
); // includeToken=true
+18
View File
@@ -19,12 +19,14 @@ import { SendInviteCodeSchema } from "@zitadel/proto/zitadel/user/v2/user_pb";
import {
AddHumanUserRequest,
AddHumanUserRequestSchema,
CreateUserRequest,
ResendEmailCodeRequest,
ResendEmailCodeRequestSchema,
SendEmailCodeRequestSchema,
SetPasswordRequest,
SetPasswordRequestSchema,
UpdateHumanUserRequest,
UpdateUserRequest,
UserService,
VerifyPasskeyRegistrationRequest,
VerifyU2FRegistrationRequest,
@@ -485,6 +487,22 @@ export async function updateHuman({
return userService.updateHumanUser(request);
}
// createUser calls the non-deprecated CreateUser endpoint, which supports user metadata
// (unlike the deprecated addHumanUser/AddHumanUserRequest flow).
export async function createUser({ serviceConfig, request }: WithServiceConfig<{ request: CreateUserRequest }>) {
const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig);
return userService.createUser(request);
}
// updateUser calls the non-deprecated UpdateUser endpoint, which supports updating user
// metadata in the same request (unlike the deprecated updateHuman/UpdateHumanUserRequest flow).
export async function updateUser({ serviceConfig, request }: WithServiceConfig<{ request: UpdateUserRequest }>) {
const userService: Client<typeof UserService> = await createServiceForHost(UserService, serviceConfig);
return userService.updateUser(request);
}
export async function verifyTOTPRegistration({
serviceConfig,
code,