Articles · Enterprise SSO & Federation

SCIM provisioning

Try out the running example

Enterprise deals come with a checkbox: when we deactivate an employee in our directory, their account in your product dies too. That checkbox is SCIM 2.0 — the customer’s IdP (Okta, Azure AD, anything) pushes Users and Groups at your API. Sentinel ships the server side of that contract, and it is deliberately standalone: no login stack, no signing keys, no database required to stand it up.

Two calls

// file: ProvisioningComposition.cs
services.AddRouting();

// Register the store BEFORE AddSentinelScim(): every Sentinel registration is TryAdd,
// so the host's instance wins and stays reachable for seeding and assertions.
// A real deployment registers AddSentinelEfCoreStores instead and gets EfScimStore.
var store = new InMemoryScimStore();
ProvisioningWorld.Seed(store);
services.AddSingleton(store);
services.AddSingleton<IScimStore>(store);

// Defaults: BaseUrl "/scim/v2", MaxPageSize 100.
services.AddSentinelScim();

and in the pipeline, app.MapSentinelScim(). That mounts discovery (/scim/v2/ServiceProviderConfig, /ResourceTypes, /Schemas), /Users and /Groups with the full verb set, every response application/scim+json. ScimServerOptions has exactly two knobs — BaseUrl and MaxPageSize — because the interesting configuration lives elsewhere: in the tokens.

The sct_ token is the tenant boundary

There is no session, no OAuth dance. A SCIM caller authenticates with a bearer token minted per organization:

// file: ProvisioningWorld.cs
await using var scope = services.CreateAsyncScope();
var tokens = scope.ServiceProvider.GetRequiredService<ScimTokenService>();
var acme = await tokens.CreateAsync(RealmId, AcmeId, "acme-idp provisioning");
var globex = await tokens.CreateAsync(RealmId, GlobexId, "globex-idp provisioning");

CreateAsync returns a ScimTokenCreated whose Secretsct_ + 32 random bytes, base64url — is shown exactly once. The store keeps only the SHA-256 digest, so a leaked database doesn’t leak provisioning access. (ScimTokenService is scoped, not singleton, because the EF-backed store is DbContext-bound — hence the explicit scope.)

Everything else about the token is a consequence of one design decision: the org context comes solely from the token. There is no org in the path, no header, no query parameter. A token minted for Acme physically cannot address Globex — the question never even parses.

Failures are uniform: missing header, malformed token, unknown token, revoked token, expired token — all answer the same 401 with WWW-Authenticate: Bearer realm="scim" and byte-identical bodies. The sample’s Missing_garbage_and_revoked_tokens_answer_an_identical_401 pins that, revocation included. Anti-enumeration applies to machines too.

The surface, honestly scoped

Sentinel implements the subset real IdPs actually send, and answers 501 — not a silent wrong answer — for the rest. ServiceProviderConfig declares it: patch.supported: true, filter.supported: true, bulk/sort/etag false.

POST /scim/v2/Users
Authorization: Bearer sct_…
Content-Type: application/scim+json

{ "schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
  "userName": "grace@acme.sample", "displayName": "Grace Hopper", "externalId": "okta|00u1" }

The 201 echoes the resource with id, active: true, meta.location, and the normalized userName — it is the user’s email, trimmed and lowercased. Filtering supports the one clause provisioning engines emit — userName eq "…" or externalId eq "…" — and anything fancier (co, sw, and, groups by anything but displayName) gets a 501 with a message saying exactly what is supported.

PATCH follows the dialect Azure AD and Okta speak, quirks included: the key is capital-O Operations, users accept replace only, active tolerates the string "True"/"False" shape Azure AD sends, and group membership moves via add/remove with the filtered path form:

{ "schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
  "Operations": [ { "op": "remove", "path": "members[value eq \"<user-id>\"]" } ] }

Group_membership_flows_through_patch_add_and_remove walks both directions.

DELETE is a deactivation

DELETE /scim/v2/Users/{id}   → 204
GET    /scim/v2/Users/{id}   → 200, "active": false

SCIM DELETE sets UserStatus.Deactivated and keeps the row. Users anchor audit history — article 012 is built on that — so erasing PII is a separate, deliberate flow (crypto-shredding, see compliance), not a side effect of an IdP sync. The deactivated user still appears in listings, still counts toward totalResults, and PATCH active: true restores them symmetrically — which is exactly what the IdP sends when the employee rejoins. Delete_deactivates_instead_of_erasing_and_patch_active_true_restores pins the round trip down to the stored UserStatus.

Groups are the opposite: DELETE /Groups/{id} is a hard delete. Groups carry no history worth anchoring.

Two fences

The interesting multi-tenant behavior is in what a token can’t do, and the two fences point in different directions.

Isolation is org-scoped. Globex’s token asking for an Acme user’s id gets 404 — not 403. A 403 would confirm the id exists; a 404 confirms nothing. Listings and even exact userName eq filters simply find nothing outside the token’s org:

// A_foreign_org_token_sees_nothing_and_touches_nothing
(await host.Client.SendAsync(host.Request(HttpMethod.Get, $"/scim/v2/Users/{id}", bearer: "globex")))
    .StatusCode.Should().Be(HttpStatusCode.NotFound);

Group membership gets the same fence with a different code: referencing a foreign user as a member is a 400 invalidValue — a provisioning bug worth surfacing, not an existence oracle worth hiding (the ids in a group payload came from the caller’s own directory).

Uniqueness is realm-scoped — deliberately wider. userName is unique across the whole realm, so creating ada@acme.sample from Globex’s token answers 409 uniqueness even though Globex can’t see that user. This is the anti-capture rule: without it, a compromised tenant IdP could provision an existing identity into its own org and inherit logins meant for someone else. The sample seeds Ada as a never-SCIM-provisioned Acme employee precisely so UserName_uniqueness_is_realm_wide_so_an_identity_cannot_be_stolen_across_orgs can demonstrate the refusal.

Where it plugs in

Swap InMemoryScimStore for AddSentinelEfCoreStores() and the same surface runs on your database — the EF adapter fences users through the membership table and was written against the same test suite. SCIM sits alongside SAML in the enterprise story: SAML answers who is signing in, SCIM answers who should exist at all. The enterprise capabilities page covers both in reference form, and the generated API reference documents the package surface.