Skip to content

Releases: mmcintosh/better-auth-scim-provisioning

v1.4.0

Choose a tag to compare

@github-actions github-actions released this 16 Oct 01:40
086f6c9

More attributes (title, phones, a work address), drift detection, the check result for the Enterprise User extension, and jobs indexed by target. Entra ID: a delete seconds after the create is checked a minute later.

Added

  • More attributes (attributes): the job title, work and mobile phone numbers and a work address, read from user fields and sent as SCIM's title, phoneNumbers and addresses. true reads fields of those names (title, phone, mobile, streetAddress, locality, region, postalCode, country) that your users have; a map names your own. An empty field clears the attribute at the app (a PUT leaves it out; a PATCH removes it by path). Entra ID gets jobTitle, businessPhones, mobilePhone and the address fields; Google Workspace the primary organization's title, phones and addresses, touching only what the target names and keeping an admin's other entries (one primary per list, ours when we send one). A mapUser that sets one of these itself takes precedence, at Google and Entra too. Webhooks get the same object. Organizations' stored targets can set it, reading only the fields the host allows (registry.attributeFields; by default the attribute names, resolved against the fields your users have, so attributes: true passes a narrowed list that covers them). Hosts with registry: on upgrade, organizations' targets may read those eight fields by default; set registry.attributeFields if any of them means something else in your user model. ScimUser gains title, phoneNumbers and addresses; exports the AttributeFields and ScimAddress types.
  • Drift detection (scimProvisioningDrift): reads a target's own list of accounts, a page at a time (targetId, limit, then after: next), and reports each one that isn't as we'd have it, with a finding: unmanaged (no link here and not our externalId: made by hand, or by another tool), unlinked (our externalId for a user here, but no link) or mismatch (deactivated or renamed at the app, or still active though the user was deleted here), with the account's ids and what to do. Reported, never changed. SCIM apps (paged by cursor or index), Google Workspace and Entra ID can be listed; a webhook receiver can't. Organizations' administrators get it for their own stored targets: POST /scim-provisioning/targets/drift { id, after?, limit? } (auth.api.scimProvisioningTargetDrift), with failures said in broad terms, as the check route does.
  • check reports the Enterprise User extension: a new result, enterprise-user, says whether the app advertises it (its /ResourceTypes User type's extensions, or its /Schemas), so you know before setting enterprise. An app with neither endpoint gets "can't tell". Read-only: the test user isn't sent the extension. checkScimTarget returns it too, before the test user's results; code that switches over CheckId should handle a value it doesn't know, as versioning says.

Changed

  • Jobs are indexed by target. A target's scheduled runs, its status counts and removing a target read its jobs by targetId, which scanned the whole table. New installs get the index from the migration; npx auth migrate doesn't add it to an existing table, so add it by hand if you like: CREATE INDEX "scimProvisioningJob_targetId_idx" ON "scimProvisioningJob" ("targetId"); (the Workers example's 0004 migration does). No column changes.

Fixed

  • Entra ID: a delete seconds after the create could leave the account live. Graph can answer 404 to a delete of an account made moments earlier, with the lookup by immutableId not finding it yet either (found live). The link was dropped as gone, and the account stayed, active. Now a delete's 404 ends the link only at the check a minute later that follows every delete at Entra, which deletes again if the account is there.

v1.3.0

Choose a tag to compare

@github-actions github-actions released this 14 Oct 01:49
a1dd440

Microsoft Entra ID (Microsoft 365) as a target: users through Microsoft Graph, linked for federated sign-in through better-auth-saml-idp, verified live.

Added

  • Microsoft Entra ID targets (type: "entra-id"): users created, updated, disabled and deleted in Entra ID (Microsoft 365) through Microsoft Graph, signed in with an app registration's client credentials (entra: { tenantId, clientId, clientSecret }, with the Graph application permission User.ReadWrite.All).
    • Each user's onPremisesImmutableId is our user id, which Entra matches a federated sign-in's NameID against: with better-auth-saml-idp as the domain's identity provider, a provisioned user signs in at once. No password is set on a federated domain; on a managed one, a random one nobody knows.
    • Deprovisioning disables the account and revokes its sessions. With deprovision: "delete", the user goes to the recycle bin, never purged, and a user back within 30 days is restored.
    • An admin's disabling isn't undone by other changes; only the plugin's own is.
    • adopt is off by default, as for Google Workspace.
    • Enterprise User attributes go to employeeId, department, companyName, employeeOrgData and the manager.
    • Graph's lag after a write (a new user 404s by id for seconds; a deleted one still reads as live) is retried, and a create whose reply was lost finds the account by its immutableId instead of making another.
    • A minute after disabling or deleting a user, their job comes back and checks that Graph has it: a disable or delete Graph answered as done but dropped (found live) is sent again until it shows.
    • National clouds: the token's scope follows url, or set entra.scope behind a proxy.
    • Verified live in a test tenant (test/live/entra-id.test.ts).
    • Users only, and only in code, for now: groups and stored (registry) Entra ID targets aren't supported yet.
    • Exports the EntraIdTarget type.

v1.2.1

Choose a tag to compare

@github-actions github-actions released this 13 Oct 02:16
1a670ef

Requires Better Auth 1.7.7 or later, which fixes a critical Magic Link account takeover; no other changes.

Security

  • Better Auth 1.7.7 or later is now required (the peer range is >=1.7.7 <1.8.0). Better Auth 1.7.7 fixes a critical Magic Link account takeover (GHSA-965c-763c-88jm). This plugin doesn't use Magic Link, but the app it's installed in may, so the plugin no longer accepts the vulnerable versions. Upgrade Better Auth along with this release; see the advisory for apps that share verification storage between servers.

v1.2.0

Choose a tag to compare

@github-actions github-actions released this 08 Oct 18:26
85f8977

Enterprise User attributes (employee number, cost center, organization, division, department and manager) at SCIM apps, Google Workspace and webhooks; Google Workspace now leaves an admin's suspensions and Google's own holds alone.

Added

  • Enterprise User attributes (enterprise): employee number, cost center, organization, division, department and manager, read from user fields, sent as SCIM's Enterprise User extension (RFC 7643 §4.3).
    • true reads the fields of those names (and managerId) that your users have; a map names your own fields, and one your users don't have stops the plugin at startup.
    • An empty field clears the attribute at the app: PUT leaves it out, PATCH removes it by path, and an app that refuses such a remove gets the change without it. A user with no values isn't sent the extension.
    • The manager is their id at that app once they're provisioned there. When that changes (appears, goes, a new id, a new address at Google), the reports are queued again by a job of its own, retried if it fails; at an organization's target, only its members.
    • Google Workspace gets the primary organization, the organization external id and the manager relation, touching only the attributes the target reads and keeping an admin's other values. Webhooks get the same object as SCIM.
    • Organizations' stored targets can set it, reading only the fields the host allows (registry.enterpriseFields; by default the attribute names and managerId).
    • Exports SCIM_ENTERPRISE_USER_SCHEMA and the ScimEnterpriseUser and EnterpriseFields types.

Changed

  • Google Workspace: only the plugin's own suspension is lifted. A change to a user (a rename, or now a manager's change) no longer sends suspended: false, which undid an admin's suspension made in the Admin console. The plugin lifts a suspension only when it made it and the user is back (unbanned, or included again).

Fixed

  • Google Workspace: an account Google suspended itself (suspensionReason such as WEB_LOGIN_REQUIRED, which Google applies to some new accounts until the user signs in) made every later change fail: each one asked to unsuspend it, Google refused with 412, and the plugin retried that for hours as "Google is still creating the user". Now that suspension is left as Google has it and the rest of the change goes, and a 412 "suspended for abuse" fails at once with a message saying who can lift it. Found live.

v1.1.0

Choose a tag to compare

@github-actions github-actions released this 08 Oct 01:45
0e2f0a9

Organizations connect their own apps at runtime (the target registry), and Better Auth apps using @better-auth/scim are tested as receivers, ID-JAG's SCIM link included.

Project

  • Every dependency install in CI goes through Socket Firewall, which blocks confirmed malware before it downloads, the release job included. pnpm 10.34.6 won't resolve a version less than a day old (minimumReleaseAge in the new pnpm-workspace.yaml; Better Auth exempt for the canary and the latest-1.7 row), and Dependabot waits 3 days (cooldown). The README shows Socket's package badge.
  • Interoperability tests against Better Auth's own inbound SCIM server, @better-auth/scim, as a real receiver, at the same version as Better Auth in each CI row. They cover users through their whole life, deprovision: "delete", organizations as groups, sessions ended on deactivation, and the link an ID-JAG sub resolves through, including that a mapUser externalId breaks it.

Documentation

  • CONTRIBUTING.md: setup, the checks CI runs, the adapter and live tests, and what a contribution needs.
  • SECURITY.md: a 7-day acknowledgement target, how fixed vulnerabilities are announced, and how releases are protected.
  • .bestpractices.json: answers for the OpenSSF Best Practices badge, which bestpractices.dev reads to pre-fill them.
  • The README shows the OpenSSF Best Practices badge (passing, project 15270).
  • README, Better Auth apps, and ID-JAG: provisioning an app that uses @better-auth/scim, and how an ID-JAG receiver finds the user through the SCIM link. Keep the default externalId (the user id, which is an ID-JAG's sub). Deactivation there also signs the user out.

Added

  • Organizations' own targets (registry): each organization connects its own SCIM app, Google Workspace domain or webhook at runtime, through /scim-provisioning/targets (list, create, update, check, status, delete), managed by its owners and admins (organizationRoles) and the host's administrators (canManage); creating, changing and deleting need a fresh session (Better Auth's freshAge), and every change is logged at warn with the acting user.
    • Credentials are encrypted with Better Auth's secret and bound to the target, its organization and where it sends: never shown, never sent anywhere new without being given again (a URL changed in the database pauses the target), and sealed again when the secret is rotated. Two changes to one target at once can't mix: the second is refused with 409.
    • URLs must be https, on the standard port and public. Every request is checked by those rules again as it's made, and its name looked up and refused if it resolves to a private address or doesn't resolve (allowHosts for exceptions; registry.fetch for a proxy). timeoutMs is at most 30 s.
    • A stored target only receives its organization's members and groups (and teams). Creating or changing one queues its organization as one job, expanded a page at a time by the deliveries, so even a large organization is a small request. A disabled one keeps its changes queued and waiting; one whose credentials can't be read is tried again every 15 minutes.
    • Stored targets are read from the database when used, so every server sees a change at once. The scimProvisioningTarget table exists only with registry: turning it on needs a migration, before deploying; without it, nothing changes.
  • scimProvisioningStatus takes targetId, or limit and after for a page of targets, and answers next. Without a registry and without limit, it answers every target, as before; with a registry, 25 at a time by default.

Changed

  • A scheduled run (scimProvisioningRun) gives no target more than half its batch while other targets' deliveries are due, so one slow or busy app doesn't hold the others up. Alone, a target still fills the batch.
  • A reconcile's limit now also counts each target it looks at (at least one each) and an organization's target's groups, so a call stays bounded however many targets there are. A host with many targets may need a few more calls; follow next as before. A cursor naming a target that was removed since goes on from the next one, instead of a 400.
  • An organization's target (organizationId) with teamGroups is queued only for that organization's teams, not every team its members are in.
  • A user's change queues an organization's target (organizationId) only for that organization's members and for users with an account at the target (however many organizations they're in); the other deliveries did nothing. A team's change queues only the targets of the team's organization.

v1.0.0

Choose a tag to compare

@github-actions github-actions released this 06 Oct 14:32
ec8cece

The first stable release: the API, the webhook format and the database schema are now covered by the versioning promise (docs/versioning.md).

Upgrading from 0.3 needs a database migration, and options or calls 0.3 ignored now stop the plugin: follow docs/upgrading.md.

Added

  • Webhook events carry schemaVersion: 1. A change a receiver could trip over will be a new version, in a major release.
  • Rotating a webhook secret: verifyWebhookSignature accepts several secrets (secret: [newSecret, oldSecret]) while the target switches over.
  • WebhookSignatureError, with a reason (malformed, expired, mismatch), so a receiver can answer 401 rather than fail with a 500.
  • ScimGroup and SCIM_GROUP_SCHEMA are exported, like ScimUser and SCIM_USER_SCHEMA.
  • check results have a stable id (create, find, update-put, …; CheckId); the human-readable name may change.
  • docs/versioning.md: what 1.0 will promise, what counts as the public API, and how Better Auth's minor releases are followed.
  • scimProvisioningStatus: per target, the jobs queued (due now), waiting (a backoff, a Retry-After, a ban running out), stuck (an app error still retried past retry.maxAttempts) and failed, and the accounts and groups the app confirmed; with userId, that user's account and pending job at each target. With scimProvisioningFailures, hosts no longer need to read the plugin's tables.
  • onFailure: called when a delivery gives up or reaches retry.maxAttempts, with the target, what failed, the error and the app's status. Errors it throws are logged.
  • scimProvisioningQueue({ userId?, organizationId?, targetId? }): queue what changed outside Better Auth's organization endpoints: a user (and the groups they're in), and/or an organization's groups. For a removal, pass both: only the organization finds the group the user left.
  • scimProvisioningFailures({ targetId?, after?, limit? }): the failed and stuck jobs, with the app's last error and status, a page at a time.

Fixed

  • Our own account is never left unfindable after a lost create reply. At an app that drops externalId, when the retry couldn't tell the account was ours (a custom userName, an unverified email), the pending link was dropped: the account stayed active at the app, and a later ban or delete "succeeded" without a request. The link is kept now, and a later leave fails loudly ("resolve it at the app") instead.
  • Role names that differ only by case (Admin, admin) keep apart on MySQL, whose keys compare case-insensitively: queueing failed with a duplicate-key error, and one role's group link could overwrite the other's. Roles with anything beyond a-z0-9_- are encoded in keys; links 0.3 wrote for them move to the new key on their next delivery.
  • Secrets never appear in errors. A token with a line break made the request fail with an error repeating the header, secret included, into logs, lastError, scimProvisioningFailures, onFailure and check's output. Secrets with control characters are refused at startup, and such an error never repeats the header.
  • onFailure isn't told about a delivery that's going out again at once (bumped during it), and gets 5 seconds before the delivery moves on.
  • A group delivery holds its job for about a minute (renewed while it runs), not ten or more, so one cut off with its Worker frees the group soon.
  • A webhook event's id also covers its content: a retry sending something different (after a write that bypassed Better Auth's hooks) gets a new id.
  • OAuth and Google tokens no longer stall deliveries on Workers. A token fetch in progress was shared by every request in the isolate; on Workers, a fetch started by a request that has ended is cancelled, so later deliveries waiting on it waited for ever (reproduced in workerd). Only tokens that have arrived are shared now.
  • An account deactivated at a SCIM app is no longer adopted and switched back on. A new user with the same verified address (a rehire, a recycled address) reactivated it, with its old data and permissions. It's refused now, as Google's suspended accounts already were: reactivate it at the app first if it should be taken over.
  • Options that need Better Auth's organization plugin (organizationId, groups, roleGroups), or its teams (teamGroups), stop the plugin at startup without it. Every reconcile, or every user's delivery, failed instead.
  • At Google, a user whose address is a Google Group's (at targets with groups) fails, saying so; it was retried for ever as "not visible yet".
  • verifyWebhookSignature without a secret (an unset environment variable) says so, instead of a TypeError.
  • A group made by hand is no longer taken over after our first create failed. At an app that drops externalId, a group created by hand after our create failed (a 503, a timeout) was found on the retry and its members rewritten. A group without our externalId is now taken as ours only if it has members and every one is someone we'd put there; our own create whose reply was lost still is.
  • Members who leave are removed at apps that list a group's members only when asked, and at apps that page them by startIndex. With groupUpdate: "patch", the members are read back to find who to remove: a group GET without them read as "no members", and index paging stopped at the first page, so leavers kept the group. Members are now asked for by name (attributes=members), and both cursor and index paging are followed.
  • Jobs held by a worker that stopped no longer block the scheduled run. They stayed at the head of the queue and used up its limit, so jobs behind them (bans included) waited until their hold ran out.
  • A 429's Retry-After is kept when the user changed during that delivery; the new change used to be sent again at once.
  • A long group delivery renews its hold on the job while it runs (a large first sync, Google's one request per member), so a second worker can't pick the same group up halfway through.
  • Deleting an organization queues its members' deprovisioning before the response (at targets scoped to it with organizationId). It was queued only in background work after the response, which Workers ends with waitUntil's budget, so members not queued by then stayed active at the app until a reconcile. The deliveries still run in the background and on the scheduled run.
  • One scimProvisioning() used by several Better Auth instances (per-tenant databases, for example) gives each its own queue. They all queued and delivered through the last instance's database.
  • A webhook event keeps its id when it's retried, so a receiver can recognise a retry by it, as the README advises; the next change gets a new id, even one back to an earlier state. Every attempt used to get a new id.
  • After an OAuth client secret (or a Google service account key) is rotated, a token fetched with the old one is no longer reused until it expires.
  • check tells you to remove its test user by hand when the app's create answered without an id or failed after it may have made the user.
  • The Workers example no longer exposes its dev mailbox when deployed. wrangler.jsonc turned it on, and those settings are deployed too, so anyone could fetch the verification link for any address (and verify an admin's address). It's now on only through .dev.vars, it only answers on localhost, and the README's deploy steps set email up before deploying.
  • The Workers example no longer stalls after a first request it doesn't serve. Better Auth finishes setting up its handler on the first call, and workerd cancels what a request leaves unfinished; the request that creates Better Auth now also calls its handler once.
  • The example's /admin/reconcile works a page at a time (limit: 200).

Changed

  • The @better-auth/core peer dependency is gone: nothing in the package uses it, and better-auth brings it with it.
  • Taking over accounts made elsewhere is a choice per target: adopt, on by default, off by default for Google Workspace, where it could reach someone's real mailbox. An account taken over is marked on its link (adopted) and is never deleted, only deactivated, even with deprovision: "delete": it wasn't ours to delete. (Part of the 1.0 migration: links gain the adopted column.)
  • Webhook URLs may have a query string (Azure Functions' ?code=, Logic Apps' signatures); SCIM URLs still may not, since paths are appended to them.
  • check takes --url=… as well as --url …, refuses unknown flags, and exits 1 only for an app the plugin can't work with: one that can't create, find or deactivate users, or update them with either PUT or PATCH (an app that only takes PATCH works with update: "patch").
  • Endpoint parameters are checked strictly, like the options: an unknown one (targetID for targetId) is refused instead of ignored, and a reconcile cursor it didn't hand out is refused (it used to report next: null having done nothing).
  • A reconcile called the 0.3 way (no limit, no after) that stops at its default page logs a warning saying to call again with after: next.
  • The CLI exits 1 for an unknown command (it exited 0); help exits 0.
  • Group links are indexed by organizationId. New installs get the index from the migration; npx auth migrate doesn't add it to an existing table, so add it by hand if you like: CREATE INDEX "scimProvisioningGroupLink_organizationId_idx" ON "scimProvisioningGroupLink" ("organizationId"); (the Workers example's 0002 migration does).
  • Upgrading needs a database migration (npx auth migrate, or npx auth generate for Drizzle and Prisma): group links gain two optional columns, kind and subjectId, saying which group each is (an organization's, a team's or a role'...
Read more

v0.3.1

Choose a tag to compare

@github-actions github-actions released this 05 Oct 23:23
55a6798

No code changes: the npm page gets the rewritten README (what it does, what's verified live, what's next).

Project

  • A weekly canary tests against Better Auth's newest releases (upstream-canary.yml) and opens an issue when one breaks the plugin, and a weekly Upstream watch issue tracks Better Auth against the peer range and updates held back on purpose (upstream-watch.yml).
  • Releases are one merge: pnpm release patch|minor|major opens the release PR, and merging it tags the version and starts the release run (tag-release.yml). Publishing still waits for the two approvals.

Documentation

  • The README opens like the companion's: what it does in a sentence, badges, an up-to-date status in place of the old 0.x note, a features list, what's verified live, and a table of contents. "Not yet" lists what's actually next (Microsoft 365, the remaining live checks, 1.0).

v0.3.0

Choose a tag to compare

@github-actions github-actions released this 05 Oct 02:08

Google Groups at Google Workspace targets, and AWS IAM Identity Center verified live. Upgrading from 0.2.x needs no database migration.

Added

  • Google Groups at Google Workspace targets (groups, teamGroups, roleGroups): organizations, teams and roles become Google Groups. Each is at an address made from its externalId, so a rename doesn't move it (google.groupDomain, google.groupEmail), and its description marks it ours, so a group someone else made is never taken over. Members are added and removed one at a time and read back a page at a time. Domain-wide delegation must also allow https://www.googleapis.com/auth/admin.directory.group. Verified live against a real Workspace. Google's lag right after a group is created (a 404 for its first members, then "already exists" while a read still finds nothing) is retried, not refused.

Changed

  • A refused Google token now says which scopes domain-wide delegation must allow.

Documentation

  • A link to the guide for using this package with better-auth-saml-idp (sign-in and provisioning together).
  • AWS IAM Identity Center is verified live with the awsIamIdentityCenter profile: a user's whole life, organizations, teams and roles as groups, and a group of over 100 members. Nothing needed fixing. The test confirmed that AWS only pages a group's members when the first request carries a cursor parameter, which the plugin sends; the test model now requires it too.

v0.2.1

Choose a tag to compare

@github-actions github-actions released this 04 Oct 17:07

Google Workspace, verified live against a real Workspace, with fixes for what that found. Upgrade if you use a Google Workspace target: on 0.2.0, a ban right after an email change can fail and leave the user active in Workspace.

Fixed

  • Google Workspace, found by testing live against a real Workspace: Google takes a while to settle after a create or an email change, and its answers in that time failed jobs for good. Now they're retried:

    • a 404 for a user created seconds ago;
    • a 412 "User creation is not complete";
    • a 409 to any change made while an email change is applied. Before, a ban right after an email change failed and left the user active in Workspace.

    A new email that belongs to another Workspace account still fails, now with a clear message.

  • Any target: if an app answers 404 for an account and then lists it under that same id, it's still being created. That's retried now; before, the second 404 failed the job.

Documentation

  • Google Workspace is marked verified live, and its setup steps are clearer: the service account needs no project role, its client ID is the Unique ID, and an organizational unit can hold the users it creates.

v0.2.0

Choose a tag to compare

@github-actions github-actions released this 04 Oct 15:26

More kinds of targets (Google Workspace, signed webhooks), teams and roles as groups, and profiles for specific apps. Upgrading from 0.1.0 needs no database migration.

Added

  • Webhook targets (type: "webhook"): every change POSTed as a JSON event with the full current state, signed with HMAC-SHA256; verifyWebhookSignature for receivers.
  • App profiles (awsIamIdentityCenter, slack, atlassian, githubEnterprise, cloudflareAccess), and compat for the behaviours they set: group updates by a diff of members for apps without PUT on groups, members read through a users filter, batched member changes, and renamed groups recreated for apps that can't rename.
  • Google Workspace targets (type: "google-workspace"): users created, updated, suspended and deleted through the Directory API, as a service account with domain-wide delegation. Users only for now.
  • Teams and roles as groups (teamGroups, roleGroups, with teamGroupName and roleGroupName): each team, or each role in an organization, is a group of its provisioned members, kept in sync like organization groups.
  • Upgrading from 0.1.0 needs no database migration: the schema is unchanged (a test keeps it so).

Fixed

  • A group job's lease now lasts long enough for a large group's batched requests, so a slow delivery isn't taken over and sent twice.
  • A deleted user is removed from their groups at the app. SQL databases delete the user's memberships with them, so their groups weren't updated.
  • A redirect or a 408 from a SCIM app (or Google) is retried instead of failing the job, so a ban answered by a maintenance page still reaches the app. Redirects are still never followed.
  • A paged reconcile (limit) now pages the groups too. Before, it did all of them in its last call, which could run past the limits of a Workers invocation.
  • A create whose reply was lost, followed by a rename before the retry, no longer leaves a second account (or group) at the app: the first is found under its old name and renamed.
  • A worker with an old list of due jobs no longer sends a job another worker has just put off (a 429's Retry-After, a backoff).
  • checkScimTarget and the check CLI refuse plain-http URLs, as the plugin does, instead of sending the token over them.
  • Retry-After is honoured at every kind of target, not only SCIM.
  • A warning is logged when an organization, user or team has more rows than are read at once (1,000), instead of skipping the rest's groups silently.
  • Reviewed before release, including by an external reviewer; tested on SQLite, PostgreSQL, MySQL and MongoDB. Google Workspace, webhooks, teams and roles, and the AWS, Slack, Atlassian and GitHub profiles are tested against models of those apps, not live.