Skip to content

SCIM 2.0 Client

Overview

@authrim/server includes a SCIM 2.0 client for programmatic user and group provisioning. The ScimClient class provides full CRUD operations, filtering, pagination, conditional GET with ETags, and optimistic locking.

ScimClient Initialization

import { ScimClient } from '@authrim/server';
const scim = new ScimClient({
baseUrl: 'https://auth.example.com/scim/v2',
accessToken: 'scim-bearer-token',
});

Configuration Options

OptionTypeRequiredDescription
baseUrlstringYesSCIM 2.0 endpoint base URL
accessTokenstringYesBearer token for SCIM API authentication
httpHttpProviderNoCustom HTTP provider (defaults to fetch)

Custom HTTP Provider

import { fetchHttpProvider } from '@authrim/server';
const scim = new ScimClient({
baseUrl: 'https://auth.example.com/scim/v2',
accessToken: 'scim-bearer-token',
http: fetchHttpProvider({ timeoutMs: 10000 }),
});

User Operations

createUser

Create a new user:

const user = await scim.createUser({
schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'],
userName: '[email protected]',
name: {
givenName: 'Jane',
familyName: 'Doe',
},
emails: [
{ value: '[email protected]', primary: true },
],
active: true,
});
console.log('Created user:', user.id);

getUser

Retrieve a user by ID:

const user = await scim.getUser('user-id-123');
console.log('User:', user.userName);
console.log('Active:', user.active);

listUsers

List users with optional filtering and pagination:

const result = await scim.listUsers({
filter: 'userName eq "[email protected]"',
startIndex: 1,
count: 25,
});
console.log('Total results:', result.totalResults);
for (const user of result.Resources) {
console.log(user.userName);
}

updateUser

Replace a user resource entirely (PUT):

const updated = await scim.updateUser('user-id-123', {
schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'],
userName: '[email protected]',
name: {
givenName: 'Jane',
familyName: 'Smith',
},
emails: [
{ value: '[email protected]', primary: true },
],
active: true,
});

patchUser

Apply partial modifications (PATCH):

await scim.patchUser('user-id-123', {
schemas: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'],
Operations: [
{
op: 'replace',
path: 'name.familyName',
value: 'Smith',
},
{
op: 'replace',
path: 'active',
value: false,
},
],
});

deleteUser

Delete a user by ID:

await scim.deleteUser('user-id-123');

Group Operations

createGroup

const group = await scim.createGroup({
schemas: ['urn:ietf:params:scim:schemas:core:2.0:Group'],
displayName: 'Engineering',
members: [
{ value: 'user-id-123' },
{ value: 'user-id-456' },
],
});
console.log('Created group:', group.id);

getGroup

const group = await scim.getGroup('group-id-789');
console.log('Group:', group.displayName);
console.log('Members:', group.members?.length);

listGroups

const result = await scim.listGroups({
filter: 'displayName co "Eng"',
count: 50,
});
for (const group of result.Resources) {
console.log(group.displayName);
}

updateGroup

Replace a group resource entirely (PUT):

const updated = await scim.updateGroup('group-id-789', {
schemas: ['urn:ietf:params:scim:schemas:core:2.0:Group'],
displayName: 'Engineering Team',
members: [
{ value: 'user-id-123' },
{ value: 'user-id-456' },
{ value: 'user-id-789' },
],
});

patchGroup

Apply partial modifications to a group (PATCH):

await scim.patchGroup('group-id-789', {
schemas: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'],
Operations: [
{
op: 'add',
path: 'members',
value: [{ value: 'user-id-new' }],
},
],
});

deleteGroup

await scim.deleteGroup('group-id-789');

Filtering and Sorting

SCIM 2.0 supports a rich filter syntax. Pass ScimFilterOptions to listUsers or listGroups:

import type { ScimFilterOptions } from '@authrim/server';
const options: ScimFilterOptions = {
filter: 'active eq true and name.familyName co "Doe"',
sortBy: 'userName',
sortOrder: 'ascending',
startIndex: 1,
count: 25,
attributes: ['userName', 'name', 'emails'],
};
const result = await scim.listUsers(options);

ScimFilterOptions

OptionTypeDescription
filterstringSCIM filter expression (RFC 7644 Section 3.4.2.2)
sortBystringAttribute to sort by
sortOrder'ascending' | 'descending'Sort direction
startIndexnumber1-based index of the first result
countnumberMaximum number of results per page
attributesstring[]Include only these attributes in results
excludedAttributesstring[]Exclude these attributes from results

Common Filter Examples

FilterDescription
userName eq "[email protected]"Exact match
name.familyName co "Doe"Contains substring
active eq trueBoolean match
emails.value sw "admin"Starts with
meta.created gt "2025-01-01T00:00:00Z"Date comparison
active eq true and name.familyName eq "Doe"Logical AND
userName eq "alice" or userName eq "bob"Logical OR

Pagination

SCIM uses 1-based index pagination:

async function getAllUsers(scim: ScimClient): Promise<ScimUser[]> {
const allUsers: ScimUser[] = [];
let startIndex = 1;
const pageSize = 100;
while (true) {
const result = await scim.listUsers({
startIndex,
count: pageSize,
});
allUsers.push(...result.Resources);
if (allUsers.length >= result.totalResults) {
break;
}
startIndex += pageSize;
}
return allUsers;
}

Conditional GET with ETags

Use conditional GET to avoid fetching unchanged resources. The getUserConditional and getGroupConditional methods return a ScimConditionalGetResult:

import type { ScimConditionalGetResult } from '@authrim/server';
// First request — no ETag yet
const result: ScimConditionalGetResult = await scim.getUserConditional(
'user-id-123',
);
if (result.status === 'ok') {
console.log('User:', result.resource);
console.log('ETag:', result.etag);
}
// Subsequent request — pass the ETag
const cached = await scim.getUserConditional('user-id-123', {
ifNoneMatch: result.etag,
});
if (cached.status === 'not_modified') {
console.log('Resource unchanged, use cached version');
} else if (cached.status === 'ok') {
console.log('Resource updated:', cached.resource);
}

Groups work the same way:

const groupResult = await scim.getGroupConditional('group-id-789');
const cachedGroup = await scim.getGroupConditional('group-id-789', {
ifNoneMatch: groupResult.etag,
});

Optimistic Locking

Use the If-Match header with ETag values to prevent concurrent modification conflicts in updateUser and patchUser:

// Fetch the current user with its ETag
const result = await scim.getUserConditional('user-id-123');
if (result.status === 'ok') {
try {
// Update only if the resource hasn't changed
const updated = await scim.updateUser(
'user-id-123',
{
...result.resource,
name: { ...result.resource.name, familyName: 'Updated' },
},
{ ifMatch: result.etag },
);
} catch (error) {
// 412 Precondition Failed — resource was modified by someone else
console.error('Conflict: resource was modified concurrently');
}
}

The same pattern applies to patchUser, updateGroup, and patchGroup:

await scim.patchUser(
'user-id-123',
{
schemas: ['urn:ietf:params:scim:api:messages:2.0:PatchOp'],
Operations: [{ op: 'replace', path: 'active', value: false }],
},
{ ifMatch: etag },
);

Next Steps