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,