mirror of
https://github.com/better-auth/better-auth.git
synced 2026-06-01 03:46:39 -05:00
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:
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user