From a8ea86e25e4f4e9aa98530b9f53cc73cf2fc8cfd Mon Sep 17 00:00:00 2001 From: Gustavo Valverde Date: Sun, 31 May 2026 13:53:18 +0100 Subject: [PATCH] fix(scim)!: always bind personal SCIM connections to their creator (#9840) --- .../scim-personal-provider-ownership.md | 11 ++++ docs/content/docs/plugins/scim.mdx | 52 ++++--------------- packages/scim/src/index.ts | 17 ++---- packages/scim/src/routes.ts | 15 ++---- packages/scim/src/scim.management.test.ts | 25 ++++----- packages/scim/src/types.ts | 7 --- 6 files changed, 40 insertions(+), 87 deletions(-) create mode 100644 .changeset/scim-personal-provider-ownership.md diff --git a/.changeset/scim-personal-provider-ownership.md b/.changeset/scim-personal-provider-ownership.md new file mode 100644 index 0000000000..7cf8da1ba9 --- /dev/null +++ b/.changeset/scim-personal-provider-ownership.md @@ -0,0 +1,11 @@ +--- +"@better-auth/scim": minor +--- + +Personal (non-organization) SCIM connections now always belong to the user who created them. Owner binding used to be opt-in through the `providerOwnership` option, which defaulted to off. With it off, a personal connection was stored without an owner, and the management endpoints denied access only when a stored owner differed from the caller. An unowned connection passed that check for any signed-in user, who could read it, list it, regenerate its token, or delete it. Regenerating the token rotated the secret and invalidated the original. + +`generateSCIMToken` now records the creator's `userId` on every personal connection. The `generate-token`, `list-provider-connections`, `get-provider-connection`, and `delete-provider-connection` endpoints grant access only to that owner. Organization-scoped connections keep their existing behavior and continue to use organization membership and the configured `requiredRole` checks. + +This release is breaking. It removes the `providerOwnership` option, and owner binding can no longer be disabled. The `scimProvider.userId` column is now a permanent part of the schema, so run a migration after upgrading with `npx auth migrate` or `npx auth generate`. + +Connections created before this release carry no owner. Access now fails closed, so those connections are no longer reachable through the management endpoints, including token regeneration. Reclaim them at the database level: delete `scimProvider` rows that have neither `organizationId` nor `userId`, or set `userId` to the intended owner, then regenerate tokens as needed. Organization-scoped connections are not affected. diff --git a/docs/content/docs/plugins/scim.mdx b/docs/content/docs/plugins/scim.mdx index 8a7ec31d37..e8c164bacf 100644 --- a/docs/content/docs/plugins/scim.mdx +++ b/docs/content/docs/plugins/scim.mdx @@ -200,30 +200,13 @@ const auth = betterAuth({ ### SCIM provider connection ownership -SCIM provider connection ownership applies to personal (non-organization) SCIM connections. It lets your application track who generated a connection and restricts later management operations for that connection to the same user. +Personal (non-organization) SCIM connections are always bound to the user who created them. Better Auth records that user's `userId` on the connection and restricts later management operations to the same owner. -```ts title="auth.ts" -import { betterAuth } from "better-auth"; -import { scim } from "@better-auth/scim"; +* Personal connections store the creating user's `userId` automatically +* Only the owner can regenerate, list, inspect, or delete a personal connection +* Organization-scoped connections instead use the organization role checks configured by `requiredRole` -const auth = betterAuth({ - plugins: [ - scim({ // [!code highlight] - providerOwnership: { // [!code highlight] - enabled: true // [!code highlight] - } // [!code highlight] - }) // [!code highlight] - ] -}); -``` - -When enabled: - -* Personal connections store the creating user's `userId` -* Only the owner can regenerate, list, inspect, or delete those personal connections later -* Organization-scoped connections continue to use the organization role checks configured by `requiredRole` - -Once enabled, make sure you migrate the database schema (again). +No configuration is required. After upgrading, make sure your database schema includes the `scimProvider.userId` column by running a migration. @@ -239,7 +222,7 @@ Once enabled, make sure you migrate the database schema (again). -See the [Schema](#if-you-have-provider-ownership-enabled-via-providerownershipenabled) section to add the fields manually. +See the [Schema](#schema) section for the full table definition. ### Managing SCIM provider connections @@ -247,7 +230,7 @@ You can manage SCIM provider connections from your application using the followi #### List SCIM provider connections -List existing connections the current user can manage. For organization-scoped connections, the user must have one of the configured `requiredRole` roles for that organization. For personal connections, access is based on ownership when `providerOwnership.enabled` is turned on. +List existing connections the current user can manage. For organization-scoped connections, the user must have one of the configured `requiredRole` roles for that organization. For personal connections, access is restricted to the connection owner. ```ts @@ -520,25 +503,16 @@ export const scimProviderTableFields = [ "The organization Id. If provider is linked to an organization.", isOptional: true, }, -]; - - - -### If you have provider ownership enabled via `providerOwnership.enabled`: - -The `scimProvider` schema is extended as follows: - -export const scimProviderOwnershipFields = [ { name: "userId", type: "string", description: - "The user id of the connection owner. Set automatically when generating a token via the API.", + "The user id of the personal (non-organization) connection owner. Set automatically when generating a token via the API.", isOptional: true, }, ]; - + ## Options @@ -552,14 +526,6 @@ scim({ }) ``` -* `providerOwnership`: `{ enabled: boolean }` — When enabled, links each personal provider connection to the user who generated its token. See [Connection ownership](#scim-provider-connection-ownership) for details. Default is `{ enabled: false }`. - -```ts title="Enable connection ownership (requires migration)" -scim({ - providerOwnership: { enabled: true }, -}) -``` - * `defaultSCIM`: Default list of SCIM tokens for testing. * `storeSCIMToken`: The method to store the SCIM token in your database, whether `encrypted`, `hashed` or `plain` text. Default is `plain` text. diff --git a/packages/scim/src/index.ts b/packages/scim/src/index.ts index 2996f4130f..c71de4730d 100644 --- a/packages/scim/src/index.ts +++ b/packages/scim/src/index.ts @@ -33,11 +33,6 @@ export const scim = (options?: SCIMOptions) => { storeSCIMToken: "plain", ...options, } satisfies SCIMOptions; - // TODO(scim-provider-ownership-default-on): flip default to `true` on next. - // Kept default-off on main so existing SQL deployments don't need a schema - // migration mid-upgrade. The dedicated next-minor PR adds the - // `scimProvider.userId` column and flips the default in one step. - const providerOwnershipEnabled = options?.providerOwnership?.enabled ?? false; const authMiddleware = authMiddlewareFactory(opts); @@ -78,14 +73,10 @@ export const scim = (options?: SCIMOptions) => { type: "string", required: false, }, - ...(providerOwnershipEnabled - ? { - userId: { - type: "string", - required: false, - }, - } - : {}), + userId: { + type: "string", + required: false, + }, }, }, }, diff --git a/packages/scim/src/routes.ts b/packages/scim/src/routes.ts index 0d458f56f9..667488ac83 100644 --- a/packages/scim/src/routes.ts +++ b/packages/scim/src/routes.ts @@ -84,14 +84,6 @@ function resolveRequiredRoles( return Array.from(new Set(["admin", creatorRole ?? "owner"])); } -// TODO(scim-provider-ownership-default-on): flip default to `true` on next so -// new non-org SCIM tokens are owner-locked by default. Coupled with the -// `scimProvider.userId` schema column. Tracks the SCIM provider-ownership -// advisory. -function isProviderOwnershipEnabled(opts: SCIMOptions): boolean { - return opts.providerOwnership?.enabled ?? false; -} - async function getSCIMUserOrgMemberships( ctx: GenericEndpointContext, userId: string, @@ -167,7 +159,7 @@ async function assertSCIMProviderAccess( message: "Insufficient role for this operation", }); } - } else if (provider.userId && provider.userId !== userId) { + } else if (provider.userId !== userId) { throw new APIError("FORBIDDEN", { message: "You must be the owner to access this provider", }); @@ -336,7 +328,7 @@ export const generateSCIMToken = (opts: SCIMOptions) => providerId, organizationId, scimToken: await storeSCIMToken(ctx, opts, baseToken), - ...(isProviderOwnershipEnabled(opts) ? { userId: user.id } : {}), + userId: user.id, }, }); @@ -421,8 +413,7 @@ export const listSCIMProviderConnections = (opts: SCIMOptions) => roles.some((role) => requiredRole.includes(role)) : false; } - // Owned by this user, or legacy provider without ownership tracking - return p.userId === userId || !p.userId; + return p.userId === userId; }); const providers = accessibleProviders.map((p) => diff --git a/packages/scim/src/scim.management.test.ts b/packages/scim/src/scim.management.test.ts index d3f31fea4e..983b430ee3 100644 --- a/packages/scim/src/scim.management.test.ts +++ b/packages/scim/src/scim.management.test.ts @@ -494,10 +494,11 @@ describe("SCIM provider management", () => { }); }); + /** + * @see https://github.com/better-auth/better-auth/security/advisories/GHSA-j8v8-g9cx-5qf4 + */ it("should deny regenerate when user is not the owner of a personal provider", async () => { - const { auth, getAuthCookieHeaders } = createTestInstance({ - providerOwnership: { enabled: true }, - }); + const { auth, getAuthCookieHeaders } = createTestInstance(); const [headersUserA, headersUserB] = await Promise.all([ getAuthCookieHeaders(policyUserA), @@ -607,9 +608,7 @@ describe("SCIM provider management", () => { }); it("should return owned non-org providers in list for the owner", async () => { - const { auth, getAuthCookieHeaders } = createTestInstance({ - providerOwnership: { enabled: true }, - }); + const { auth, getAuthCookieHeaders } = createTestInstance(); const [headersUserA, headersUserB] = await Promise.all([ getAuthCookieHeaders(policyUserA), @@ -675,10 +674,11 @@ describe("SCIM provider management", () => { }); }); + /** + * @see https://github.com/better-auth/better-auth/security/advisories/GHSA-j8v8-g9cx-5qf4 + */ it("should deny access to non-org provider when user is not the owner", async () => { - const { auth, getAuthCookieHeaders } = createTestInstance({ - providerOwnership: { enabled: true }, - }); + const { auth, getAuthCookieHeaders } = createTestInstance(); const [headersUserA, headersUserB] = await Promise.all([ getAuthCookieHeaders(policyUserA), @@ -884,10 +884,11 @@ describe("SCIM provider management", () => { }); }); + /** + * @see https://github.com/better-auth/better-auth/security/advisories/GHSA-j8v8-g9cx-5qf4 + */ it("should deny delete of non-org provider when user is not the owner", async () => { - const { auth, getAuthCookieHeaders } = createTestInstance({ - providerOwnership: { enabled: true }, - }); + const { auth, getAuthCookieHeaders } = createTestInstance(); const [headersUserA, headersUserB] = await Promise.all([ getAuthCookieHeaders(policyUserA), diff --git a/packages/scim/src/types.ts b/packages/scim/src/types.ts index 0e32aa1092..aefdf11704 100644 --- a/packages/scim/src/types.ts +++ b/packages/scim/src/types.ts @@ -18,13 +18,6 @@ export type SCIMName = { export type SCIMEmail = { value?: string; primary?: boolean }; export type SCIMOptions = { - /** - * SCIM provider ownership configuration. When enabled, each provider - * connection is linked to the user who generated its token. - */ - providerOwnership?: { - enabled: boolean; - }; /** * Minimum organization role(s) required for SCIM management operations * (generate-token, list/get/delete provider connections).