From d8a1ae2ffe9c067d9dc5220101156eea4297f3d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cem=20=C3=87evik?= Date: Tue, 13 Jan 2026 23:40:12 +0100 Subject: [PATCH] feat(scim): add Microsoft Entra ID SCIM Compatibility (#6589) Co-authored-by: Cem Cevik Co-authored-by: Alex Yang --- packages/scim/src/patch-operations.ts | 98 +++++- packages/scim/src/routes.ts | 8 +- packages/scim/src/scim.test.ts | 435 +++++++++++++++++++++++++- 3 files changed, 521 insertions(+), 20 deletions(-) diff --git a/packages/scim/src/patch-operations.ts b/packages/scim/src/patch-operations.ts index ea53f95b1d..10f15e6958 100644 --- a/packages/scim/src/patch-operations.ts +++ b/packages/scim/src/patch-operations.ts @@ -10,19 +10,25 @@ type Operation = { type Mapping = { target: string; resource: "user" | "account"; - map: (user: User, op: Operation) => any; + map: (user: User, op: Operation, resources: Resources) => any; }; -const identity = (user: User, op: Operation) => { +type Resources = { + user: Record; + account: Record; +}; + +const identity = (user: User, op: Operation, resources: Resources) => { return op.value; }; -const lowerCase = (user: User, op: Operation) => { +const lowerCase = (user: User, op: Operation, resources: Resources) => { return op.value.toLowerCase(); }; -const givenName = (user: User, op: Operation) => { - const familyName = user.name.split(" ").slice(1).join(" ").trim(); +const givenName = (user: User, op: Operation, resources: Resources) => { + const currentName = (resources.user.name as string) ?? user.name; + const familyName = currentName.split(" ").slice(1).join(" ").trim(); const givenName = op.value; return getUserFullName(user.email, { @@ -31,9 +37,10 @@ const givenName = (user: User, op: Operation) => { }); }; -const familyName = (user: User, op: Operation) => { +const familyName = (user: User, op: Operation, resources: Resources) => { + const currentName = (resources.user.name as string) ?? user.name; const givenName = ( - user.name.split(" ").slice(0, -1).join(" ") || user.name + currentName.split(" ").slice(0, -1).join(" ") || currentName ).trim(); const familyName = op.value; return getUserFullName(user.email, { @@ -58,22 +65,83 @@ const userPatchMappings: Record = { "/userName": { resource: "user", target: "email", map: lowerCase }, }; +const normalizePath = (path: string): string => { + const withoutLeadingSlash = path.startsWith("/") ? path.slice(1) : path; + return `/${withoutLeadingSlash.replaceAll(".", "/")}`; +}; + +const isNestedObject = (value: unknown): value is Record => { + return typeof value === "object" && value !== null && !Array.isArray(value); +}; + +const applyMapping = ( + user: User, + resources: Resources, + path: string, + value: unknown, + op: "add" | "replace", +) => { + const normalizedPath = normalizePath(path); + const mapping = userPatchMappings[normalizedPath]; + + if (!mapping) { + return; + } + + const newValue = mapping.map( + user, + { + op, + value, + path: normalizedPath, + }, + resources, + ); + + if (op === "add" && mapping.resource === "user") { + const currentValue = (user as Record)[mapping.target]; + if (currentValue === newValue) { + return; + } + } + + resources[mapping.resource][mapping.target] = newValue; +}; + +const applyPatchValue = ( + user: User, + resources: Resources, + value: unknown, + op: "add" | "replace", + path?: string | undefined, +) => { + if (isNestedObject(value)) { + for (const [key, nestedValue] of Object.entries(value)) { + const nestedPath = path ? `${path}.${key}` : key; + applyPatchValue(user, resources, nestedValue, op, nestedPath); + } + } else if (path) { + applyMapping(user, resources, path, value, op); + } +}; + export const buildUserPatch = (user: User, operations: Operation[]) => { const userPatch: Record = {}; const accountPatch: Record = {}; - - const resources = { user: userPatch, account: accountPatch }; + const resources: Resources = { user: userPatch, account: accountPatch }; for (const operation of operations) { - if (operation.op !== "replace" || !operation.path) { + if (operation.op !== "add" && operation.op !== "replace") { continue; } - const mapping = userPatchMappings[operation.path]; - if (mapping) { - const resource = resources[mapping.resource]; - resource[mapping.target] = mapping.map(user, operation); - } + applyPatchValue( + user, + resources, + operation.value, + operation.op, + operation.path, + ); } return resources; diff --git a/packages/scim/src/routes.ts b/packages/scim/src/routes.ts index 358545e17c..9775251471 100644 --- a/packages/scim/src/routes.ts +++ b/packages/scim/src/routes.ts @@ -536,7 +536,11 @@ const patchSCIMUserBodySchema = z.object({ ), Operations: z.array( z.object({ - op: z.enum(["replace", "add", "remove"]).default("replace"), + op: z + .string() + .toLowerCase() + .default("replace") + .pipe(z.enum(["replace", "add", "remove"])), path: z.string().optional(), value: z.any(), }), @@ -623,7 +627,7 @@ export const deleteSCIMUser = (authMiddleware: AuthMiddleware) => method: "DELETE", metadata: { ...HIDE_METADATA, - allowedMediaTypes: supportedMediaTypes, + allowedMediaTypes: [...supportedMediaTypes, ""], openapi: { summary: "Delete SCIM user", description: diff --git a/packages/scim/src/scim.test.ts b/packages/scim/src/scim.test.ts index a683228877..0d940b7add 100644 --- a/packages/scim/src/scim.test.ts +++ b/packages/scim/src/scim.test.ts @@ -1310,7 +1310,10 @@ describe("SCIM", () => { }); describe("PATCH /scim/v2/users", () => { - it("should partially update a user resource", async () => { + it.each([ + "replace", + "add", + ])("should partially update a user resource with %s", async (op) => { const { auth, getSCIMToken } = createTestInstance(); const scimToken = await getSCIMToken(); @@ -1340,9 +1343,85 @@ describe("SCIM", () => { body: { schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], Operations: [ - { op: "replace", path: "/externalId", value: "external-username" }, + { op: op, path: "/externalId", value: "external-username" }, + { op: op, path: "/userName", value: "other-username" }, + { op: op, path: "/name/givenName", value: "Daniel" }, + ], + }, + headers: { + authorization: `Bearer ${scimToken}`, + }, + }); + + const updatedUser = await auth.api.getSCIMUser({ + params: { + userId: user.id, + }, + headers: { + authorization: `Bearer ${scimToken}`, + }, + }); + + expect(updatedUser).toMatchObject({ + active: true, + displayName: "Daniel Perez", + emails: [ + { + primary: true, + value: "other-username", + }, + ], + externalId: "external-username", + id: expect.any(String), + meta: expect.objectContaining({ + created: expect.any(Date), + lastModified: expect.any(Date), + location: expect.stringContaining("/api/auth/scim/v2/Users/"), + resourceType: "User", + }), + name: { + formatted: "Daniel Perez", + }, + schemas: expect.arrayContaining([ + "urn:ietf:params:scim:schemas:core:2.0:User", + ]), + userName: "other-username", + }); + }); + + it("should partially update a user resource with mixed operations", async () => { + const { auth, getSCIMToken } = createTestInstance(); + const scimToken = await getSCIMToken(); + + const user = await auth.api.createSCIMUser({ + body: { + userName: "the-username", + name: { + formatted: "Juan Perez", + }, + emails: [{ value: "primary-email@test.com", primary: true }], + }, + headers: { + authorization: `Bearer ${scimToken}`, + }, + }); + + expect(user).toBeTruthy(); + expect(user.externalId).toBe("the-username"); + expect(user.userName).toBe("primary-email@test.com"); + expect(user.name.formatted).toBe("Juan Perez"); + expect(user.emails[0]?.value).toBe("primary-email@test.com"); + + await auth.api.patchSCIMUser({ + params: { + userId: user.id, + }, + body: { + schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + Operations: [ + { op: "add", path: "/externalId", value: "external-username" }, { op: "replace", path: "/userName", value: "other-username" }, - { op: "replace", path: "/name/formatted", value: "Daniel Lopez" }, + { op: "add", path: "/name/formatted", value: "Daniel Lopez" }, ], }, headers: { @@ -1386,6 +1465,356 @@ describe("SCIM", () => { }); }); + it.each([ + "replace", + "add", + ])("should partially update multiple name sub-attributes with %s", async (op) => { + const { auth, getSCIMToken } = createTestInstance(); + const scimToken = await getSCIMToken(); + + const user = await auth.api.createSCIMUser({ + body: { + userName: "sub-attribute-test-user", + name: { + formatted: "Original Name", + }, + }, + headers: { + authorization: `Bearer ${scimToken}`, + }, + }); + + await auth.api.patchSCIMUser({ + params: { userId: user.id }, + body: { + schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + Operations: [ + { op: op, path: "/name/givenName", value: "Updated" }, + { op: op, path: "/name/familyName", value: "Value" }, + ], + }, + headers: { + authorization: `Bearer ${scimToken}`, + }, + }); + + const updatedUser = await auth.api.getSCIMUser({ + params: { userId: user.id }, + headers: { + authorization: `Bearer ${scimToken}`, + }, + }); + + expect(updatedUser.name.formatted).toBe("Updated Value"); + }); + + it.each([ + "replace", + "add", + ])("should %s nested object values with path prefix", async (op) => { + const { auth, getSCIMToken } = createTestInstance(); + const scimToken = await getSCIMToken(); + + const user = await auth.api.createSCIMUser({ + body: { + userName: "nested-test-user", + name: { formatted: "Original Name" }, + }, + headers: { + authorization: `Bearer ${scimToken}`, + }, + }); + + await auth.api.patchSCIMUser({ + params: { userId: user.id }, + body: { + schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + Operations: [ + { + op: op, + path: "name", + value: { givenName: "Nested" }, + }, + { + op: op, + path: "name", + value: { familyName: "User" }, + }, + { + op: op, + path: "userName", + value: "nested-test-user-updated", + }, + ], + }, + headers: { + authorization: `Bearer ${scimToken}`, + }, + }); + + const updatedUser = await auth.api.getSCIMUser({ + params: { userId: user.id }, + headers: { + authorization: `Bearer ${scimToken}`, + }, + }); + + expect(updatedUser.name.formatted).toBe("Nested User"); + expect(updatedUser.displayName).toBe("Nested User"); + expect(updatedUser.userName).toBe("nested-test-user-updated"); + }); + + it.each([ + "replace", + "add", + ])("should support operations without explicit path with %s", async (op) => { + const { auth, getSCIMToken } = createTestInstance(); + const scimToken = await getSCIMToken(); + + const user = await auth.api.createSCIMUser({ + body: { + userName: "no-path-test-user", + }, + headers: { + authorization: `Bearer ${scimToken}`, + }, + }); + + await auth.api.patchSCIMUser({ + params: { userId: user.id }, + body: { + schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + Operations: [ + { + op: op, + value: { + name: { formatted: "No Path Name" }, + userName: "Username", + }, + }, + ], + }, + headers: { + authorization: `Bearer ${scimToken}`, + }, + }); + + const updatedUser = await auth.api.getSCIMUser({ + params: { userId: user.id }, + headers: { + authorization: `Bearer ${scimToken}`, + }, + }); + + expect(updatedUser.name.formatted).toBe("No Path Name"); + expect(updatedUser.userName).toBe("username"); + }); + + it("should support dot notation in paths", async () => { + const { auth, getSCIMToken } = createTestInstance(); + const scimToken = await getSCIMToken(); + + const user = await auth.api.createSCIMUser({ + body: { + userName: "dot-notation-user", + name: { formatted: "Original Name" }, + }, + headers: { + authorization: `Bearer ${scimToken}`, + }, + }); + + await auth.api.patchSCIMUser({ + params: { userId: user.id }, + body: { + schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + Operations: [ + { op: "replace", path: "name.familyName", value: "Dot" }, + { op: "add", path: "name.givenName", value: "User" }, + { op: "add", path: "userName", value: "Username" }, + ], + }, + headers: { + authorization: `Bearer ${scimToken}`, + }, + }); + + const updatedUser = await auth.api.getSCIMUser({ + params: { userId: user.id }, + headers: { + authorization: `Bearer ${scimToken}`, + }, + }); + + expect(updatedUser.name.formatted).toBe("User Dot"); + expect(updatedUser.userName).toBe("username"); + }); + + it.each([ + "replace", + "add", + ])("should handle %s operation case-insensitively", async (op) => { + const { auth, getSCIMToken } = createTestInstance(); + const scimToken = await getSCIMToken(); + + const user = await auth.api.createSCIMUser({ + body: { + userName: "user-case-insensitive", + name: { formatted: "Original" }, + }, + headers: { + authorization: `Bearer ${scimToken}`, + }, + }); + + await auth.api.patchSCIMUser({ + params: { userId: user.id }, + body: { + schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + Operations: [ + { + op: op.toUpperCase(), + path: "name.formatted", + value: "user-case", + }, + ], + }, + headers: { + authorization: `Bearer ${scimToken}`, + }, + }); + + const updatedUser = await auth.api.getSCIMUser({ + params: { userId: user.id }, + headers: { + authorization: `Bearer ${scimToken}`, + }, + }); + + expect(updatedUser.name.formatted).toBe("user-case"); + }); + + it("should skip add operation when value already exists", async () => { + const { auth, getSCIMToken } = createTestInstance(); + const scimToken = await getSCIMToken(); + + const user = await auth.api.createSCIMUser({ + body: { + userName: "add-same-info-user", + name: { formatted: "Existing Name" }, + }, + headers: { + authorization: `Bearer ${scimToken}`, + }, + }); + + const patchUser = () => + auth.api.patchSCIMUser({ + params: { userId: user.id }, + body: { + schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + Operations: [ + { op: "add", path: "/name/formatted", value: "Existing Name" }, + ], + }, + headers: { + authorization: `Bearer ${scimToken}`, + }, + }); + + await expect(patchUser()).rejects.toThrowError( + expect.objectContaining({ + message: "No valid fields to update", + body: { + detail: "No valid fields to update", + schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"], + status: "400", + }, + }), + ); + }); + + it.each([ + "replace", + "add", + ])("should ignore %s on non-existing path", async (op) => { + const { auth, getSCIMToken } = createTestInstance(); + const scimToken = await getSCIMToken(); + + const user = await auth.api.createSCIMUser({ + body: { + userName: "non-existing-path", + name: { formatted: "Original Name" }, + }, + headers: { + authorization: `Bearer ${scimToken}`, + }, + }); + + const patchUser = () => + auth.api.patchSCIMUser({ + params: { userId: user.id }, + body: { + schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + Operations: [ + { op: op, path: "/nonExistentField", value: "Some Value" }, + ], + }, + headers: { + authorization: `Bearer ${scimToken}`, + }, + }); + + await expect(patchUser()).rejects.toThrowError( + expect.objectContaining({ + message: "No valid fields to update", + body: { + detail: "No valid fields to update", + schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"], + status: "400", + }, + }), + ); + }); + + it("should ignore non-existing operation", async () => { + const { auth, getSCIMToken } = createTestInstance(); + const scimToken = await getSCIMToken(); + + const user = await auth.api.createSCIMUser({ + body: { + userName: "non-existing-operation", + }, + headers: { + authorization: `Bearer ${scimToken}`, + }, + }); + + const patchUser = () => + auth.api.patchSCIMUser({ + params: { userId: user.id }, + body: { + schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], + Operations: [ + { op: "update", path: "userName", value: "Some Value" }, + ], + }, + headers: { + authorization: `Bearer ${scimToken}`, + }, + }); + + await expect(patchUser()).rejects.toThrowError( + expect.objectContaining({ + body: { + code: "VALIDATION_ERROR", + message: + '[body.Operations.0.op] Invalid option: expected one of "replace"|"add"|"remove"', + }, + }), + ); + }); + it("should return not found for missing users", async () => { const { auth, getSCIMToken } = createTestInstance(); const scimToken = await getSCIMToken();