feat(scim): add Microsoft Entra ID SCIM Compatibility (#6589)

Co-authored-by: Cem Cevik <soslubok01@gmail.com>
Co-authored-by: Alex Yang <himself65@outlook.com>
This commit is contained in:
Cem Çevik
2026-01-13 23:40:12 +01:00
committed by Alex Yang
parent 3fa5e4179c
commit d8a1ae2ffe
3 changed files with 521 additions and 20 deletions

View File

@@ -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<string, any>;
account: Record<string, any>;
};
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<string, Mapping> = {
"/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<string, unknown> => {
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<string, unknown>)[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<string, any> = {};
const accountPatch: Record<string, any> = {};
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;

View File

@@ -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:

View File

@@ -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();