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
| Option | Type | Required | Description |
|---|---|---|---|
baseUrl | string | Yes | SCIM 2.0 endpoint base URL |
accessToken | string | Yes | Bearer token for SCIM API authentication |
http | HttpProvider | No | Custom 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'], name: { givenName: 'Jane', familyName: 'Doe', }, emails: [ ], 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({ 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'], name: { givenName: 'Jane', familyName: 'Smith', }, emails: [ ], 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
| Option | Type | Description |
|---|---|---|
filter | string | SCIM filter expression (RFC 7644 Section 3.4.2.2) |
sortBy | string | Attribute to sort by |
sortOrder | 'ascending' | 'descending' | Sort direction |
startIndex | number | 1-based index of the first result |
count | number | Maximum number of results per page |
attributes | string[] | Include only these attributes in results |
excludedAttributes | string[] | Exclude these attributes from results |
Common Filter Examples
| Filter | Description |
|---|---|
userName eq "[email protected]" | Exact match |
name.familyName co "Doe" | Contains substring |
active eq true | Boolean 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 yetconst 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 ETagconst 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 ETagconst 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
- Back-Channel Logout — Handle server-side logout notifications
- Express & Fastify Adapters — Framework integration for token validation
- Error Handling — SCIM error responses and handling
- Configuration Reference — HTTP provider customization