diff --git a/apps/docs/content/guides/integrate/actions/testing-response-manipulation.mdx b/apps/docs/content/guides/integrate/actions/testing-response-manipulation.mdx
index 30d4b026c..085931690 100644
--- a/apps/docs/content/guides/integrate/actions/testing-response-manipulation.mdx
+++ b/apps/docs/content/guides/integrate/actions/testing-response-manipulation.mdx
@@ -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.
+
+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.
+
+
```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": "test-user@zitadel.ch",
+ "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": "test-user@zitadel.ch",
+ "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
diff --git a/apps/login/src/lib/server/idp-intent.test.ts b/apps/login/src/lib/server/idp-intent.test.ts
index 9f4822a01..a92ca83fb 100644
--- a/apps/login/src/lib/server/idp-intent.test.ts
+++ b/apps/login/src/lib/server/idp-intent.test.ts
@@ -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: "test@example.com" },
+ },
+ },
+ 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: "test@example.com" },
+ }),
+ }),
+ 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: "test@example.com" },
+ 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");
});
diff --git a/apps/login/src/lib/server/idp-intent.ts b/apps/login/src/lib/server/idp-intent.ts
index f1ef5178f..a6ad4f2b8 100644
--- a/apps/login/src/lib/server/idp-intent.ts
+++ b/apps/login/src/lib/server/idp-intent.ts
@@ -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>;
+
+/**
+ * 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 {
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>;
type IDPConfig = Awaited>;
interface IDPHandlerContext {
@@ -292,23 +440,17 @@ async function handleExplicitLinking(ctx: IDPHandlerContext): Promise {
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 {
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 {
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 {
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) {
+ const userService: Client = 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 = await createServiceForHost(UserService, serviceConfig);
+
+ return userService.updateUser(request);
+}
+
export async function verifyTOTPRegistration({
serviceConfig,
code,