mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user