diff --git a/.changeset/scim-org-scoped-connections.md b/.changeset/scim-org-scoped-connections.md new file mode 100644 index 0000000000..e91f97d490 --- /dev/null +++ b/.changeset/scim-org-scoped-connections.md @@ -0,0 +1,11 @@ +--- +"@better-auth/scim": minor +--- + +Runtime SCIM tokens now require `organizationId`; use `staticProviders` for app-level SCIM. + +SCIM-managed accounts now use namespaced provider IDs (`scim:{organizationId}:{providerId}` or `scim:{providerId}` for app-level static providers). Migrate only known SCIM-managed account rows before upgrading; leave non-SCIM accounts unchanged even when they share the same provider ID. + +Organization-scoped `active: false` now makes a user inactive in that organization while keeping SCIM group and team associations available for reactivation. Use `DELETE` to fully deprovision organization-scoped SCIM state. + +`defaultSCIM` has been replaced by `staticProviders`. `linkExistingUsers.trustedDomains` has been removed; use `requireExistingOrgMembership`, `shouldLinkUser`, or explicit `true` instead. diff --git a/docs/content/docs/plugins/scim.mdx b/docs/content/docs/plugins/scim.mdx index ca92788988..4222a89dba 100644 --- a/docs/content/docs/plugins/scim.mdx +++ b/docs/content/docs/plugins/scim.mdx @@ -26,14 +26,18 @@ This plugin exposes a [SCIM](https://simplecloud.info/#Specification) server tha ```ts title="auth.ts" import { betterAuth } from "better-auth" + import { organization } from "better-auth/plugins"; import { scim } from "@better-auth/scim"; // [!code highlight] const auth = betterAuth({ plugins: [ + organization(), // [!code highlight] scim() // [!code highlight] ] }) ``` + + SCIM tokens generated through the management API are scoped to an organization, so the [organization plugin](/docs/plugins/organization) is required. For single-tenant app-level SCIM, configure [`staticProviders`](#static-scim-providers). @@ -115,28 +119,27 @@ This eliminates the back-and-forth typically required when setting up SCIM, redu Before your identity provider can start syncing information to your SCIM server, you need to generate a SCIM token that your identity provider will use to authenticate against it. -A SCIM token is a simple bearer token that you can generate: +A runtime SCIM token is a simple bearer token scoped to one organization: ```ts type generateSCIMToken = { /** - * The provider id + * The provider id for this directory connection */ providerId: string = "acme-corp" /** - * Optional organization id. When specified, the organizations plugin must also be enabled + * The organization this token can provision into */ - organizationId?: string = "the-org" + organizationId: string = "the-org" } ``` -A `SCIM` token is always restricted to a provider, thus you are required to specify a `providerId`. This can be any provider your instance supports (e.g one of the built-in providers such as `credentials` or an external provider registered through an external plugin such as `@better-auth/sso`). -Additionally, when the `organization` plugin is registered, you can optionally restrict the token to an organization via the `organizationId`. +A `SCIM` token is always restricted to a provider and an organization. `providerId` is the logical directory provider ID, such as `okta` or `entra-id`, and `organizationId` is the Better Auth organization the token can manage. Provider IDs cannot contain `:`, because SCIM bearer tokens use that character to separate token segments. - **Important:** Personal SCIM connections can still be generated by any authenticated user. Organization-scoped connections are restricted by default to users with the `admin` role or the organization creator role (`organization.creatorRole`, which defaults to `owner`). If you need a different policy, configure [`requiredRole`](#options) and/or add stricter checks in [hooks](#hooks). + The management API no longer creates personal SCIM connections. Use organization-scoped tokens for customer directory sync. Use [`staticProviders`](#static-scim-providers) only when you need a code-configured, app-level provider for a single-tenant deployment. #### Organization-scoped authorization @@ -148,7 +151,7 @@ By default, `requiredRole` resolves to: * `admin` * `organization.creatorRole` or `owner` -The same role requirement is also used by the SCIM management endpoints for organization-scoped connections: +The same role requirement is also used by the SCIM management endpoints: * `GET /scim/list-provider-connections` * `GET /scim/get-provider-connection` @@ -169,9 +172,9 @@ scim({ See the [hooks](#hooks) documentation for more details about supported hooks. -#### Default SCIM token +#### Static SCIM providers -We also provide a way for you to specify a `SCIM` token to use by default. This allows you to test a SCIM connection without setting up providers in the database: +Use `staticProviders` when the SCIM provider is declared in code instead of being created through `/scim/generate-token`. This is useful for single-tenant app-level SCIM or for test environments that should not persist provider rows in the database. ```ts title="auth.ts" import { betterAuth } from "better-auth" @@ -180,11 +183,11 @@ import { scim } from "@better-auth/scim"; const auth = betterAuth({ plugins: [ scim({ - defaultSCIM: [ + staticProviders: [ { - providerId: "default-scim", // ID of the existing provider you want to provision + providerId: "default-scim", // Logical provider ID scimToken: "some-scim-token", // SCIM plain token - organizationId: "the-org" // Optional organization id + organizationId: "the-org" // Optional. Omit for app-level SCIM. } ] }) @@ -193,44 +196,18 @@ const auth = betterAuth({ ``` - **Important**: Please note that you must base64 encode your `scimToken` before you try to use as follows: `base64(scimToken:providerId[:organizationId])`. + Base64 encode the bearer token value as `base64(scimToken:providerId[:organizationId])`. - In our example above, you would need to encode the `some-scim-token:default-scim:the-org` text to base64, resulting in the following scimToken: `c29tZS1zY2ltLXRva2VuOmRlZmF1bHQtc2NpbTp0aGUtb3Jn` + In the example above, encode `some-scim-token:default-scim:the-org`, which results in `c29tZS1zY2ltLXRva2VuOmRlZmF1bHQtc2NpbTp0aGUtb3Jn`. -### SCIM provider connection ownership - -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. - -* 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` - -No configuration is required. After upgrading, make sure your database schema includes the `scimProvider.userId` column by running a migration. - - - - ```bash - npx auth migrate - ``` - - - - ```bash - npx auth generate - ``` - - - -See the [Schema](#schema) section for the full table definition. - ### Managing SCIM provider connections You can manage SCIM provider connections from your application using the following endpoints: #### 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 restricted to the connection owner. +List existing organization-scoped connections the current user can manage. The user must be a member of the organization and satisfy the configured `requiredRole` policy. ```ts @@ -241,7 +218,7 @@ List existing connections the current user can manage. For organization-scoped c #### Get SCIM provider connection details -Get a single connection by provider id. Access is allowed only if the user can manage that connection: either because they satisfy the configured organization role requirement, or because they own the personal connection. +Get a single connection by provider ID and organization ID. Access is allowed only when the user can manage that organization-scoped connection. ```ts @@ -250,6 +227,10 @@ Get a single connection by provider id. Access is allowed only if the user can m * Unique provider identifier */ providerId: string = "acme-corp" + /** + * Organization identifier + */ + organizationId: string = "the-org" } ``` @@ -265,6 +246,10 @@ Delete an existing connection. This will immediately invalidate the connection's * Unique provider identifier */ providerId: string = "acme-corp" + /** + * Organization identifier + */ + organizationId: string = "the-org" } ``` @@ -275,7 +260,7 @@ The following subset of the specification is currently supported: #### List users -Get a list of available users in the database. This is restricted to list only users associated to the same provider and organization than your SCIM token. +Get provisioned users for the same provider and organization as your SCIM token. App-level static providers list users for their configured provider only. ```ts @@ -290,7 +275,7 @@ Get a list of available users in the database. This is restricted to list only u #### Get user -Get an user from the database. The user will be only returned if it belongs to the same provider and organization than the SCIM token. +Get a user from the database. The user is returned only when they are associated with the same SCIM account provider as the bearer token. ```ts @@ -305,7 +290,7 @@ Get an user from the database. The user will be only returned if it belongs to t #### Create new user -Provisions a new user to the database. The user will have an account associated to the same provider and will be member of the same org than the SCIM token. +Provision a new user to the database. For organization-scoped tokens, Better Auth creates the SCIM account and adds the user to the token's organization unless the incoming resource has `active: false`. ```ts @@ -335,13 +320,17 @@ Provisions a new user to the database. The user will have an account associated * List of emails associated to the user, only a single email can be primary */ emails?: Array<{ value: string, primary?: boolean }> = [{ value: "daniel@email.com", primary: true }] + /** + * Whether the user should be active in this organization + */ + active?: boolean = true } ``` #### Update an existing user -Replaces an existing user details in the database. This operation can only update users that belong to the same provider and organization than the SCIM token. +Replace an existing user's details. This operation can update only users associated with the same SCIM account provider as the bearer token. ```ts @@ -371,13 +360,17 @@ Replaces an existing user details in the database. This operation can only updat * List of emails associated to the user, only a single email can be primary */ emails?: Array<{ value: string, primary?: boolean }> = [{ value: "daniel@email.com", primary: true }] + /** + * Whether the user should be active in this organization + */ + active?: boolean = true } ``` #### Partial update an existing user -Allows to apply a partial update to the user details. This operation can only update users that belong to the same provider and organization than the SCIM token. +Apply a partial update to a user. This operation can update only users associated with the same SCIM account provider as the bearer token. ```ts @@ -398,7 +391,7 @@ Allows to apply a partial update to the user details. This operation can only up Removes a user resource. This operation only affects users that belong to the same provider (and organization) as the SCIM token. -For an organization-scoped token, the user is deprovisioned from the organization: their membership and the provider account are removed, while the global user record is kept. For a non-organization token, the global user is deleted only when this provider's account is their sole identity; otherwise just that account is unlinked. +For an organization-scoped token, the user is deprovisioned from the organization: SCIM group memberships, projected roles, team memberships, organization membership, and the SCIM account link are removed, while the global user record is kept. Sessions are revoked only when the user has no remaining organization memberships. For an app-level static provider, the global user is deleted only when this provider's account is their sole identity; otherwise, just that account is unlinked. ```ts @@ -469,11 +462,15 @@ By default, the SCIM provisioning will automatically map the following fields: * `user.email`: User primary email or the first available email if there is not a primary one * `user.name`: Derived from `name` (`name.formatted` or `name.givenName` + `name.familyName`) and fallbacks to the user primary email -* `account.providerId`: Provider associated to the `SCIM` token +* `account.providerId`: Namespaced SCIM provider key. Organization-scoped tokens store `scim:{organizationId}:{providerId}`. App-level static providers store `scim:{providerId}`. * `account.accountId`: Defaults to `externalId` and fallbacks to `userName` -* `member.organizationId`: Organization associated to the provider +* `member.organizationId`: Organization associated with the provider, for organization-scoped tokens -The SCIM `active` attribute maps to the user's disabled state. `active: false` deactivates the user (via the [admin](/docs/plugins/admin) plugin's `banned` state) and revokes their sessions; `active: true` reactivates. Honoring `active` requires the admin plugin. Changing a user's email through SCIM also resets their verified status. +The logical `providerId` remains in the SCIM token and the `scimProvider` table. Only account rows use the namespaced provider key, so SCIM accounts do not collide with social, SSO, OIDC, SAML, credential, or generic OAuth accounts that use the same provider ID. + +Better Auth's email field is globally unique. If another user already has the incoming SCIM email, `createSCIMUser` returns a `409` conflict unless you explicitly enable `linkExistingUsers` and every configured linking constraint passes. + +The SCIM `active` attribute maps to organization membership for organization-scoped tokens. `active: false` removes the organization membership and reports the user as inactive to that token, while preserving the SCIM account, SCIM group membership, SCIM role grants, and team association rows for reactivation. `active: true` recreates membership and reapplies SCIM-projected roles; manual roles stored only on the removed member row are not restored. `DELETE` is the full deprovisioning operation and removes SCIM group and team state for that organization. App-level static providers map `active: false` to the [admin](/docs/plugins/admin) plugin's `banned` state and revoke sessions, so honoring app-level deactivation requires the admin plugin. Changing a user's email through SCIM also resets their verified status. ## Schema @@ -490,7 +487,13 @@ export const scimProviderTableFields = [ name: "providerId", type: "string", description: - "The provider ID. Used to identify a provider and to generate a redirect URL.", + "The logical SCIM provider ID.", + }, + { + name: "providerKey", + type: "string", + description: + "Internal unique key for the organization-scoped provider connection.", isUnique: true, }, { @@ -504,25 +507,120 @@ export const scimProviderTableFields = [ name: "organizationId", type: "string", description: - "The organization Id. If provider is linked to an organization.", - isOptional: true, - }, - { - name: "userId", - type: "string", - description: - "The user id of the personal (non-organization) connection owner. Set automatically when generating a token via the API.", - isOptional: true, + "The organization ID this provider is scoped to.", }, ]; +### Upgrade notes + +Generated SCIM provider rows must now be organization-scoped. If you previously used personal SCIM provider rows, replace them with organization-scoped tokens or configure app-level providers through `staticProviders`. + +`defaultSCIM` has been replaced by `staticProviders`. `linkExistingUsers.trustedDomains` has been removed; use `requireExistingOrgMembership`, `shouldLinkUser`, or the explicit `true` escape hatch instead. + +SCIM-managed account rows also need the namespaced provider key. Do not bulk update accounts by matching only `account.providerId = scimProvider.providerId`: if a SCIM provider ID was also used by SSO, OAuth, OIDC, SAML, One Tap, or another account provider, that update would convert non-SCIM account rows into SCIM-managed accounts. Build a reviewed staging list of known SCIM-managed account row IDs first, then update only those rows. + + + If you cannot prove which existing `account` rows were created by SCIM, leave those rows unchanged and let your identity provider reprovision users under the new SCIM namespace. + + + + + ```sql + CREATE TEMP TABLE scim_account_migration ( + "accountId" text PRIMARY KEY, + "organizationId" text NOT NULL, + "providerId" text NOT NULL + ); + + -- Insert only reviewed SCIM-managed account row IDs into scim_account_migration. + + UPDATE "account" AS a + SET "providerId" = 'scim:' || m."organizationId" || ':' || m."providerId" + FROM scim_account_migration AS m + WHERE a."id" = m."accountId" + AND a."providerId" NOT LIKE 'scim:%'; + + UPDATE "scimProvider" + SET "providerKey" = "organizationId" || ':' || "providerId" + WHERE "organizationId" IS NOT NULL; + + DELETE FROM "scimProvider" + WHERE "organizationId" IS NULL; + + ALTER TABLE "scimProvider" DROP COLUMN IF EXISTS "userId"; + ``` + + + + ```sql + CREATE TEMPORARY TABLE scim_account_migration ( + accountId varchar(255) PRIMARY KEY, + organizationId varchar(255) NOT NULL, + providerId varchar(255) NOT NULL + ); + + -- Insert only reviewed SCIM-managed account row IDs into scim_account_migration. + + UPDATE account AS a + JOIN scim_account_migration AS m + ON a.id = m.accountId + SET a.providerId = CONCAT('scim:', m.organizationId, ':', m.providerId) + WHERE a.providerId NOT LIKE 'scim:%'; + + UPDATE scimProvider + SET providerKey = CONCAT(organizationId, ':', providerId) + WHERE organizationId IS NOT NULL; + + DELETE FROM scimProvider + WHERE organizationId IS NULL; + + ALTER TABLE scimProvider DROP COLUMN userId; + ``` + + + + ```sql + CREATE TEMP TABLE scim_account_migration ( + accountId text PRIMARY KEY, + organizationId text NOT NULL, + providerId text NOT NULL + ); + + -- Insert only reviewed SCIM-managed account row IDs into scim_account_migration. + + UPDATE account + SET providerId = ( + SELECT 'scim:' || m.organizationId || ':' || m.providerId + FROM scim_account_migration AS m + WHERE m.accountId = account.id + LIMIT 1 + ) + WHERE providerId NOT LIKE 'scim:%' + AND EXISTS ( + SELECT 1 + FROM scim_account_migration AS m + WHERE m.accountId = account.id + ); + + UPDATE scimProvider + SET providerKey = organizationId || ':' || providerId + WHERE organizationId IS NOT NULL; + + DELETE FROM scimProvider + WHERE organizationId IS NULL; + ``` + + SQLite does not support dropping a column with the same syntax as PostgreSQL and MySQL in all supported environments. Use `npx auth generate` to create the table-rebuild migration if you need to remove `scimProvider.userId`. + + + ## Options ### Server -* `requiredRole`: `string[]` — Minimum organization role(s) allowed to generate organization-scoped tokens and manage organization-scoped connections. Defaults to `["admin", organization.creatorRole ?? "owner"]`. +* `requiredRole`: `string[] | SCIMRequiredRoleResolver`. Authorizes users who can generate organization-scoped tokens and manage organization-scoped connections. A string array grants access to members with any listed role. A resolver receives `{ user, member, organizationId, ctx }` and returns `true` to allow access. Defaults to `["admin", organization.creatorRole ?? "owner"]`. ```ts title="Allow only owners to manage organization-scoped SCIM connections" scim({ @@ -530,7 +628,17 @@ scim({ }) ``` -* `defaultSCIM`: Default list of SCIM tokens for testing. +```ts title="Use a custom organization policy" +scim({ + requiredRole: async ({ member }) => { + return member.role.split(",").includes("directory-admin"); + }, +}) +``` + +* `staticProviders`: Code-configured SCIM providers. Use an entry with `organizationId` to bind a static token to an organization, or omit `organizationId` for app-level SCIM. Provider IDs must be unique within their organization scope and cannot contain `:`. Organization-scoped static providers require the organization plugin. +* `linkExistingUsers`: Disabled by default. Set to `true` to allow SCIM provisioning to link to any existing user with the same email, or pass constraints such as `requireExistingOrgMembership` and `shouldLinkUser` to control when linking is allowed. +* `canGenerateToken`: Additional authorization hook for runtime token generation. It runs after membership and `requiredRole` checks and can only deny access. * `storeSCIMToken`: The method to store the SCIM token in your database, whether `encrypted`, `hashed` or `plain` text. Default is `plain` text. Alternatively, you can pass a custom encryptor or hasher to store the SCIM token in your database. @@ -575,7 +683,6 @@ const approvedScimOperators = new Set(["some-admin-user-id"]); scim({ beforeSCIMTokenGenerated: async ({ user, member, scimToken }) => { - // `member` is null for personal connections. // Add any extra restrictions you need before the token is persisted. if (!approvedScimOperators.has(user.id)) { throw new APIError("FORBIDDEN", { message: "User does not have enough permissions" }); diff --git a/packages/scim/src/group-provisioning.ts b/packages/scim/src/group-provisioning.ts index 2504b2186a..3801e592ee 100644 --- a/packages/scim/src/group-provisioning.ts +++ b/packages/scim/src/group-provisioning.ts @@ -7,6 +7,7 @@ import type { } from "better-auth"; import { generateRandomString } from "better-auth/crypto"; import type { Member } from "better-auth/plugins"; +import { scimAccountProviderId } from "./mappings"; import { SCIMAPIError } from "./scim-error"; import type { SCIMFilterWhere } from "./scim-filters"; import { parseSCIMGroupFilter, SCIMParseError } from "./scim-filters"; @@ -212,7 +213,13 @@ async function validateGroupMembers( adapter.findMany({ model: "account", where: [ - { field: "providerId", value: input.providerId }, + { + field: "providerId", + value: scimAccountProviderId({ + providerId: input.providerId, + organizationId: input.organizationId, + }), + }, { field: "userId", value: input.userIds, operator: "in" }, ], }), diff --git a/packages/scim/src/index.ts b/packages/scim/src/index.ts index 764d4ed588..f5182f82ce 100644 --- a/packages/scim/src/index.ts +++ b/packages/scim/src/index.ts @@ -1,4 +1,5 @@ import type { BetterAuthPlugin } from "better-auth"; +import { BetterAuthError } from "better-auth"; import { authMiddlewareFactory } from "./middlewares"; import { createSCIMGroup, @@ -34,6 +35,32 @@ declare module "@better-auth/core" { } } +function validateStaticProviders( + opts: SCIMOptions, + hasOrganizationPlugin: boolean, +) { + const providerKeys = new Set(); + for (const provider of opts.staticProviders ?? []) { + if (!provider.providerId || provider.providerId.includes(":")) { + throw new BetterAuthError( + "SCIM static provider ids must be non-empty and cannot contain `:`.", + ); + } + if (provider.organizationId && !hasOrganizationPlugin) { + throw new BetterAuthError( + "Organization-scoped SCIM static providers require the organization plugin.", + ); + } + const providerKey = `${provider.organizationId ?? ""}:${provider.providerId}`; + if (providerKeys.has(providerKey)) { + throw new BetterAuthError( + "SCIM static providers must be unique per provider id and organization.", + ); + } + providerKeys.add(providerKey); + } +} + export const scim = (options?: SCIMOptions) => { const opts = { storeSCIMToken: "plain", @@ -45,6 +72,15 @@ export const scim = (options?: SCIMOptions) => { return { id: "scim", version: PACKAGE_VERSION, + init(ctx) { + const hasOrganizationPlugin = ctx.hasPlugin("organization"); + validateStaticProviders(opts, hasOrganizationPlugin); + if (!hasOrganizationPlugin && !opts.staticProviders?.length) { + throw new BetterAuthError( + "The scim plugin requires the organization plugin. Register it, or configure app-level providers via `staticProviders` for single-tenant SCIM.", + ); + } + }, endpoints: { generateSCIMToken: generateSCIMToken(opts), listSCIMProviderConnections: listSCIMProviderConnections(opts), @@ -74,7 +110,12 @@ export const scim = (options?: SCIMOptions) => { providerId: { type: "string", required: true, + }, + providerKey: { + type: "string", + required: true, unique: true, + returned: false, }, scimToken: { type: "string", @@ -83,11 +124,7 @@ export const scim = (options?: SCIMOptions) => { }, organizationId: { type: "string", - required: false, - }, - userId: { - type: "string", - required: false, + required: true, }, }, }, diff --git a/packages/scim/src/mappings.ts b/packages/scim/src/mappings.ts index b10496896c..6bcf58a0b7 100644 --- a/packages/scim/src/mappings.ts +++ b/packages/scim/src/mappings.ts @@ -4,6 +4,28 @@ export const getAccountId = (userName: string, externalId?: string) => { return externalId ?? userName; }; +/** + * Account provider key for SCIM-managed users, isolated from other providers. + */ +export const scimAccountProviderId = (provider: { + providerId: string; + organizationId?: string | null; +}): string => { + return provider.organizationId + ? `scim:${provider.organizationId}:${provider.providerId}` + : `scim:${provider.providerId}`; +}; + +/** + * Unique storage key for runtime-managed SCIM provider connections. + */ +export const scimProviderKey = (provider: { + providerId: string; + organizationId: string; +}): string => { + return `${provider.organizationId}:${provider.providerId}`; +}; + const getFormattedName = (name: SCIMName) => { if (name.givenName && name.familyName) { return `${name.givenName} ${name.familyName}`; diff --git a/packages/scim/src/middlewares.ts b/packages/scim/src/middlewares.ts index 8e7e2ef406..aed974e4c4 100644 --- a/packages/scim/src/middlewares.ts +++ b/packages/scim/src/middlewares.ts @@ -1,9 +1,10 @@ import { base64Url } from "@better-auth/utils/base64"; import { createAuthMiddleware } from "better-auth/api"; import { constantTimeEqual } from "better-auth/crypto"; +import { scimProviderKey } from "./mappings"; import { SCIMAPIError } from "./scim-error"; import { verifySCIMToken } from "./scim-tokens"; -import type { SCIMOptions, SCIMProvider } from "./types"; +import type { SCIMOptions, SCIMProvider, StaticSCIMProvider } from "./types"; export type AuthMiddleware = ReturnType; @@ -34,17 +35,11 @@ export const authMiddlewareFactory = (opts: SCIMOptions) => }); } - let scimProvider: Omit | null = - opts.defaultSCIM?.find((p) => { - if (p.providerId === providerId && !organizationId) { - return true; - } - - return !!( - p.providerId === providerId && - organizationId && - p.organizationId === organizationId - ); + let scimProvider: SCIMProvider | StaticSCIMProvider | null = + opts.staticProviders?.find((p) => { + if (p.providerId !== providerId) return false; + if (!organizationId) return !p.organizationId; + return p.organizationId === organizationId; }) ?? null; if (scimProvider) { @@ -57,13 +52,19 @@ export const authMiddlewareFactory = (opts: SCIMOptions) => } } + if (!organizationId) { + throw new SCIMAPIError("UNAUTHORIZED", { + detail: "Invalid SCIM token", + }); + } + scimProvider = await ctx.context.adapter.findOne({ model: "scimProvider", where: [ - { field: "providerId", value: providerId }, - ...(organizationId - ? [{ field: "organizationId", value: organizationId }] - : []), + { + field: "providerKey", + value: scimProviderKey({ providerId, organizationId }), + }, ], }); diff --git a/packages/scim/src/patch-operations.ts b/packages/scim/src/patch-operations.ts index 4c5664d83f..4b1fcb898e 100644 --- a/packages/scim/src/patch-operations.ts +++ b/packages/scim/src/patch-operations.ts @@ -50,9 +50,8 @@ const familyName = (user: User, op: Operation, resources: Resources) => { }; const active = (user: User, op: Operation, resources: Resources) => { - // SCIM `active:false` deactivates the user; map it to the admin plugin's - // enforced `banned` state (`banned = !active`). The handler requires the - // admin plugin and revokes sessions on deactivation. + // Build the generic inactive signal. The route maps it to organization + // membership for org-scoped tokens and to the admin ban for app-level tokens. return op.value === false || op.value === "false"; }; diff --git a/packages/scim/src/routes.ts b/packages/scim/src/routes.ts index 6ba31844c5..02df0c1403 100644 --- a/packages/scim/src/routes.ts +++ b/packages/scim/src/routes.ts @@ -32,7 +32,13 @@ import { SCIMGroupResourceSchema, SCIMGroupResourceType, } from "./group-schemas"; -import { getAccountId, getUserFullName, getUserPrimaryEmail } from "./mappings"; +import { + getAccountId, + getUserFullName, + getUserPrimaryEmail, + scimAccountProviderId, + scimProviderKey, +} from "./mappings"; import type { AuthMiddleware } from "./middlewares"; import { buildUserPatch } from "./patch-operations"; import { SCIMAPIError, SCIMErrorOpenAPISchemas } from "./scim-error"; @@ -45,7 +51,7 @@ import { } from "./scim-metadata"; import { createGroupResource, createUserResource } from "./scim-resources"; import { storeSCIMToken } from "./scim-tokens"; -import type { SCIMOptions, SCIMProvider } from "./types"; +import type { SCIMGroupRoleGrant, SCIMOptions, SCIMProvider } from "./types"; import { APIUserSchema, OpenAPIUserResourceSchema, @@ -60,51 +66,35 @@ const supportedSCIMResourceTypes = [ SCIMGroupResourceType, ]; const supportedMediaTypes = ["application/json", "application/scim+json"]; +type SCIMWriteAdapter = Pick< + DBAdapter, + | "count" + | "create" + | "delete" + | "deleteMany" + | "findMany" + | "findOne" + | "update" +>; const generateSCIMTokenBodySchema = z.object({ - providerId: z.string().meta({ description: "Unique provider identifier" }), + providerId: z.string().min(1).meta({ description: "Provider identifier" }), organizationId: z .string() - .optional() - .meta({ description: "Optional organization id" }), + .min(1) + .meta({ description: "Organization the token is scoped to" }), }); const getSCIMProviderConnectionQuerySchema = z.object({ - providerId: z.string(), + providerId: z.string().min(1), + organizationId: z.string().min(1), }); const deleteSCIMProviderConnectionBodySchema = z.object({ - providerId: z.string(), + providerId: z.string().min(1), + organizationId: z.string().min(1), }); -function getDefaultSSOProviderIds(pluginOptions: unknown): string[] { - const options = - pluginOptions && typeof pluginOptions === "object" - ? (pluginOptions as Record) - : null; - if ( - !options || - !("defaultSSO" in options) || - !Array.isArray(options.defaultSSO) - ) { - return []; - } - - return options.defaultSSO - .map((provider) => { - if ( - provider && - typeof provider === "object" && - "providerId" in provider && - typeof provider.providerId === "string" - ) { - return provider.providerId; - } - return null; - }) - .filter((providerId): providerId is string => providerId !== null); -} - function parseMemberRoles(role: string): string[] { return role .split(",") @@ -119,41 +109,75 @@ function hasRequiredRole(memberRole: string, requiredRole: string[]): boolean { ); } -function resolveRequiredRoles( - ctx: GenericEndpointContext, - opts: SCIMOptions, -): string[] { - if (opts.requiredRole) { - return opts.requiredRole; - } - +function defaultOrgRoles(ctx: GenericEndpointContext): string[] { const creatorRole = ctx.context.getPlugin("organization")?.options?.creatorRole; return Array.from(new Set(["admin", creatorRole ?? "owner"])); } -async function getSCIMUserOrgMemberships( +async function isOrgActionAllowed( + ctx: GenericEndpointContext, + opts: SCIMOptions, + payload: { user: User; member: Member | null; organizationId: string }, +): Promise { + if (!payload.member) return false; + if (typeof opts.requiredRole === "function") { + return !!(await opts.requiredRole({ + user: payload.user, + member: payload.member, + organizationId: payload.organizationId, + ctx, + })); + } + const roles = opts.requiredRole ?? defaultOrgRoles(ctx); + return hasRequiredRole(payload.member.role, roles); +} + +/** Authorizes organization-scoped SCIM management actions. */ +async function assertOrgAccess( + ctx: GenericEndpointContext, + opts: SCIMOptions, + user: User, + organizationId: string, +): Promise { + if (!ctx.context.hasPlugin("organization")) { + throw new APIError("FORBIDDEN", { + message: "Organization plugin is required to access this SCIM provider", + }); + } + const member = await findOrganizationMember(ctx, user.id, organizationId); + const allowed = await isOrgActionAllowed(ctx, opts, { + user, + member, + organizationId, + }); + if (!allowed) { + throw new APIError("FORBIDDEN", { + message: member + ? "Insufficient role for this operation" + : "You are not a member of the organization", + }); + } + return member as Member; +} + +async function getUserMembershipsByOrg( ctx: GenericEndpointContext, userId: string, -): Promise> { +): Promise> { const members = await ctx.context.adapter.findMany({ model: "member", where: [{ field: "userId", value: userId }], }); - return new Map( - members.map((member) => [ - member.organizationId, - parseMemberRoles(member.role), - ]), - ); + return new Map(members.map((member) => [member.organizationId, member])); } function normalizeSCIMProvider(provider: SCIMProvider) { return { id: provider.id, providerId: provider.providerId, - organizationId: provider.organizationId ?? null, + organizationId: provider.organizationId, }; } @@ -177,13 +201,6 @@ async function findOrganizationMember( }); } -/** - * Decides whether SCIM provisioning may attach to a pre-existing user that - * matched by email. Linking by email alone would give the SCIM token full - * read/write/delete access to a user it never provisioned, so this returns - * `false` unless `opts.linkExistingUsers` explicitly opts in and every - * configured constraint passes. - */ async function canLinkExistingUser( ctx: GenericEndpointContext, opts: SCIMOptions, @@ -196,10 +213,8 @@ async function canLinkExistingUser( const { organizationId, providerId } = ctx.context.scimProvider; - // An empty policy object must not silently allow linking — require at least - // one positive constraint to be configured. + // Empty policy objects do not opt in to linking. const hasConstraint = - (policy.trustedDomains?.length ?? 0) > 0 || policy.requireExistingOrgMembership === true || typeof policy.shouldLinkUser === "function"; if (!hasConstraint) return false; @@ -214,13 +229,6 @@ async function canLinkExistingUser( if (!member) return false; } - if (policy.trustedDomains?.length) { - const domain = email.split("@")[1]?.toLowerCase(); - const allowed = - !!domain && policy.trustedDomains.some((d) => d.toLowerCase() === domain); - if (!allowed) return false; - } - if (policy.shouldLinkUser) { const ok = await policy.shouldLinkUser({ user: existingUser, @@ -233,53 +241,21 @@ async function canLinkExistingUser( return true; } -async function assertSCIMProviderAccess( - ctx: GenericEndpointContext, - userId: string, - provider: SCIMProvider, - requiredRole: string[], -): Promise { - if (provider.organizationId) { - if (!ctx.context.hasPlugin("organization")) { - throw new APIError("FORBIDDEN", { - message: "Organization plugin is required to access this SCIM provider", - }); - } - - const member = await findOrganizationMember( - ctx, - userId, - provider.organizationId, - ); - - if (!member) { - throw new APIError("FORBIDDEN", { - message: - "You must be a member of the organization to access this provider", - }); - } - - if (!hasRequiredRole(member.role, requiredRole)) { - throw new APIError("FORBIDDEN", { - message: "Insufficient role for this operation", - }); - } - } else if (provider.userId !== userId) { - throw new APIError("FORBIDDEN", { - message: "You must be the owner to access this provider", - }); - } -} - async function checkSCIMProviderAccess( ctx: GenericEndpointContext, - userId: string, + user: User, providerId: string, - requiredRole: string[], + organizationId: string, + opts: SCIMOptions, ): Promise { const provider = await ctx.context.adapter.findOne({ model: "scimProvider", - where: [{ field: "providerId", value: providerId }], + where: [ + { + field: "providerKey", + value: scimProviderKey({ providerId, organizationId }), + }, + ], }); if (!provider) { @@ -288,16 +264,11 @@ async function checkSCIMProviderAccess( }); } - await assertSCIMProviderAccess(ctx, userId, provider, requiredRole); + await assertOrgAccess(ctx, opts, user, organizationId); return provider; } -/** - * Rejects a SCIM email change that would collide with another user. Mirrors the - * uniqueness guard `createSCIMUser` already performs, so PUT/PATCH cannot - * reassign one user's email onto another existing user. - */ async function assertSCIMEmailAvailable( ctx: GenericEndpointContext, email: string, @@ -315,11 +286,221 @@ async function assertSCIMEmailAvailable( } } -/** - * Applies SCIM `active` semantics to a pending user update. `active` maps to the - * admin plugin's `banned` field, the only enforced disabled-user state in Better - * Auth, so honoring deactivation requires the admin plugin. - */ +async function updateSCIMUserAndAccount( + adapter: SCIMWriteAdapter, + input: { + userId: string; + accountId: string; + userUpdate: Record; + accountUpdate: Record; + }, +): Promise<[User | null, Account | null]> { + const updatedUser = + Object.keys(input.userUpdate).length > 0 + ? await adapter.update({ + model: "user", + where: [{ field: "id", value: input.userId }], + update: input.userUpdate, + }) + : null; + + const updatedAccount = + Object.keys(input.accountUpdate).length > 0 + ? await adapter.update({ + model: "account", + where: [{ field: "id", value: input.accountId }], + update: input.accountUpdate, + }) + : null; + + return [updatedUser, updatedAccount]; +} + +async function removeOrgProvisioningState( + adapter: SCIMWriteAdapter, + { + member, + account, + userId, + organizationId, + providerId, + unlinkAccount, + removeSCIMGroupState, + removeTeamState, + }: { + member: Member | null; + account: Account | null; + userId: string; + organizationId: string; + providerId: string; + unlinkAccount: boolean; + removeSCIMGroupState: boolean; + removeTeamState: boolean; + }, +): Promise { + if (removeSCIMGroupState) { + await removeUserFromSCIMGroups(adapter, { + providerId, + organizationId, + userId, + }); + } + if (member) { + await adapter.delete({ + model: "member", + where: [{ field: "id", value: member.id }], + }); + if (removeTeamState) { + const teams = await adapter.findMany<{ id: string }>({ + model: "team", + where: [{ field: "organizationId", value: organizationId }], + }); + if (teams.length > 0) { + await adapter.deleteMany({ + model: "teamMember", + where: [ + { field: "userId", value: member.userId }, + { + field: "teamId", + value: teams.map((team) => team.id), + operator: "in", + }, + ], + }); + } + } + } + if (account && unlinkAccount) { + await adapter.delete({ + model: "account", + where: [{ field: "id", value: account.id }], + }); + } +} + +async function getOrgMembershipChangeContext( + ctx: GenericEndpointContext, + user: User, + organizationId: string, +) { + const organizationPlugin = ctx.context.getPlugin("organization"); + if (!organizationPlugin) { + throw new SCIMAPIError("BAD_REQUEST", { + detail: + "Organization-scoped SCIM membership changes require the organization plugin", + }); + } + const orgOptions = organizationPlugin.options; + const orgAdapter = getOrgAdapter(ctx.context, orgOptions); + const member = await findOrganizationMember(ctx, user.id, organizationId); + const organization = member + ? await orgAdapter.findOrganizationById(organizationId) + : null; + + return { orgOptions, member, organization }; +} + +async function removeUserFromOrg( + ctx: GenericEndpointContext, + { + user, + account, + organizationId, + providerId, + unlinkAccount, + removeSCIMGroupState, + removeTeamState, + }: { + user: User; + account: Account | null; + organizationId: string; + providerId: string; + unlinkAccount: boolean; + removeSCIMGroupState: boolean; + removeTeamState: boolean; + }, +): Promise { + const { orgOptions, member, organization } = + await getOrgMembershipChangeContext(ctx, user, organizationId); + + if (member && organization) { + await orgOptions?.organizationHooks?.beforeRemoveMember?.({ + member, + user, + organization, + }); + } + + await ctx.context.adapter.transaction(async (trx) => { + await removeOrgProvisioningState(trx, { + member, + account, + userId: user.id, + organizationId, + providerId, + unlinkAccount, + removeSCIMGroupState, + removeTeamState: removeTeamState && !!orgOptions?.teams?.enabled, + }); + }); + + if (member && organization) { + await orgOptions?.organizationHooks?.afterRemoveMember?.({ + member, + user, + organization, + }); + } +} + +async function ensureOrgMembership( + adapter: SCIMWriteAdapter, + userId: string, + organizationId: string, +): Promise { + const existing = await adapter.findOne({ + model: "member", + where: [ + { field: "userId", value: userId }, + { field: "organizationId", value: organizationId }, + ], + }); + if (existing) return; + const roleGrants = await adapter.findMany({ + model: "scimGroupRoleGrant", + where: [ + { field: "organizationId", value: organizationId }, + { field: "userId", value: userId }, + { field: "isRoleProjected", value: true }, + ], + }); + const roles = Array.from( + new Set(["member", ...roleGrants.map((grant) => grant.role)]), + ); + await adapter.create({ + model: "member", + data: { + userId, + role: roles.join(","), + createdAt: new Date(), + organizationId, + }, + }); +} + +async function revokeSessionsIfSoleOrgMembership( + ctx: GenericEndpointContext, + userId: string, +): Promise { + const remaining = await ctx.context.adapter.findMany({ + model: "member", + where: [{ field: "userId", value: userId }], + }); + if (remaining.length === 0) { + await ctx.context.internalAdapter.deleteUserSessions(userId); + } +} + function resolveSCIMActiveDeactivation( ctx: GenericEndpointContext, userUpdate: Record, @@ -382,87 +563,23 @@ export const generateSCIMToken = (opts: SCIMOptions) => async (ctx) => { const { providerId, organizationId } = ctx.body; const user = ctx.context.session.user; - const requiredRole = resolveRequiredRoles(ctx, opts); + // Prevent forged SCIM account namespace segments. if (providerId.includes(":")) { throw new APIError("BAD_REQUEST", { message: "Provider id contains forbidden characters", }); } - // A SCIM token authenticates as the row whose providerId matches the - // claim in the bearer token. Reject ids that collide with other - // account-producing providers so a token cannot act against accounts - // that were never SCIM-provisioned. - // - // We read social provider keys from `options.socialProviders` (raw - // config) rather than `context.socialProviders` (resolved list) so - // that providers configured with `enabled: false` are still - // rejected: their account rows can persist in the DB from a prior - // enabled state. - const defaultSSOProviderIds = getDefaultSSOProviderIds( - ctx.context.getPlugin("sso")?.options, - ); - const reservedProviderIds = new Set([ - "credential", - "email-otp", - "magic-link", - "phone-number", - "anonymous", - "siwe", - ...Object.keys(ctx.context.options.socialProviders ?? {}), - ...ctx.context.socialProviders.map((p) => p.id), - ...defaultSSOProviderIds, - ]); - if (reservedProviderIds.has(providerId)) { + if (!organizationId) { throw new APIError("BAD_REQUEST", { message: - "Provider id collides with another account provider and cannot be used for SCIM", + "SCIM tokens must be scoped to an organization. Configure an app-level provider via `staticProviders` for single-tenant SCIM.", }); } - if (ctx.context.hasPlugin("sso")) { - const existingSSOProvider = await ctx.context.adapter.findOne<{ - id: string; - }>({ - model: "ssoProvider", - where: [{ field: "providerId", value: providerId }], - }); - if (existingSSOProvider) { - throw new APIError("BAD_REQUEST", { - message: - "Provider id collides with another account provider and cannot be used for SCIM", - }); - } - } + const member = await assertOrgAccess(ctx, opts, user, organizationId); - if (organizationId && !ctx.context.hasPlugin("organization")) { - throw new APIError("BAD_REQUEST", { - message: - "Restricting a token to an organization requires the organization plugin", - }); - } - - let member: Member | null = null; - if (organizationId) { - member = await findOrganizationMember(ctx, user.id, organizationId); - - if (!member) { - throw new APIError("FORBIDDEN", { - message: "You are not a member of the organization", - }); - } - - if (!hasRequiredRole(member.role, requiredRole)) { - throw new APIError("FORBIDDEN", { - message: "Insufficient role for this operation", - }); - } - } - - // Optional app-level gate. Personal (non-org) tokens otherwise have no - // authorization beyond a valid session, so this is the hook to - // restrict who can mint them. if (opts.canGenerateToken) { const allowed = await opts.canGenerateToken({ user, @@ -477,23 +594,13 @@ export const generateSCIMToken = (opts: SCIMOptions) => } } + const providerKey = scimProviderKey({ providerId, organizationId }); const scimProvider = await ctx.context.adapter.findOne({ model: "scimProvider", - where: [ - { field: "providerId", value: providerId }, - ...(organizationId - ? [{ field: "organizationId", value: organizationId }] - : []), - ], + where: [{ field: "providerKey", value: providerKey }], }); if (scimProvider) { - await assertSCIMProviderAccess( - ctx, - user.id, - scimProvider, - requiredRole, - ); await ctx.context.adapter.delete({ model: "scimProvider", where: [{ field: "id", value: scimProvider.id }], @@ -502,7 +609,7 @@ export const generateSCIMToken = (opts: SCIMOptions) => const baseToken = generateRandomString(24); const scimToken = base64Url.encode( - `${baseToken}:${providerId}${organizationId ? `:${organizationId}` : ""}`, + `${baseToken}:${providerId}:${organizationId}`, ); if (opts.beforeSCIMTokenGenerated) { @@ -517,9 +624,9 @@ export const generateSCIMToken = (opts: SCIMOptions) => model: "scimProvider", data: { providerId, + providerKey, organizationId, scimToken: await storeSCIMToken(ctx, opts, baseToken), - userId: user.id, }, }); @@ -584,28 +691,36 @@ export const listSCIMProviderConnections = (opts: SCIMOptions) => }, }, async (ctx) => { - const userId = ctx.context.session.user.id; - const requiredRole = resolveRequiredRoles(ctx, opts); - const orgMemberships: Map = ctx.context.hasPlugin( - "organization", - ) - ? await getSCIMUserOrgMemberships(ctx, userId) - : new Map(); + const user = ctx.context.session.user; + const membershipsByOrg = ctx.context.hasPlugin("organization") + ? await getUserMembershipsByOrg(ctx, user.id) + : new Map(); + const organizationIds = Array.from(membershipsByOrg.keys()); + if (!organizationIds.length) { + return ctx.json({ providers: [] }); + } - const allProviders = await ctx.context.adapter.findMany({ + const orgProviders = await ctx.context.adapter.findMany({ model: "scimProvider", + where: [ + { + field: "organizationId", + value: organizationIds, + operator: "in", + }, + ], }); - const accessibleProviders = allProviders.filter((p) => { - if (p.organizationId) { - const roles = orgMemberships.get(p.organizationId); - return roles - ? !requiredRole.length || - roles.some((role) => requiredRole.includes(role)) - : false; - } - return p.userId === userId; - }); + const accessibleProviders: SCIMProvider[] = []; + for (const provider of orgProviders) { + const member = membershipsByOrg.get(provider.organizationId) ?? null; + const allowed = await isOrgActionAllowed(ctx, opts, { + user, + member, + organizationId: provider.organizationId, + }); + if (allowed) accessibleProviders.push(provider); + } const providers = accessibleProviders.map((p) => normalizeSCIMProvider(p), @@ -657,15 +772,15 @@ export const getSCIMProviderConnection = (opts: SCIMOptions) => }, }, async (ctx) => { - const { providerId } = ctx.query; - const userId = ctx.context.session.user.id; - const requiredRole = resolveRequiredRoles(ctx, opts); + const { providerId, organizationId } = ctx.query; + const user = ctx.context.session.user; const provider = await checkSCIMProviderAccess( ctx, - userId, + user, providerId, - requiredRole, + organizationId, + opts, ); return ctx.json(normalizeSCIMProvider(provider)); @@ -709,15 +824,20 @@ export const deleteSCIMProviderConnection = (opts: SCIMOptions) => }, }, async (ctx) => { - const { providerId } = ctx.body; - const userId = ctx.context.session.user.id; - const requiredRole = resolveRequiredRoles(ctx, opts); + const { providerId, organizationId } = ctx.body; + const user = ctx.context.session.user; - await checkSCIMProviderAccess(ctx, userId, providerId, requiredRole); + const provider = await checkSCIMProviderAccess( + ctx, + user, + providerId, + organizationId, + opts, + ); await ctx.context.adapter.delete({ model: "scimProvider", - where: [{ field: "providerId", value: providerId }], + where: [{ field: "id", value: provider.id }], }); return ctx.json({ success: true }); @@ -757,14 +877,15 @@ export const createSCIMUser = ( }, async (ctx) => { const body = ctx.body; - const providerId = ctx.context.scimProvider.providerId; + const { organizationId } = ctx.context.scimProvider; + const accountProviderId = scimAccountProviderId(ctx.context.scimProvider); const accountId = getAccountId(body.userName, body.externalId); const existingAccount = await ctx.context.adapter.findOne({ model: "account", where: [ { field: "accountId", value: accountId }, - { field: "providerId", value: providerId }, + { field: "providerId", value: accountProviderId }, ], }); @@ -775,9 +896,7 @@ export const createSCIMUser = ( }); } - // Reject `active:false` before provisioning so create never persists a - // user it cannot then deactivate. - if (body.active === false) { + if (body.active === false && !organizationId) { resolveSCIMActiveDeactivation(ctx, { banned: true }); } @@ -795,7 +914,7 @@ export const createSCIMUser = ( const createAccount = (userId: string) => ctx.context.internalAdapter.createAccount({ userId: userId, - providerId: providerId, + providerId: accountProviderId, accountId: accountId, accessToken: "", refreshToken: "", @@ -811,28 +930,12 @@ export const createSCIMUser = ( ); const createOrgMembership = async (userId: string) => { - const organizationId = ctx.context.scimProvider.organizationId; - - if (organizationId) { - const isOrgMember = await ctx.context.adapter.findOne({ - model: "member", - where: [ - { field: "organizationId", value: organizationId }, - { field: "userId", value: userId }, - ], - }); - - if (!isOrgMember) { - return await ctx.context.adapter.create({ - model: "member", - data: { - userId: userId, - role: "member", - createdAt: new Date(), - organizationId, - }, - }); - } + if (organizationId && body.active !== false) { + await ensureOrgMembership( + ctx.context.adapter, + userId, + organizationId, + ); } }; @@ -840,9 +943,6 @@ export const createSCIMUser = ( let account: Account; if (existingUser) { - // Do not auto-link a pre-existing user by email alone — that would - // grant this SCIM token access to an account it never provisioned. - // Require an explicit, configured policy to allow it. const allowLink = await canLinkExistingUser( ctx, opts, @@ -872,7 +972,7 @@ export const createSCIMUser = ( }); } - if (body.active === false) { + if (body.active === false && !organizationId) { const deactivation: Record = { banned: true }; resolveSCIMActiveDeactivation(ctx, deactivation); const banned = await ctx.context.internalAdapter.updateUser( @@ -889,6 +989,8 @@ export const createSCIMUser = ( ctx.context.baseURL, user, account, + undefined, + organizationId ? body.active !== false : undefined, ); ctx.setStatus(201); @@ -929,12 +1031,12 @@ export const updateSCIMUser = (authMiddleware: AuthMiddleware) => const body = ctx.body; const userId = ctx.params.userId; const { organizationId, providerId } = ctx.context.scimProvider; + const accountProviderId = scimAccountProviderId(ctx.context.scimProvider); const accountId = getAccountId(body.userName, body.externalId); const { user, account } = await findUserById(ctx.context.adapter, { userId, - providerId, - organizationId, + providerId: accountProviderId, }); if (!user) { @@ -962,30 +1064,84 @@ export const updateSCIMUser = (authMiddleware: AuthMiddleware) => if (emailChanged) { userUpdate.emailVerified = false; } - if (body.active !== undefined) { + // App-level providers map `active: false` to a global ban. + if (!organizationId && body.active !== undefined) { userUpdate.banned = body.active === false; } - const deactivating = resolveSCIMActiveDeactivation(ctx, userUpdate); + const deactivating = organizationId + ? false + : resolveSCIMActiveDeactivation(ctx, userUpdate); + const accountUpdate = { + accountId, + updatedAt: new Date(), + }; + const orgMembershipChange = + organizationId && body.active === false + ? await getOrgMembershipChangeContext(ctx, user, organizationId) + : null; + if (orgMembershipChange?.member && orgMembershipChange.organization) { + await orgMembershipChange.orgOptions?.organizationHooks?.beforeRemoveMember?.( + { + member: orgMembershipChange.member, + user, + organization: orgMembershipChange.organization, + }, + ); + } + + let active: boolean | undefined; const [updatedUser, updatedAccount] = await ctx.context.adapter.transaction<[User | null, Account | null]>( - async () => { - const updatedUser = await ctx.context.internalAdapter.updateUser( + async (trx) => { + if (organizationId) { + if (body.active === false) { + await removeOrgProvisioningState(trx, { + member: orgMembershipChange?.member ?? null, + account, + userId, + organizationId, + providerId, + unlinkAccount: false, + removeSCIMGroupState: false, + removeTeamState: false, + }); + active = false; + } else if (body.active === true) { + await ensureOrgMembership(trx, userId, organizationId); + active = true; + } + } + return updateSCIMUserAndAccount(trx, { userId, + accountId: account.id, userUpdate, - ); - - const updatedAccount = - await ctx.context.internalAdapter.updateAccount(account.id, { - accountId, - updatedAt: new Date(), - }); - - return [updatedUser, updatedAccount]; + accountUpdate, + }); }, ); - if (deactivating) { + if (orgMembershipChange?.member && orgMembershipChange.organization) { + await orgMembershipChange.orgOptions?.organizationHooks?.afterRemoveMember?.( + { + member: orgMembershipChange.member, + user, + organization: orgMembershipChange.organization, + }, + ); + } + + if (organizationId) { + if (body.active === false) { + await revokeSessionsIfSoleOrgMembership(ctx, userId); + } else if (body.active !== true) { + active = !!(await findOrganizationMember( + ctx, + userId, + organizationId, + )); + } + } else if (deactivating) { await ctx.context.internalAdapter.deleteUserSessions(userId); } @@ -994,9 +1150,10 @@ export const updateSCIMUser = (authMiddleware: AuthMiddleware) => : undefined; const userResource = createUserResource( ctx.context.baseURL, - updatedUser!, - updatedAccount, + updatedUser ?? user, + updatedAccount ?? account, groups, + active, ); return ctx.json(userResource); @@ -1061,50 +1218,48 @@ export const listSCIMUsers = (authMiddleware: AuthMiddleware) => ctx.query?.filter, ); - const providerId = ctx.context.scimProvider.providerId; + const accountProviderId = scimAccountProviderId(ctx.context.scimProvider); const accounts = await ctx.context.adapter.findMany({ model: "account", - where: [{ field: "providerId", value: providerId }], + where: [{ field: "providerId", value: accountProviderId }], }); const accountUserIds = accounts.map((account) => account.userId); - // No accounts exist for this provider - if (accountUserIds.length === 0) { return ctx.json(emptyListResponse); } - let userFilters: SCIMFilterWhere[] = [ + const userFilters: SCIMFilterWhere[] = [ { field: "id", value: accountUserIds, operator: "in" }, ]; const organizationId = ctx.context.scimProvider.organizationId; - if (organizationId) { - const members = await ctx.context.adapter.findMany({ - model: "member", - where: [ - { field: "organizationId", value: organizationId }, - { field: "userId", value: accountUserIds, operator: "in" }, - ], - }); - - const memberUserIds = members.map((member) => member.userId); - - // No members exist for this organization - - if (memberUserIds.length === 0) { - return ctx.json(emptyListResponse); - } - - userFilters = [{ field: "id", value: memberUserIds, operator: "in" }]; - } const users = await ctx.context.adapter.findMany({ model: "user", where: [...userFilters, ...apiFilters], }); + // Deactivated org users keep their SCIM account; membership sets `active`. + const activeMemberIds = organizationId + ? new Set( + ( + await ctx.context.adapter.findMany({ + model: "member", + where: [ + { field: "organizationId", value: organizationId }, + { + field: "userId", + value: users.map((user) => user.id), + operator: "in", + }, + ], + }) + ).map((member) => member.userId), + ) + : null; + const accountByUserId = new Map( accounts.map((account) => [account.userId, account]), ); @@ -1120,6 +1275,7 @@ export const listSCIMUsers = (authMiddleware: AuthMiddleware) => user, accountByUserId.get(user.id), groupReferencesByUserId.get(user.id), + activeMemberIds ? activeMemberIds.has(user.id) : undefined, ), ); @@ -1162,13 +1318,12 @@ export const getSCIMUser = (authMiddleware: AuthMiddleware) => }, async (ctx) => { const userId = ctx.params.userId; - const providerId = ctx.context.scimProvider.providerId; const organizationId = ctx.context.scimProvider.organizationId; + const accountProviderId = scimAccountProviderId(ctx.context.scimProvider); const { user, account } = await findUserById(ctx.context.adapter, { userId, - providerId, - organizationId, + providerId: accountProviderId, }); if (!user) { @@ -1180,9 +1335,12 @@ export const getSCIMUser = (authMiddleware: AuthMiddleware) => const groups = organizationId ? await listUserSCIMGroupReferences(ctx, user.id) : undefined; + const active = organizationId + ? !!(await findOrganizationMember(ctx, user.id, organizationId)) + : undefined; return ctx.json( - createUserResource(ctx.context.baseURL, user, account, groups), + createUserResource(ctx.context.baseURL, user, account, groups, active), ); }, ); @@ -1235,11 +1393,11 @@ export const patchSCIMUser = (authMiddleware: AuthMiddleware) => const userId = ctx.params.userId; const organizationId = ctx.context.scimProvider.organizationId; const providerId = ctx.context.scimProvider.providerId; + const accountProviderId = scimAccountProviderId(ctx.context.scimProvider); const { user, account } = await findUserById(ctx.context.adapter, { userId, - providerId, - organizationId, + providerId: accountProviderId, }); if (!user) { @@ -1253,9 +1411,18 @@ export const patchSCIMUser = (authMiddleware: AuthMiddleware) => ctx.body.Operations, ); + // Org-scoped `active` changes membership, not a global ban. + let orgActive: boolean | undefined; + if (organizationId && "banned" in userPatch) { + orgActive = userPatch.banned !== true; + // biome-ignore lint/performance/noDelete: drop the field so an org update never writes a global ban. + delete userPatch.banned; + } + if ( Object.keys(userPatch).length === 0 && - Object.keys(accountPatch).length === 0 + Object.keys(accountPatch).length === 0 && + orgActive === undefined ) { throw new SCIMAPIError("BAD_REQUEST", { detail: "No valid fields to update", @@ -1270,24 +1437,70 @@ export const patchSCIMUser = (authMiddleware: AuthMiddleware) => userPatch.emailVerified = false; } - const deactivating = resolveSCIMActiveDeactivation(ctx, userPatch); - - await Promise.all([ + const deactivating = organizationId + ? false + : resolveSCIMActiveDeactivation(ctx, userPatch); + const userUpdate = Object.keys(userPatch).length > 0 - ? ctx.context.internalAdapter.updateUser(userId, { - ...userPatch, - updatedAt: new Date(), - }) - : Promise.resolve(), + ? { ...userPatch, updatedAt: new Date() } + : {}; + const accountUpdate = Object.keys(accountPatch).length > 0 - ? ctx.context.internalAdapter.updateAccount(account.id, { - ...accountPatch, - updatedAt: new Date(), - }) - : Promise.resolve(), - ]); + ? { ...accountPatch, updatedAt: new Date() } + : {}; + const orgMembershipChange = + organizationId && orgActive === false + ? await getOrgMembershipChangeContext(ctx, user, organizationId) + : null; - if (deactivating) { + if (orgMembershipChange?.member && orgMembershipChange.organization) { + await orgMembershipChange.orgOptions?.organizationHooks?.beforeRemoveMember?.( + { + member: orgMembershipChange.member, + user, + organization: orgMembershipChange.organization, + }, + ); + } + + await ctx.context.adapter.transaction(async (trx) => { + if (organizationId) { + if (orgActive === false) { + await removeOrgProvisioningState(trx, { + member: orgMembershipChange?.member ?? null, + account, + userId, + organizationId, + providerId, + unlinkAccount: false, + removeSCIMGroupState: false, + removeTeamState: false, + }); + } else if (orgActive === true) { + await ensureOrgMembership(trx, userId, organizationId); + } + } + await updateSCIMUserAndAccount(trx, { + userId, + accountId: account.id, + userUpdate, + accountUpdate, + }); + }); + + if (orgMembershipChange?.member && orgMembershipChange.organization) { + await orgMembershipChange.orgOptions?.organizationHooks?.afterRemoveMember?.( + { + member: orgMembershipChange.member, + user, + organization: orgMembershipChange.organization, + }, + ); + } + + if (organizationId && orgActive === false) { + await revokeSessionsIfSoleOrgMembership(ctx, userId); + } else if (!organizationId && deactivating) { await ctx.context.internalAdapter.deleteUserSessions(userId); } @@ -1611,11 +1824,11 @@ export const deleteSCIMUser = (authMiddleware: AuthMiddleware) => const userId = ctx.params.userId; const providerId = ctx.context.scimProvider.providerId; const organizationId = ctx.context.scimProvider.organizationId; + const accountProviderId = scimAccountProviderId(ctx.context.scimProvider); const { user, account } = await findUserById(ctx.context.adapter, { userId, - providerId, - organizationId, + providerId: accountProviderId, }); if (!user) { @@ -1624,85 +1837,17 @@ export const deleteSCIMUser = (authMiddleware: AuthMiddleware) => }); } - // Organization-scoped SCIM must not delete the *global* Better Auth - // user — that would remove the person's access to every other - // organization and identity, well outside this token's boundary. - // Deprovision instead: drop their membership in this organization and - // the SCIM account link for this provider, leaving the user intact. if (organizationId) { - const organizationPlugin = ctx.context.getPlugin("organization"); - if (!organizationPlugin) { - throw new SCIMAPIError("BAD_REQUEST", { - detail: - "Organization-scoped SCIM deprovisioning requires the organization plugin", - }); - } - const orgOptions = organizationPlugin.options; - const orgAdapter = getOrgAdapter(ctx.context, orgOptions); - const member = await findOrganizationMember( - ctx, - userId, + await removeUserFromOrg(ctx, { + user, + account, organizationId, - ); - const organization = member - ? await orgAdapter.findOrganizationById(organizationId) - : null; - - if (member && organization) { - await orgOptions?.organizationHooks?.beforeRemoveMember?.({ - member, - user, - organization, - }); - } - - await ctx.context.adapter.transaction(async (trx) => { - await removeUserFromSCIMGroups(trx, { - providerId, - organizationId, - userId, - }); - if (member) { - await trx.delete({ - model: "member", - where: [{ field: "id", value: member.id }], - }); - if (orgOptions?.teams?.enabled) { - const teams = await trx.findMany<{ id: string }>({ - model: "team", - where: [{ field: "organizationId", value: organizationId }], - }); - if (teams.length > 0) { - await trx.deleteMany({ - model: "teamMember", - where: [ - { field: "userId", value: userId }, - { - field: "teamId", - value: teams.map((team) => team.id), - operator: "in", - }, - ], - }); - } - } - } - if (account) { - await trx.delete({ - model: "account", - where: [{ field: "id", value: account.id }], - }); - } + providerId, + unlinkAccount: true, + removeSCIMGroupState: true, + removeTeamState: true, }); - - if (member && organization) { - await orgOptions?.organizationHooks?.afterRemoveMember?.({ - member, - user, - organization, - }); - } - + await revokeSessionsIfSoleOrgMembership(ctx, userId); ctx.setStatus(204); return; } @@ -1974,11 +2119,7 @@ export const getSCIMResourceType = createAuthEndpoint( const findUserById = async ( adapter: DBAdapter, - { - userId, - providerId, - organizationId, - }: { userId: string; providerId: string; organizationId?: string }, + { userId, providerId }: { userId: string; providerId: string }, ) => { const account = await adapter.findOne({ model: "account", @@ -1988,31 +2129,10 @@ const findUserById = async ( ], }); - // Disallows access to the resource - // Account is not associated to the provider - if (!account) { return { user: null, account: null }; } - let member: Member | null = null; - if (organizationId) { - member = await adapter.findOne({ - model: "member", - where: [ - { field: "organizationId", value: organizationId }, - { field: "userId", value: userId }, - ], - }); - } - - // Disallows access to the resource - // Token is restricted to an org and the member is not part of it - - if (organizationId && !member) { - return { user: null, account: null }; - } - const user = await adapter.findOne({ model: "user", where: [{ field: "id", value: userId }], diff --git a/packages/scim/src/scim-groups.test.ts b/packages/scim/src/scim-groups.test.ts index 2a00d66773..51acd032cb 100644 --- a/packages/scim/src/scim-groups.test.ts +++ b/packages/scim/src/scim-groups.test.ts @@ -94,6 +94,7 @@ const createTestInstance = (scimOptions?: SCIMOptions) => { organizationId?: string, ) { const headers = await getAuthCookieHeaders(); + if (!organizationId) throw new Error("SCIM token requires an organization"); const { scimToken } = await auth.api.generateSCIMToken({ body: { providerId, @@ -107,7 +108,7 @@ const createTestInstance = (scimOptions?: SCIMOptions) => { async function getOrganizationSCIMToken(providerId = "scim-groups-provider") { const org = await registerOrganization(); - const scimToken = await getSCIMToken(providerId, org?.id); + const scimToken = await getSCIMToken(providerId, org!.id); return { organization: org, scimToken }; } @@ -656,16 +657,9 @@ describe("SCIM Groups", () => { ); }); - it("rejects non-org tokens and invalid group members", async () => { - const { - auth, - headers, - createGroup, - createUser, - getSCIMToken, - getOrganizationSCIMToken, - } = createTestInstance(); - const nonOrgToken = await getSCIMToken("personal-provider"); + it("rejects invalid group members", async () => { + const { auth, headers, createGroup, createUser, getOrganizationSCIMToken } = + createTestInstance(); const { scimToken: orgAToken } = await getOrganizationSCIMToken("provider-a"); const { scimToken: orgBToken } = @@ -673,11 +667,6 @@ describe("SCIM Groups", () => { const userA = await createUser(orgAToken, "org-a@test.com"); const userB = await createUser(orgBToken, "org-b@test.com"); - await expect( - createGroup(nonOrgToken, { displayName: "Personal" }), - ).rejects.toMatchObject({ - body: { status: "400", scimType: "invalidValue" }, - }); await expect( createGroup(orgAToken, { displayName: "Nested", diff --git a/packages/scim/src/scim-namespacing.test.ts b/packages/scim/src/scim-namespacing.test.ts new file mode 100644 index 0000000000..bb7dc9d96d --- /dev/null +++ b/packages/scim/src/scim-namespacing.test.ts @@ -0,0 +1,310 @@ +import { sso } from "@better-auth/sso"; +import { betterAuth } from "better-auth"; +import { memoryAdapter } from "better-auth/adapters/memory"; +import { createAuthClient } from "better-auth/client"; +import { setCookieToHeader } from "better-auth/cookies"; +import { organization } from "better-auth/plugins"; +import { describe, expect, it } from "vitest"; +import { scim } from "."; +import { scimClient } from "./client"; + +const emptyData = () => ({ + user: [], + session: [], + verification: [], + account: [], + ssoProvider: [], + scimProvider: [], + scimGroup: [], + scimGroupMember: [], + scimGroupRole: [], + scimGroupRoleGrant: [], + organization: [], + member: [], +}); + +const instance = () => { + const auth = betterAuth({ + database: memoryAdapter(emptyData()), + baseURL: "http://localhost:3000", + emailAndPassword: { enabled: true }, + plugins: [sso(), scim(), organization()], + }); + const authClient = createAuthClient({ + baseURL: "http://localhost:3000", + plugins: [scimClient()], + fetchOptions: { + customFetchImpl: async (url, init) => + auth.handler(new Request(url, init)), + }, + }); + const signIn = async (email: string) => { + const headers = new Headers(); + await authClient.signUp.email({ email, password: "password", name: email }); + await authClient.signIn.email( + { email, password: "password" }, + { throw: true, onSuccess: setCookieToHeader(headers) }, + ); + return headers; + }; + return { auth, signIn }; +}; + +/** + * @see https://github.com/better-auth/better-auth/security/advisories/GHSA-rjg6 + */ +describe("SCIM account namespacing", () => { + it("stores the SCIM account under a namespaced providerId, not the logical id", async () => { + const { auth, signIn } = instance(); + const headers = await signIn("owner@acme.test"); + const org = await auth.api.createOrganization({ + body: { slug: "acme", name: "Acme" }, + headers, + }); + const { scimToken } = await auth.api.generateSCIMToken({ + body: { providerId: "okta", organizationId: org!.id }, + headers, + }); + const ctx = await auth.$context; + const provider = await ctx.adapter.findOne<{ providerKey: string }>({ + model: "scimProvider", + where: [{ field: "providerId", value: "okta" }], + }); + expect(provider?.providerKey).toBe(`${org!.id}:okta`); + + const provisioned = await auth.api.createSCIMUser({ + body: { userName: "u@acme.test", emails: [{ value: "u@acme.test" }] }, + headers: { authorization: `Bearer ${scimToken}` }, + }); + + const accounts = await ctx.internalAdapter.findAccounts(provisioned.id); + const scimAccount = accounts.find((a) => a.accountId === "u@acme.test"); + expect(scimAccount?.providerId).toBe(`scim:${org!.id}:okta`); + expect(scimAccount?.providerId).not.toBe("okta"); + }); + + it("cannot resolve an account a colliding provider id created outside SCIM", async () => { + const { auth, signIn } = instance(); + const headers = await signIn("owner@acme.test"); + const org = await auth.api.createOrganization({ + body: { slug: "acme", name: "Acme" }, + headers, + }); + + const ctx = await auth.$context; + const victim = await ctx.internalAdapter.createUser( + { + email: "victim@acme.test", + name: "victim", + }, + { method: "test" }, + ); + await ctx.internalAdapter.createAccount({ + userId: victim.id, + providerId: "okta", + accountId: "victim@acme.test", + accessToken: "", + refreshToken: "", + }); + + const { scimToken } = await auth.api.generateSCIMToken({ + body: { providerId: "okta", organizationId: org!.id }, + headers, + }); + + await expect( + auth.api.getSCIMUser({ + params: { userId: victim.id }, + headers: { authorization: `Bearer ${scimToken}` }, + }), + ).rejects.toThrowError( + expect.objectContaining({ message: "User not found" }), + ); + }); + + it("isolates two organizations that register the same logical provider id", async () => { + const { auth, signIn } = instance(); + const headersA = await signIn("a@x.test"); + const headersB = await signIn("b@y.test"); + const orgA = await auth.api.createOrganization({ + body: { slug: "org-a", name: "Org A" }, + headers: headersA, + }); + const orgB = await auth.api.createOrganization({ + body: { slug: "org-b", name: "Org B" }, + headers: headersB, + }); + const { scimToken: tokenA } = await auth.api.generateSCIMToken({ + body: { providerId: "okta", organizationId: orgA!.id }, + headers: headersA, + }); + const { scimToken: tokenB } = await auth.api.generateSCIMToken({ + body: { providerId: "okta", organizationId: orgB!.id }, + headers: headersB, + }); + + const userA = await auth.api.createSCIMUser({ + body: { userName: "shared@x.test", emails: [{ value: "shared@x.test" }] }, + headers: { authorization: `Bearer ${tokenA}` }, + }); + const userB = await auth.api.createSCIMUser({ + body: { userName: "shared@y.test", emails: [{ value: "shared@y.test" }] }, + headers: { authorization: `Bearer ${tokenB}` }, + }); + + const listA = await auth.api.listSCIMUsers({ + headers: { authorization: `Bearer ${tokenA}` }, + }); + expect(listA.Resources?.map((r) => r.id)).toEqual([userA.id]); + const listB = await auth.api.listSCIMUsers({ + headers: { authorization: `Bearer ${tokenB}` }, + }); + expect(listB.Resources?.map((r) => r.id)).toEqual([userB.id]); + }); + + it("rejects a stored org-scoped token without the organization segment", async () => { + const { auth, signIn } = instance(); + const headers = await signIn("owner@acme.test"); + const org = await auth.api.createOrganization({ + body: { slug: "org-token", name: "Org Token" }, + headers, + }); + const { scimToken } = await auth.api.generateSCIMToken({ + body: { providerId: "okta", organizationId: org!.id }, + headers, + }); + const [rawToken, providerId] = Buffer.from(scimToken, "base64") + .toString("utf8") + .split(":"); + const orglessToken = Buffer.from(`${rawToken}:${providerId}`).toString( + "base64", + ); + + await expect( + auth.api.createSCIMUser({ + body: { userName: "missing-org@acme.test" }, + headers: { authorization: `Bearer ${orglessToken}` }, + }), + ).rejects.toThrowError( + expect.objectContaining({ message: "Invalid SCIM token" }), + ); + }); + + it("rejects legacy database provider rows without an organization scope", async () => { + const { auth } = instance(); + const ctx = await auth.$context; + type LegacySCIMProviderRow = { + providerId: string; + scimToken: string; + organizationId: null; + }; + await ctx.adapter.create({ + model: "scimProvider", + data: { + providerId: "legacy", + scimToken: "legacy-token", + organizationId: null, + }, + }); + const legacyToken = Buffer.from("legacy-token:legacy").toString("base64"); + + await expect( + auth.api.createSCIMUser({ + body: { userName: "legacy@acme.test" }, + headers: { authorization: `Bearer ${legacyToken}` }, + }), + ).rejects.toThrowError( + expect.objectContaining({ message: "Invalid SCIM token" }), + ); + }); + + it("rotates runtime provider connections by organization-scoped provider key", async () => { + const { auth, signIn } = instance(); + const headers = await signIn("owner@rotation.test"); + const org = await auth.api.createOrganization({ + body: { slug: "rotation", name: "Rotation" }, + headers, + }); + + await auth.api.generateSCIMToken({ + body: { providerId: "okta", organizationId: org!.id }, + headers, + }); + await auth.api.generateSCIMToken({ + body: { providerId: "okta", organizationId: org!.id }, + headers, + }); + + const ctx = await auth.$context; + const providers = await ctx.adapter.findMany<{ providerKey: string }>({ + model: "scimProvider", + where: [{ field: "providerKey", value: `${org!.id}:okta` }], + }); + expect(providers).toHaveLength(1); + }); +}); + +describe("SCIM plugin requirements", () => { + it("throws at init without the organization plugin and without staticProviders", async () => { + const auth = betterAuth({ + database: memoryAdapter(emptyData()), + baseURL: "http://localhost:3000", + emailAndPassword: { enabled: true }, + plugins: [scim()], + }); + await expect(auth.$context).rejects.toThrow(/organization plugin/); + }); + + it("does not require the organization plugin when staticProviders is configured", async () => { + const auth = betterAuth({ + database: memoryAdapter(emptyData()), + baseURL: "http://localhost:3000", + emailAndPassword: { enabled: true }, + plugins: [ + scim({ + staticProviders: [{ providerId: "app", scimToken: "secret" }], + }), + ], + }); + await expect(auth.$context).resolves.toBeTruthy(); + }); + + it("rejects org-scoped staticProviders without the organization plugin", async () => { + const auth = betterAuth({ + database: memoryAdapter(emptyData()), + baseURL: "http://localhost:3000", + emailAndPassword: { enabled: true }, + plugins: [ + scim({ + staticProviders: [ + { + providerId: "app", + scimToken: "secret", + organizationId: "org", + }, + ], + }), + ], + }); + await expect(auth.$context).rejects.toThrow(/organization plugin/); + }); + + it("rejects staticProviders that can forge SCIM account namespace segments", async () => { + const auth = betterAuth({ + database: memoryAdapter(emptyData()), + baseURL: "http://localhost:3000", + emailAndPassword: { enabled: true }, + plugins: [ + scim({ + staticProviders: [ + { + providerId: "org:okta", + scimToken: "secret", + }, + ], + }), + ], + }); + await expect(auth.$context).rejects.toThrow(/cannot contain `:`/); + }); +}); diff --git a/packages/scim/src/scim-patch.test.ts b/packages/scim/src/scim-patch.test.ts index e31e16cf39..b31b6bda32 100644 --- a/packages/scim/src/scim-patch.test.ts +++ b/packages/scim/src/scim-patch.test.ts @@ -70,15 +70,30 @@ const createTestInstance = (scimOptions?: SCIMOptions) => { return headers; } + let defaultOrgPromise: Promise | undefined; + function ensureDefaultOrg(headers: Headers) { + if (!defaultOrgPromise) { + defaultOrgPromise = auth.api + .createOrganization({ + body: { slug: "default-org", name: "Default Org" }, + headers, + }) + .then((org) => org?.id); + } + return defaultOrgPromise; + } + async function getSCIMToken( providerId: string = "the-saml-provider-1", organizationId?: string, ) { const headers = await getAuthCookieHeaders(); + const orgId = organizationId ?? (await ensureDefaultOrg(headers)); + if (!orgId) throw new Error("Default organization not found"); const { scimToken } = await auth.api.generateSCIMToken({ body: { providerId, - organizationId, + organizationId: orgId, }, headers, }); diff --git a/packages/scim/src/scim-resources.ts b/packages/scim/src/scim-resources.ts index cbfd3c957d..fcf53c506b 100644 --- a/packages/scim/src/scim-resources.ts +++ b/packages/scim/src/scim-resources.ts @@ -13,6 +13,7 @@ export const createUserResource = ( user: User, account?: Account | null, groups?: SCIMUserGroupReference[], + active?: boolean, ) => { return { // Common attributes @@ -35,9 +36,10 @@ export const createUserResource = ( formatted: user.name, }, displayName: user.name, - // `active` reflects the enforced disabled-user state. Without the admin - // plugin there is no `banned` column, so the user reads as active. - active: !(user as User & { banned?: boolean | null }).banned, + // `active` reflects the disabled-user state. App-level deactivation uses + // the admin plugin's `banned` field; org-scoped callers pass an explicit + // value (membership presence). Absent both, the user reads as active. + active: active ?? !(user as User & { banned?: boolean | null }).banned, emails: [{ primary: true, value: user.email }], ...(groups && groups.length > 0 ? { groups } : {}), schemas: [SCIMUserResourceSchema.id], diff --git a/packages/scim/src/scim-users.test.ts b/packages/scim/src/scim-users.test.ts index 60d9c5bc4b..9c7b45981f 100644 --- a/packages/scim/src/scim-users.test.ts +++ b/packages/scim/src/scim-users.test.ts @@ -4,7 +4,7 @@ import { betterAuth } from "better-auth"; import { memoryAdapter } from "better-auth/adapters/memory"; import { createAuthClient } from "better-auth/client"; import { setCookieToHeader } from "better-auth/cookies"; -import { admin, bearer, organization } from "better-auth/plugins"; +import { bearer, organization } from "better-auth/plugins"; import { describe, expect, it } from "vitest"; import { scim } from "."; import { scimClient } from "./client"; @@ -74,15 +74,30 @@ const createTestInstance = ( return headers; } + let defaultOrgPromise: Promise | undefined; + function ensureDefaultOrg(headers: Headers) { + if (!defaultOrgPromise) { + defaultOrgPromise = auth.api + .createOrganization({ + body: { slug: "default-org", name: "Default Org" }, + headers, + }) + .then((org) => org!.id); + } + return defaultOrgPromise; + } + async function getSCIMToken( providerId: string = "the-saml-provider-1", organizationId?: string, ) { const headers = await getAuthCookieHeaders(); + const orgId = organizationId ?? (await ensureDefaultOrg(headers)); + if (!orgId) throw new Error("Default organization not found"); const { scimToken } = await auth.api.generateSCIMToken({ body: { providerId, - organizationId, + organizationId: orgId, }, headers, }); @@ -187,8 +202,8 @@ describe("SCIM", () => { ]); const [scimTokenOrgA, scimTokenOrgB] = await Promise.all([ - getSCIMToken("provider-org-a", organizationA?.id), - getSCIMToken("provider-org-b", organizationB?.id), + getSCIMToken("provider-org-a", organizationA!.id), + getSCIMToken("provider-org-b", organizationB!.id), ]); await createUser("user-a", scimTokenOrgA); @@ -265,8 +280,8 @@ describe("SCIM", () => { ]); const [scimTokenProviderA, scimTokenProviderB] = await Promise.all([ - getSCIMToken("provider-a", organizationA?.id), - getSCIMToken("provider-b", organizationB?.id), + getSCIMToken("provider-a", organizationA!.id), + getSCIMToken("provider-b", organizationB!.id), ]); const createUser = (userName: string, scimToken: string) => { @@ -473,8 +488,8 @@ describe("SCIM", () => { ]); const [scimTokenProviderA, scimTokenProviderB] = await Promise.all([ - getSCIMToken("provider-a", organizationA?.id), - getSCIMToken("provider-b", organizationB?.id), + getSCIMToken("provider-a", organizationA!.id), + getSCIMToken("provider-b", organizationB!.id), ]); const createUser = (userName: string, scimToken: string) => { @@ -740,8 +755,12 @@ describe("SCIM", () => { onSuccess: setCookieToHeader(adminHeaders), }); + const org = await auth.api.createOrganization({ + body: { slug: "scim-storage-org", name: "SCIM Storage Org" }, + headers: adminHeaders, + }); const { scimToken } = await auth.api.generateSCIMToken({ - body: { providerId: "the-saml-provider-1" }, + body: { providerId: "the-saml-provider-1", organizationId: org!.id }, headers: adminHeaders, }); @@ -750,8 +769,6 @@ describe("SCIM", () => { headers: { authorization: `Bearer ${scimToken}` }, }); - // The SCIM provider is the victim's sole identity, so a SCIM delete - // removes the global user and must also clear their stored sessions. const ctx = await auth.$context; const victimSession = await ctx.internalAdapter.createSession( provisioned.id, @@ -771,7 +788,7 @@ describe("SCIM", () => { const organization = await registerOrganization("org:deprovision"); const scimToken = await getSCIMToken( "provider-deprovision", - organization?.id, + organization!.id, ); const created = await auth.api.createSCIMUser({ @@ -784,7 +801,6 @@ describe("SCIM", () => { const ctx = await auth.$context; - // SCIM provisioning created an org membership for the new user. const memberBefore = await ctx.adapter.findOne({ model: "member", where: [ @@ -799,14 +815,12 @@ describe("SCIM", () => { headers: { authorization: `Bearer ${scimToken}` }, }); - // The global Better Auth user must NOT be deleted by an org-scoped token. const userAfter = await ctx.adapter.findOne({ model: "user", where: [{ field: "id", value: created.id }], }); expect(userAfter).not.toBeNull(); - // The org membership is removed (deprovisioned). const memberAfter = await ctx.adapter.findOne({ model: "member", where: [ @@ -816,8 +830,6 @@ describe("SCIM", () => { }); expect(memberAfter).toBeNull(); - // The SCIM account link is removed, so the user is no longer - // reachable through this provider. await expect( auth.api.getSCIMUser({ params: { userId: created.id }, @@ -948,11 +960,11 @@ describe("SCIM", () => { }); }); - describe("Default SCIM provider", () => { + describe("Static (app-level) SCIM provider", () => { it("should work with a default SCIM provider", async () => { - const scimToken = "dGhlLXNjaW0tdG9rZW46dGhlLXNjaW0tcHJvdmlkZXI="; // base64(scimToken:providerId) + const scimToken = "dGhlLXNjaW0tdG9rZW46dGhlLXNjaW0tcHJvdmlkZXI="; const { auth } = createTestInstance({ - defaultSCIM: [ + staticProviders: [ { providerId: "the-scim-provider", scimToken: "the-scim-token", @@ -1016,9 +1028,40 @@ describe("SCIM", () => { ).resolves.toBe(undefined); }); + it("rejects org-scoped static provider tokens without the organization segment", async () => { + const orglessToken = "dGhlLXNjaW0tdG9rZW46dGhlLXNjaW0tcHJvdmlkZXI="; + const orgToken = + "dGhlLXNjaW0tdG9rZW46dGhlLXNjaW0tcHJvdmlkZXI6dGhlLW9yZw=="; + const { auth } = createTestInstance({ + staticProviders: [ + { + providerId: "the-scim-provider", + scimToken: "the-scim-token", + organizationId: "the-org", + }, + ], + }); + + await expect( + auth.api.createSCIMUser({ + body: { userName: "missing-org" }, + headers: { authorization: `Bearer ${orglessToken}` }, + }), + ).rejects.toThrowError( + expect.objectContaining({ message: "Invalid SCIM token" }), + ); + + const createdUser = await auth.api.createSCIMUser({ + body: { userName: "with-org" }, + headers: { authorization: `Bearer ${orgToken}` }, + }); + + expect(createdUser.id).toBeTruthy(); + }); + it("should reject invalid SCIM tokens", async () => { const { auth } = createTestInstance({ - defaultSCIM: [ + staticProviders: [ { providerId: "the-scim-provider", scimToken: "the-scim-token", @@ -1081,11 +1124,16 @@ describe("SCIM write-path access and validation", () => { expect(user).not.toBeNull(); const accounts = await ctx.internalAdapter.findAccounts(provisioned.id); - expect(accounts.some((a) => a.providerId === "scim-a")).toBe(false); + const [, , organizationId] = Buffer.from(scimToken, "base64") + .toString("utf8") + .split(":"); + expect( + accounts.some((a) => a.providerId === `scim:${organizationId}:scim-a`), + ).toBe(false); expect(accounts.some((a) => a.providerId === "credential")).toBe(true); }); - it("deletes the global user when this provider's account is their sole identity", async () => { + it("deprovisions from the organization without deleting the global user", async () => { const { auth, getSCIMToken } = createTestInstance(); const scimToken = await getSCIMToken("scim-a"); @@ -1104,7 +1152,12 @@ describe("SCIM write-path access and validation", () => { model: "user", where: [{ field: "id", value: provisioned.id }], }); - expect(user).toBeNull(); + expect(user).not.toBeNull(); + const members = await ctx.adapter.findMany({ + model: "member", + where: [{ field: "userId", value: provisioned.id }], + }); + expect(members).toHaveLength(0); }); it("resets emailVerified when a SCIM email change is applied", async () => { @@ -1163,14 +1216,35 @@ describe("SCIM write-path access and validation", () => { ); }); - it("honors active:false by banning the user and reporting the real state (admin plugin)", async () => { - const { auth, getSCIMToken } = createTestInstance(undefined, [admin()]); + /** + * @see https://github.com/better-auth/better-auth/security/advisories/GHSA-rjg6 + */ + it("deactivates by removing organization membership and reactivates with preserved SCIM group roles", async () => { + const { auth, getSCIMToken } = createTestInstance(); const scimToken = await getSCIMToken("scim-a"); const provisioned = await auth.api.createSCIMUser({ body: { userName: "deact", emails: [{ value: "deact@email.com" }] }, headers: { authorization: `Bearer ${scimToken}` }, }); + expect(provisioned.active).toBe(true); + + await auth.api.createSCIMGroup({ + body: { + displayName: "admin", + members: [{ value: provisioned.id }], + }, + headers: { authorization: `Bearer ${scimToken}` }, + }); + const ctx = await auth.$context; + const memberBeforeDeactivate = await ctx.adapter.findOne<{ role: string }>({ + model: "member", + where: [{ field: "userId", value: provisioned.id }], + }); + expect(memberBeforeDeactivate?.role.split(",").sort()).toEqual([ + "admin", + "member", + ]); const deactivated = await auth.api.updateSCIMUser({ params: { userId: provisioned.id }, @@ -1183,18 +1257,29 @@ describe("SCIM write-path access and validation", () => { }); expect(deactivated.active).toBe(false); - const ctx = await auth.$context; - const banned = await ctx.adapter.findOne<{ - banned: boolean; - banReason: string | null; - }>({ + const user = await ctx.adapter.findOne({ model: "user", where: [{ field: "id", value: provisioned.id }], }); - expect(banned?.banned).toBe(true); - expect(banned?.banReason).toBeTruthy(); + expect(user).not.toBeNull(); + const membersAfterDeactivate = await ctx.adapter.findMany({ + model: "member", + where: [{ field: "userId", value: provisioned.id }], + }); + expect(membersAfterDeactivate).toHaveLength(0); + const groupMembersAfterDeactivate = await ctx.adapter.findMany({ + model: "scimGroupMember", + where: [{ field: "userId", value: provisioned.id }], + }); + expect(groupMembersAfterDeactivate).toHaveLength(1); - const reactivated = await auth.api.patchSCIMUser({ + const fetched = await auth.api.getSCIMUser({ + params: { userId: provisioned.id }, + headers: { authorization: `Bearer ${scimToken}` }, + }); + expect(fetched.active).toBe(false); + + await auth.api.patchSCIMUser({ params: { userId: provisioned.id }, body: { schemas: ["urn:ietf:params:scim:api:messages:2.0:PatchOp"], @@ -1202,17 +1287,23 @@ describe("SCIM write-path access and validation", () => { }, headers: { authorization: `Bearer ${scimToken}` }, }); - expect(reactivated).toBeUndefined(); - const cleared = await ctx.adapter.findOne<{ - banned: boolean; - banReason: string | null; - }>({ - model: "user", - where: [{ field: "id", value: provisioned.id }], + const membersAfterReactivate = await ctx.adapter.findMany<{ role: string }>( + { + model: "member", + where: [{ field: "userId", value: provisioned.id }], + }, + ); + expect(membersAfterReactivate).toHaveLength(1); + expect(membersAfterReactivate[0]?.role.split(",").sort()).toEqual([ + "admin", + "member", + ]); + const refetched = await auth.api.getSCIMUser({ + params: { userId: provisioned.id }, + headers: { authorization: `Bearer ${scimToken}` }, }); - expect(cleared?.banned).toBe(false); - expect(cleared?.banReason).toBeFalsy(); + expect(refetched.active).toBe(true); }); it("normalizes email casing when checking uniqueness on update", async () => { @@ -1244,39 +1335,10 @@ describe("SCIM write-path access and validation", () => { ); }); - it("rejects active:false rather than silently dropping it when the admin plugin is absent", async () => { + it("provisions a user without organization membership when created with active:false", async () => { const { auth, getSCIMToken } = createTestInstance(); const scimToken = await getSCIMToken("scim-a"); - const provisioned = await auth.api.createSCIMUser({ - body: { userName: "noadmin", emails: [{ value: "noadmin@email.com" }] }, - headers: { authorization: `Bearer ${scimToken}` }, - }); - - await expect( - auth.api.updateSCIMUser({ - params: { userId: provisioned.id }, - body: { - userName: "noadmin", - emails: [{ value: "noadmin@email.com" }], - active: false, - }, - headers: { authorization: `Bearer ${scimToken}` }, - }), - ).rejects.toThrowError( - expect.objectContaining({ - body: expect.objectContaining({ - detail: expect.stringContaining("admin plugin"), - status: "400", - }), - }), - ); - }); - - it("provisions a deactivated user when created with active:false (admin plugin)", async () => { - const { auth, getSCIMToken } = createTestInstance(undefined, [admin()]); - const scimToken = await getSCIMToken("scim-a"); - const created = await auth.api.createSCIMUser({ body: { userName: "born-off", @@ -1288,82 +1350,16 @@ describe("SCIM write-path access and validation", () => { expect(created.active).toBe(false); const ctx = await auth.$context; - const user = await ctx.adapter.findOne<{ banned: boolean }>({ - model: "user", - where: [{ field: "id", value: created.id }], + const members = await ctx.adapter.findMany({ + model: "member", + where: [{ field: "userId", value: created.id }], }); - expect(user?.banned).toBe(true); - }); + expect(members).toHaveLength(0); - it("rejects create with active:false before persisting when the admin plugin is absent", async () => { - const { auth, getSCIMToken } = createTestInstance(); - const scimToken = await getSCIMToken("scim-a"); - - await expect( - auth.api.createSCIMUser({ - body: { - userName: "never", - emails: [{ value: "never@email.com" }], - active: false, - }, - headers: { authorization: `Bearer ${scimToken}` }, - }), - ).rejects.toThrowError( - expect.objectContaining({ - body: expect.objectContaining({ - detail: expect.stringContaining("admin plugin"), - status: "400", - }), - }), - ); - - const ctx = await auth.$context; - const user = await ctx.adapter.findOne({ - model: "user", - where: [{ field: "email", value: "never@email.com" }], - }); - expect(user).toBeNull(); - }); - - it("revokes sessions when create links and deactivates a pre-existing user", async () => { - const { auth, authClient, getSCIMToken } = createTestInstance( - { linkExistingUsers: true }, - [admin()], - ); - const scimToken = await getSCIMToken("scim-a"); - - await authClient.signUp.email({ - email: "existing@email.com", - password: "the password", - name: "existing", - }); - - const ctx = await auth.$context; - const existing = await ctx.adapter.findOne<{ id: string }>({ - model: "user", - where: [{ field: "email", value: "existing@email.com" }], - }); - await ctx.internalAdapter.createSession(existing!.id); - - await auth.api.createSCIMUser({ - body: { - userName: "existing", - emails: [{ value: "existing@email.com" }], - active: false, - }, + const fetched = await auth.api.getSCIMUser({ + params: { userId: created.id }, headers: { authorization: `Bearer ${scimToken}` }, }); - - const sessions = await ctx.adapter.findMany({ - model: "session", - where: [{ field: "userId", value: existing!.id }], - }); - expect(sessions).toHaveLength(0); - - const banned = await ctx.adapter.findOne<{ banned: boolean }>({ - model: "user", - where: [{ field: "id", value: existing!.id }], - }); - expect(banned?.banned).toBe(true); + expect(fetched.active).toBe(false); }); }); diff --git a/packages/scim/src/scim.management.test.ts b/packages/scim/src/scim.management.test.ts index da013ab37f..65f3463772 100644 --- a/packages/scim/src/scim.management.test.ts +++ b/packages/scim/src/scim.management.test.ts @@ -4,8 +4,8 @@ import { memoryAdapter } from "better-auth/adapters/memory"; import { createAuthClient } from "better-auth/client"; import { setCookieToHeader } from "better-auth/cookies"; import type { OrganizationOptions } from "better-auth/plugins"; -import { bearer, genericOAuth, organization } from "better-auth/plugins"; -import { describe, expect, it } from "vitest"; +import { bearer, organization } from "better-auth/plugins"; +import { describe, expect, it, vi } from "vitest"; import { scim } from "."; import { scimClient } from "./client"; import type { SCIMOptions } from "./types"; @@ -74,16 +74,31 @@ const createTestInstance = ( return headers; } + let defaultOrgPromise: Promise | undefined; + function ensureDefaultOrg(headers: Headers) { + if (!defaultOrgPromise) { + defaultOrgPromise = auth.api + .createOrganization({ + body: { slug: "default-org", name: "Default Org" }, + headers, + }) + .then((org) => org!.id); + } + return defaultOrgPromise; + } + async function getSCIMToken( providerId: string = "the-saml-provider-1", organizationId?: string, userHeaders?: Headers, ) { const headers = userHeaders ?? (await getAuthCookieHeaders()); + const orgId = organizationId ?? (await ensureDefaultOrg(headers)); + if (!orgId) throw new Error("Default organization not found"); const { scimToken } = await auth.api.generateSCIMToken({ body: { providerId, - organizationId, + organizationId: orgId, }, headers, }); @@ -108,6 +123,7 @@ const createTestInstance = ( registerOrganization, getSCIMToken, getAuthCookieHeaders, + ensureDefaultOrg, }; }; @@ -128,7 +144,9 @@ describe("SCIM provider management", () => { it("should require user session", async () => { const { auth } = createTestInstance(); const generateSCIMToken = () => - auth.api.generateSCIMToken({ body: { providerId: "the id" } }); + auth.api.generateSCIMToken({ + body: { providerId: "the id", organizationId: "the-org" }, + }); await expect(generateSCIMToken()).rejects.toThrowError( expect.objectContaining({ @@ -137,47 +155,23 @@ describe("SCIM provider management", () => { ); }); - it("should deny personal token creation when canGenerateToken returns false", async () => { - const { auth, getAuthCookieHeaders } = createTestInstance({ - canGenerateToken: ({ organizationId }) => !!organizationId, - }); + it("should reject a token mint without an organization", async () => { + const { auth, getAuthCookieHeaders } = createTestInstance(); const headers = await getAuthCookieHeaders(); - const generateSCIMToken = () => + await expect( auth.api.generateSCIMToken({ - body: { providerId: "personal-provider" }, + // @ts-expect-error Testing request validation for a missing required field. + body: { providerId: "no-org-provider" }, headers, - }); - - await expect(generateSCIMToken()).rejects.toThrowError( + }), + ).rejects.toThrowError( expect.objectContaining({ - message: "You are not allowed to generate a SCIM token", + message: expect.stringContaining("[body.organizationId]"), }), ); }); - it("should allow token creation when canGenerateToken returns true (member is null for personal)", async () => { - let received: { providerId: string; member: unknown } | null = null; - const { auth, getAuthCookieHeaders } = createTestInstance({ - canGenerateToken: ({ providerId, member }) => { - received = { providerId, member }; - return true; - }, - }); - const headers = await getAuthCookieHeaders(); - - const { scimToken } = await auth.api.generateSCIMToken({ - body: { providerId: "personal-provider" }, - headers, - }); - - expect(scimToken).toBeTruthy(); - expect(received).toEqual({ - providerId: "personal-provider", - member: null, - }); - }); - it("should fail if the authenticated user does not belong to the given org", async () => { const { auth, getAuthCookieHeaders } = createTestInstance(); const headers = await getAuthCookieHeaders(); @@ -199,10 +193,14 @@ describe("SCIM provider management", () => { storeSCIMToken: "plain", }); const headers = await getAuthCookieHeaders(); + const org = await auth.api.createOrganization({ + body: { slug: "provider-validation", name: "Provider Validation" }, + headers, + }); - const generateSCIMToken = (providerId: string, organizationId?: string) => + const generateSCIMToken = (providerId: string) => auth.api.generateSCIMToken({ - body: { providerId, organizationId }, + body: { providerId, organizationId: org!.id }, headers, }); @@ -213,308 +211,15 @@ describe("SCIM provider management", () => { ); }); - /** - * @see https://github.com/better-auth/better-auth/security/advisories/GHSA-2g28-66mv-wghh - */ - it("rejects providerId values that collide with built-in account providers", async () => { - const { auth, getAuthCookieHeaders } = createTestInstance(); - const headers = await getAuthCookieHeaders(); - const generateSCIMToken = (providerId: string) => - auth.api.generateSCIMToken({ body: { providerId }, headers }); - - for (const reserved of [ - "credential", - "email-otp", - "magic-link", - "phone-number", - "anonymous", - "siwe", - ]) { - await expect(generateSCIMToken(reserved)).rejects.toThrowError( - expect.objectContaining({ - message: - "Provider id collides with another account provider and cannot be used for SCIM", - }), - ); - } - }); - - /** - * @see https://github.com/better-auth/better-auth/security/advisories/GHSA-2g28-66mv-wghh - */ - it("rejects providerId values that collide with configured social providers", async () => { - const data = { - user: [], - session: [], - verification: [], - account: [], - ssoProvider: [], - scimProvider: [], - scimGroup: [], - scimGroupMember: [], - scimGroupRole: [], - scimGroupRoleGrant: [], - organization: [], - member: [], - }; - const memory = memoryAdapter(data); - const auth = betterAuth({ - database: memory, - baseURL: "http://localhost:3000", - emailAndPassword: { enabled: true }, - socialProviders: { - google: { - clientId: "google-client-id", - clientSecret: "google-client-secret", - enabled: true, - }, - github: { - clientId: "github-client-id", - clientSecret: "github-client-secret", - enabled: true, - }, - // Disabled providers must still be rejected: a previously - // enabled provider can have leftover account rows in the DB. - discord: { - clientId: "discord-client-id", - clientSecret: "discord-client-secret", - enabled: false, - }, - }, - plugins: [scim()], - }); - const authClient = createAuthClient({ - baseURL: "http://localhost:3000", - plugins: [bearer(), scimClient()], - fetchOptions: { - customFetchImpl: async (url, init) => - auth.handler(new Request(url, init)), - }, - }); - const headers = new Headers(); - await authClient.signUp.email({ - email: "social@email.com", - password: "password", - name: "Social User", - }); - await authClient.signIn.email( - { email: "social@email.com", password: "password" }, - { throw: true, onSuccess: setCookieToHeader(headers) }, - ); - for (const reserved of ["google", "github", "discord"]) { - await expect( - auth.api.generateSCIMToken({ - body: { providerId: reserved }, - headers, - }), - ).rejects.toThrowError( - expect.objectContaining({ - message: - "Provider id collides with another account provider and cannot be used for SCIM", - }), - ); - } - }); - - it("rejects providerId values that collide with configured generic OAuth providers", async () => { - const data = { - user: [], - session: [], - verification: [], - account: [], - ssoProvider: [], - scimProvider: [], - organization: [], - member: [], - }; - const memory = memoryAdapter(data); - const auth = betterAuth({ - database: memory, - baseURL: "http://localhost:3000", - emailAndPassword: { enabled: true }, - plugins: [ - scim(), - genericOAuth({ - config: [ - { - providerId: "generic-provider", - clientId: "generic-client-id", - clientSecret: "generic-client-secret", - authorizationUrl: "https://idp.example.com/auth", - tokenUrl: "https://idp.example.com/token", - userInfoUrl: "https://idp.example.com/userinfo", - }, - ], - }), - ], - }); - const authClient = createAuthClient({ - baseURL: "http://localhost:3000", - plugins: [bearer(), scimClient()], - fetchOptions: { - customFetchImpl: async (url, init) => - auth.handler(new Request(url, init)), - }, - }); - const headers = new Headers(); - await authClient.signUp.email({ - email: "generic@email.com", - password: "password", - name: "Generic User", - }); - await authClient.signIn.email( - { email: "generic@email.com", password: "password" }, - { throw: true, onSuccess: setCookieToHeader(headers) }, - ); - - await expect( - auth.api.generateSCIMToken({ - body: { providerId: "generic-provider" }, - headers, - }), - ).rejects.toThrowError( - expect.objectContaining({ - message: - "Provider id collides with another account provider and cannot be used for SCIM", - }), - ); - }); - - it("rejects providerId values that collide with SSO providers", async () => { - const { auth, getAuthCookieHeaders } = createTestInstance(); - const headers = await getAuthCookieHeaders(); - - await auth.api.registerSSOProvider({ - body: { - providerId: "sso-provider", - issuer: "https://idp.example.com", - domain: "example.com", - samlConfig: { - entryPoint: "https://idp.example.com/sso", - cert: "test-cert", - callbackUrl: "http://localhost:3000/api/sso/callback", - spMetadata: {}, - }, - }, - headers, - }); - - await expect( - auth.api.generateSCIMToken({ - body: { providerId: "sso-provider" }, - headers, - }), - ).rejects.toThrowError( - expect.objectContaining({ - message: - "Provider id collides with another account provider and cannot be used for SCIM", - }), - ); - }); - - it("prevents SSO provider registration with an existing SCIM providerId", async () => { - const { auth, getAuthCookieHeaders } = createTestInstance(); - const headers = await getAuthCookieHeaders(); - - await auth.api.generateSCIMToken({ - body: { providerId: "scim-provider" }, - headers, - }); - - const response = await auth.api.registerSSOProvider({ - body: { - providerId: "scim-provider", - issuer: "https://idp.example.com", - domain: "example.com", - samlConfig: { - entryPoint: "https://idp.example.com/sso", - cert: "test-cert", - callbackUrl: "http://localhost:3000/api/sso/callback", - spMetadata: {}, - }, - }, - headers, - asResponse: true, - }); - - expect(response.status).toBe(422); - }); - - it("rejects providerId values that collide with default SSO providers", async () => { - const data = { - user: [], - session: [], - verification: [], - account: [], - ssoProvider: [], - scimProvider: [], - organization: [], - member: [], - }; - const memory = memoryAdapter(data); - const auth = betterAuth({ - database: memory, - baseURL: "http://localhost:3000", - emailAndPassword: { enabled: true }, - plugins: [ - sso({ - defaultSSO: [ - { - domain: "example.com", - providerId: "default-sso-provider", - samlConfig: { - issuer: "https://idp.example.com", - entryPoint: "https://idp.example.com/sso", - cert: "test-cert", - callbackUrl: "http://localhost:3000/api/sso/callback", - spMetadata: {}, - }, - }, - ], - }), - scim(), - organization(), - ], - }); - const authClient = createAuthClient({ - baseURL: "http://localhost:3000", - plugins: [bearer(), scimClient()], - fetchOptions: { - customFetchImpl: async (url, init) => - auth.handler(new Request(url, init)), - }, - }); - const headers = new Headers(); - await authClient.signUp.email({ - email: "default-sso@email.com", - password: "password", - name: "Default SSO User", - }); - await authClient.signIn.email( - { email: "default-sso@email.com", password: "password" }, - { throw: true, onSuccess: setCookieToHeader(headers) }, - ); - - await expect( - auth.api.generateSCIMToken({ - body: { providerId: "default-sso-provider" }, - headers, - }), - ).rejects.toThrowError( - expect.objectContaining({ - message: - "Provider id collides with another account provider and cannot be used for SCIM", - }), - ); - }); - it("should generate a new scim token (client)", async () => { - const { auth, authClient, getAuthCookieHeaders } = createTestInstance(); + const { auth, authClient, getAuthCookieHeaders, ensureDefaultOrg } = + createTestInstance(); const headers = await getAuthCookieHeaders(); const response = await authClient.scim.generateToken( { providerId: "the id", + organizationId: (await ensureDefaultOrg(headers))!, }, { headers }, ); @@ -542,8 +247,12 @@ describe("SCIM provider management", () => { }); const headers = await getAuthCookieHeaders(); + const org = await auth.api.createOrganization({ + body: { slug: "gen-org", name: "Gen Org" }, + headers, + }); const response = await auth.api.generateSCIMToken({ - body: { providerId: "the id" }, + body: { providerId: "the id", organizationId: org!.id }, headers, }); @@ -570,8 +279,12 @@ describe("SCIM provider management", () => { }); const headers = await getAuthCookieHeaders(); + const org = await auth.api.createOrganization({ + body: { slug: "gen-org", name: "Gen Org" }, + headers, + }); const response = await auth.api.generateSCIMToken({ - body: { providerId: "the id" }, + body: { providerId: "the id", organizationId: org!.id }, headers, }); @@ -598,8 +311,12 @@ describe("SCIM provider management", () => { }); const headers = await getAuthCookieHeaders(); + const org = await auth.api.createOrganization({ + body: { slug: "gen-org", name: "Gen Org" }, + headers, + }); const response = await auth.api.generateSCIMToken({ - body: { providerId: "the id" }, + body: { providerId: "the id", organizationId: org!.id }, headers, }); @@ -622,8 +339,12 @@ describe("SCIM provider management", () => { }); const headers = await getAuthCookieHeaders(); + const org = await auth.api.createOrganization({ + body: { slug: "gen-org", name: "Gen Org" }, + headers, + }); const response = await auth.api.generateSCIMToken({ - body: { providerId: "the id" }, + body: { providerId: "the id", organizationId: org!.id }, headers, }); @@ -649,8 +370,12 @@ describe("SCIM provider management", () => { }); const headers = await getAuthCookieHeaders(); + const org = await auth.api.createOrganization({ + body: { slug: "gen-org", name: "Gen Org" }, + headers, + }); const response = await auth.api.generateSCIMToken({ - body: { providerId: "the id" }, + body: { providerId: "the id", organizationId: org!.id }, headers, }); @@ -673,8 +398,12 @@ describe("SCIM provider management", () => { }); const headers = await getAuthCookieHeaders(); + const org = await auth.api.createOrganization({ + body: { slug: "gen-org", name: "Gen Org" }, + headers, + }); const response = await auth.api.generateSCIMToken({ - body: { providerId: "the id" }, + body: { providerId: "the id", organizationId: org!.id }, headers, }); @@ -711,7 +440,7 @@ describe("SCIM provider management", () => { const headers = await getAuthCookieHeaders(); const response = await auth.api.generateSCIMToken({ - body: { providerId: "the id", organizationId: orgA?.id }, + body: { providerId: "the id", organizationId: orgA!.id }, headers, }); @@ -737,7 +466,7 @@ describe("SCIM provider management", () => { const generateSCIMToken = () => auth.api.generateSCIMToken({ - body: { providerId: "the id", organizationId: orgA?.id }, + body: { providerId: "the id", organizationId: orgA!.id }, headers, }); @@ -762,8 +491,12 @@ describe("SCIM provider management", () => { }); const headers = await getAuthCookieHeaders(); + const org = await auth.api.createOrganization({ + body: { slug: "gen-org", name: "Gen Org" }, + headers, + }); const response = await auth.api.generateSCIMToken({ - body: { providerId: "the id" }, + body: { providerId: "the id", organizationId: org!.id }, headers, }); @@ -772,33 +505,6 @@ 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(); - - const [headersUserA, headersUserB] = await Promise.all([ - getAuthCookieHeaders(policyUserA), - getAuthCookieHeaders(policyUserB), - ]); - - await auth.api.generateSCIMToken({ - body: { providerId: "user-a-owned-provider" }, - headers: headersUserA, - }); - - await expect( - auth.api.generateSCIMToken({ - body: { providerId: "user-a-owned-provider" }, - headers: headersUserB, - }), - ).rejects.toMatchObject({ - status: "FORBIDDEN", - message: "You must be the owner to access this provider", - }); - }); - it("should deny regenerate when provider belongs to another org", async () => { const { auth, getAuthCookieHeaders, registerOrganization } = createTestInstance(); @@ -814,20 +520,19 @@ describe("SCIM provider management", () => { ]); await auth.api.generateSCIMToken({ - body: { providerId: "other-org", organizationId: org1?.id }, + body: { providerId: "other-org", organizationId: org1!.id }, headers: headers1, }); - // User B omits organizationId - tries to replace org1's provider + // User B targets org1's provider but is not a member of org1. await expect( auth.api.generateSCIMToken({ - body: { providerId: "other-org" }, + body: { providerId: "other-org", organizationId: org1!.id }, headers: headers2, }), ).rejects.toMatchObject({ status: "FORBIDDEN", - message: - "You must be a member of the organization to access this provider", + message: "You are not a member of the organization", }); }); }); @@ -885,32 +590,54 @@ describe("SCIM provider management", () => { }); }); - it("should return owned non-org providers in list for the owner", async () => { - const { auth, getAuthCookieHeaders } = createTestInstance(); + it("should query providers only from the user's organizations", async () => { + const { auth, getAuthCookieHeaders, registerOrganization, getSCIMToken } = + createTestInstance(); const [headersUserA, headersUserB] = await Promise.all([ getAuthCookieHeaders(policyUserA), getAuthCookieHeaders(policyUserB), ]); + const [orgA, orgB] = await Promise.all([ + registerOrganization("filtered-org-a", headersUserA), + registerOrganization("filtered-org-b", headersUserB), + ]); - await auth.api.generateSCIMToken({ - body: { providerId: "user-a-personal-provider" }, - headers: headersUserA, - }); + await Promise.all([ + getSCIMToken("filtered-provider-a", orgA!.id, headersUserA), + getSCIMToken("filtered-provider-b", orgB!.id, headersUserB), + ]); - const resUserA = await auth.api.listSCIMProviderConnections({ - headers: headersUserA, - }); - expect(resUserA.providers).toHaveLength(1); - expect(resUserA.providers?.[0]).toMatchObject({ - providerId: "user-a-personal-provider", - organizationId: null, - }); + const ctx = await auth.$context; + const findManySpy = vi.spyOn(ctx.adapter, "findMany"); - const resUserB = await auth.api.listSCIMProviderConnections({ - headers: headersUserB, - }); - expect(resUserB.providers).toHaveLength(0); + try { + const res = await auth.api.listSCIMProviderConnections({ + headers: headersUserA, + }); + + expect(res.providers?.map((p) => p.providerId)).toEqual([ + "filtered-provider-a", + ]); + + const scimProviderCall = findManySpy.mock.calls.find( + ([query]) => query.model === "scimProvider", + )?.[0]; + expect(scimProviderCall).toEqual( + expect.objectContaining({ + model: "scimProvider", + where: [ + { + field: "organizationId", + value: [orgA!.id], + operator: "in", + }, + ], + }), + ); + } finally { + findManySpy.mockRestore(); + } }); }); @@ -924,7 +651,7 @@ describe("SCIM provider management", () => { await getSCIMToken("my-provider", org!.id); const res = await auth.api.getSCIMProviderConnection({ - query: { providerId: "my-provider" }, + query: { providerId: "my-provider", organizationId: org!.id }, headers, }); @@ -935,50 +662,6 @@ describe("SCIM provider management", () => { }); }); - it("should return own non-org provider", async () => { - const { auth, getAuthCookieHeaders, getSCIMToken } = createTestInstance(); - const headers = await getAuthCookieHeaders(); - - await getSCIMToken("no-org-provider"); - - const res = await auth.api.getSCIMProviderConnection({ - query: { providerId: "no-org-provider" }, - headers, - }); - - expect(res).toMatchObject({ - providerId: "no-org-provider", - organizationId: null, - }); - }); - - /** - * @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(); - - const [headersUserA, headersUserB] = await Promise.all([ - getAuthCookieHeaders(policyUserA), - getAuthCookieHeaders(policyUserB), - ]); - - await auth.api.generateSCIMToken({ - body: { providerId: "user-a-owned-provider" }, - headers: headersUserA, - }); - - await expect( - auth.api.getSCIMProviderConnection({ - query: { providerId: "user-a-owned-provider" }, - headers: headersUserB, - }), - ).rejects.toMatchObject({ - status: "FORBIDDEN", - message: "You must be the owner to access this provider", - }); - }); - it("should return 403 when provider belongs to another org", async () => { const { auth, getAuthCookieHeaders, registerOrganization } = createTestInstance(); @@ -994,19 +677,21 @@ describe("SCIM provider management", () => { ]); await auth.api.generateSCIMToken({ - body: { providerId: "other-org-provider", organizationId: org1?.id }, + body: { providerId: "other-org-provider", organizationId: org1!.id }, headers: headers1, }); await expect( auth.api.getSCIMProviderConnection({ - query: { providerId: "other-org-provider" }, + query: { + providerId: "other-org-provider", + organizationId: org1!.id, + }, headers: headers2, }), ).rejects.toMatchObject({ status: "FORBIDDEN", - message: - "You must be a member of the organization to access this provider", + message: "You are not a member of the organization", }); }); @@ -1021,7 +706,7 @@ describe("SCIM provider management", () => { const org = await registerOrganization("owner-removed-org", headersUserA); await auth.api.generateSCIMToken({ - body: { providerId: "owner-removed-provider", organizationId: org?.id }, + body: { providerId: "owner-removed-provider", organizationId: org!.id }, headers: headersUserA, }); @@ -1046,13 +731,15 @@ describe("SCIM provider management", () => { await expect( auth.api.getSCIMProviderConnection({ - query: { providerId: "owner-removed-provider" }, + query: { + providerId: "owner-removed-provider", + organizationId: org!.id, + }, headers: headersUserA, }), ).rejects.toMatchObject({ status: "FORBIDDEN", - message: - "You must be a member of the organization to access this provider", + message: "You are not a member of the organization", }); const listRes = await auth.api.listSCIMProviderConnections({ @@ -1066,12 +753,14 @@ describe("SCIM provider management", () => { }); it("should return 404 for unknown providerId", async () => { - const { auth, getAuthCookieHeaders } = createTestInstance(); + const { auth, getAuthCookieHeaders, ensureDefaultOrg } = + createTestInstance(); const headers = await getAuthCookieHeaders(); + const organizationId = (await ensureDefaultOrg(headers))!; await expect( auth.api.getSCIMProviderConnection({ - query: { providerId: "unknown" }, + query: { providerId: "unknown", organizationId }, headers, }), ).rejects.toMatchObject({ @@ -1097,7 +786,7 @@ describe("SCIM provider management", () => { ).toBe(true); const deleteRes = await auth.api.deleteSCIMProviderConnection({ - body: { providerId: "my-provider" }, + body: { providerId: "my-provider", organizationId: org!.id }, headers, }); expect(deleteRes).toMatchObject({ success: true }); @@ -1132,62 +821,36 @@ describe("SCIM provider management", () => { ]); await auth.api.generateSCIMToken({ - body: { providerId: "other-org-del", organizationId: org1?.id }, + body: { providerId: "other-org-del", organizationId: org1!.id }, headers: headers1, }); await expect( auth.api.deleteSCIMProviderConnection({ - body: { providerId: "other-org-del" }, + body: { providerId: "other-org-del", organizationId: org1!.id }, headers: headers2, }), ).rejects.toMatchObject({ status: "FORBIDDEN", - message: - "You must be a member of the organization to access this provider", + message: "You are not a member of the organization", }); }); it("should return 404 for unknown providerId", async () => { - const { auth, getAuthCookieHeaders } = createTestInstance(); + const { auth, getAuthCookieHeaders, ensureDefaultOrg } = + createTestInstance(); const headers = await getAuthCookieHeaders(); + const organizationId = (await ensureDefaultOrg(headers))!; await expect( auth.api.deleteSCIMProviderConnection({ - body: { providerId: "unknown" }, + body: { providerId: "unknown", organizationId }, headers, }), ).rejects.toMatchObject({ message: "SCIM provider not found", }); }); - - /** - * @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(); - - const [headersUserA, headersUserB] = await Promise.all([ - getAuthCookieHeaders(policyUserA), - getAuthCookieHeaders(policyUserB), - ]); - - await auth.api.generateSCIMToken({ - body: { providerId: "user-a-delete-provider" }, - headers: headersUserA, - }); - - await expect( - auth.api.deleteSCIMProviderConnection({ - body: { providerId: "user-a-delete-provider" }, - headers: headersUserB, - }), - ).rejects.toMatchObject({ - status: "FORBIDDEN", - message: "You must be the owner to access this provider", - }); - }); }); describe("role-based authorization", () => { @@ -1219,7 +882,7 @@ describe("SCIM provider management", () => { auth.api.generateSCIMToken({ body: { providerId: "member-attempt", - organizationId: org?.id, + organizationId: org!.id, }, headers: headersMember, }), @@ -1256,7 +919,7 @@ describe("SCIM provider management", () => { const result = await auth.api.generateSCIMToken({ body: { providerId: "admin-attempt", - organizationId: org?.id, + organizationId: org!.id, }, headers: headersAdmin, }); @@ -1290,7 +953,7 @@ describe("SCIM provider management", () => { const result = await auth.api.generateSCIMToken({ body: { providerId: "multi-role-provider", - organizationId: org?.id, + organizationId: org!.id, }, headers: headersPrivilegedMember, }); @@ -1308,7 +971,7 @@ describe("SCIM provider management", () => { ).toBe(true); const provider = await auth.api.getSCIMProviderConnection({ - query: { providerId: "multi-role-provider" }, + query: { providerId: "multi-role-provider", organizationId: org!.id }, headers: headersPrivilegedMember, }); expect(provider).toMatchObject({ @@ -1343,7 +1006,7 @@ describe("SCIM provider management", () => { auth.api.generateSCIMToken({ body: { providerId: "custom-role-attempt", - organizationId: org?.id, + organizationId: org!.id, }, headers: headersAdmin, }), @@ -1356,7 +1019,7 @@ describe("SCIM provider management", () => { const result = await auth.api.generateSCIMToken({ body: { providerId: "custom-role-attempt", - organizationId: org?.id, + organizationId: org!.id, }, headers: headersOwner, }); @@ -1365,6 +1028,32 @@ describe("SCIM provider management", () => { }); }); + it("should not let a custom requiredRole resolver bypass organization membership", async () => { + const { auth, getAuthCookieHeaders, registerOrganization } = + createTestInstance({ requiredRole: () => true }); + + const headersOwner = await getAuthCookieHeaders(policyUserA); + const headersOutsider = await getAuthCookieHeaders(policyUserB); + + const org = await registerOrganization( + "resolver-membership-org", + headersOwner, + ); + + await expect( + auth.api.generateSCIMToken({ + body: { + providerId: "resolver-membership-provider", + organizationId: org!.id, + }, + headers: headersOutsider, + }), + ).rejects.toMatchObject({ + status: "FORBIDDEN", + message: "You are not a member of the organization", + }); + }); + it("should default to the organization creator role when it is customized", async () => { const { auth, getAuthCookieHeaders, registerOrganization } = createTestInstance(undefined, { @@ -1380,7 +1069,7 @@ describe("SCIM provider management", () => { const result = await auth.api.generateSCIMToken({ body: { providerId: "custom-creator-role-provider", - organizationId: org?.id, + organizationId: org!.id, }, headers: headersCreator, }); @@ -1422,7 +1111,7 @@ describe("SCIM provider management", () => { await auth.api.generateSCIMToken({ body: { providerId: "list-role-provider", - organizationId: org?.id, + organizationId: org!.id, }, headers: headersOwner, }); diff --git a/packages/scim/src/scim.test.ts b/packages/scim/src/scim.test.ts index fd958b5ae6..4e249c3ee7 100644 --- a/packages/scim/src/scim.test.ts +++ b/packages/scim/src/scim.test.ts @@ -71,15 +71,30 @@ const createTestInstance = (scimOptions?: SCIMOptions) => { return headers; } + let defaultOrgPromise: Promise | undefined; + function ensureDefaultOrg(headers: Headers) { + if (!defaultOrgPromise) { + defaultOrgPromise = auth.api + .createOrganization({ + body: { slug: "default-org", name: "Default Org" }, + headers, + }) + .then((org) => org?.id); + } + return defaultOrgPromise; + } + async function getSCIMToken( providerId: string = "the-saml-provider-1", organizationId?: string, ) { const headers = await getAuthCookieHeaders(); + const orgId = organizationId ?? (await ensureDefaultOrg(headers)); + if (!orgId) throw new Error("Default organization not found"); const { scimToken } = await auth.api.generateSCIMToken({ body: { providerId, - organizationId, + organizationId: orgId, }, headers, }); @@ -126,6 +141,7 @@ const _createSqlTestInstance = async ( organizationId?: string, ) { const { headers } = await signInWithTestUser(); + if (!organizationId) throw new Error("SCIM token requires an organization"); const { scimToken } = await auth.api.generateSCIMToken({ body: { providerId, @@ -765,8 +781,6 @@ describe("SCIM", () => { }); it("should create a new account linked to an existing user", async () => { - // Linking a pre-existing user by email is opt-in via - // `linkExistingUsers`; enable the legacy behavior for this test. const { auth, authClient, getSCIMToken } = createTestInstance({ linkExistingUsers: true, }); @@ -843,7 +857,6 @@ describe("SCIM", () => { }, }); - // Must not silently link to the existing account. await expect(createUser()).rejects.toThrowError( expect.objectContaining({ message: "User already exists", @@ -855,29 +868,31 @@ describe("SCIM", () => { ); }); - it("should only link a pre-existing user whose email domain is trusted", async () => { - const { auth, authClient, getSCIMToken } = createTestInstance({ - linkExistingUsers: { trustedDomains: ["trusted.com"] }, + it("only links a pre-existing user who belongs to the token's organization", async () => { + const { auth, authClient, getAuthCookieHeaders } = createTestInstance({ + linkExistingUsers: { requireExistingOrgMembership: true }, }); - const scimToken = await getSCIMToken(); - - await authClient.signUp.email({ - email: "user@other.com", - password: "the password", - name: "other", + const ownerHeaders = await getAuthCookieHeaders(); + const org = await auth.api.createOrganization({ + body: { slug: "link-org", name: "Link Org" }, + headers: ownerHeaders, }); + const { scimToken } = await auth.api.generateSCIMToken({ + body: { providerId: "member-link", organizationId: org!.id }, + headers: ownerHeaders, + }); + await authClient.signUp.email({ - email: "user@trusted.com", + email: "outsider@company.com", password: "the password", - name: "trusted", + name: "outsider", }); - // Domain not in trustedDomains: rejected. await expect( auth.api.createSCIMUser({ body: { - userName: "other", - emails: [{ value: "user@other.com" }], + userName: "outsider", + emails: [{ value: "outsider@company.com" }], }, headers: { authorization: `Bearer ${scimToken}` }, }), @@ -885,18 +900,37 @@ describe("SCIM", () => { expect.objectContaining({ message: "User already exists" }), ); - // Trusted domain: linked. + const memberHeaders = new Headers(); + await authClient.signUp.email({ + email: "member@company.com", + password: "the password", + name: "member", + }); + await authClient.signIn.email( + { email: "member@company.com", password: "the password" }, + { throw: true, onSuccess: setCookieToHeader(memberHeaders) }, + ); + const memberSession = await auth.api.getSession({ + headers: memberHeaders, + }); + await auth.api.addMember({ + body: { + organizationId: org!.id, + userId: memberSession!.user.id, + role: "member", + }, + headers: ownerHeaders, + }); + const linked = await auth.api.createSCIMUser({ body: { - userName: "trusted", - emails: [{ value: "user@trusted.com" }], + userName: "member", + emails: [{ value: "member@company.com" }], }, headers: { authorization: `Bearer ${scimToken}` }, }); - expect(linked.id).toBeTruthy(); - expect(linked.emails).toEqual([ - { primary: true, value: "user@trusted.com" }, - ]); + + expect(linked.id).toBe(memberSession!.user.id); }); it("should create a new user with external id", async () => { diff --git a/packages/scim/src/types.ts b/packages/scim/src/types.ts index f5468bc336..fc9deabf53 100644 --- a/packages/scim/src/types.ts +++ b/packages/scim/src/types.ts @@ -1,13 +1,26 @@ -import type { User } from "better-auth"; +import type { GenericEndpointContext, User } from "better-auth"; import type { Member } from "better-auth/plugins"; export interface SCIMProvider { id: string; + providerId: string; + providerKey: string; + scimToken: string; + organizationId: string; +} + +export type StaticSCIMProvider = { providerId: string; scimToken: string; organizationId?: string; - userId?: string; -} +}; + +export type SCIMRequiredRoleResolver = (payload: { + user: User; + member: Member; + organizationId: string; + ctx: GenericEndpointContext; +}) => boolean | Promise; export type SCIMName = { formatted?: string; @@ -95,17 +108,15 @@ export interface SCIMGroupRoleGrant { export type SCIMOptions = { /** - * Minimum organization role(s) required for SCIM management operations - * (generate-token, list/get/delete provider connections). + * Roles, or a resolver, allowed to manage SCIM providers for an organization. * * Defaults to `["admin", organization.creatorRole ?? "owner"]`. */ - requiredRole?: string[]; + requiredRole?: string[] | SCIMRequiredRoleResolver; /** - * Default list of SCIM providers for testing. - * These will take precedence over the database when present. + * Code-defined providers. Omit `organizationId` only for app-level SCIM. */ - defaultSCIM?: Omit[]; + staticProviders?: StaticSCIMProvider[]; /** * Maps an incoming SCIM Group resource to Better Auth organization role(s). * @@ -115,31 +126,13 @@ export type SCIMOptions = { input: MapGroupToRolesInput, ) => string | string[] | Promise; /** - * Controls whether SCIM provisioning may link to a *pre-existing* Better - * Auth user whose email matches the incoming SCIM resource. - * - * Disabled by default: when a user with the same email already exists, - * `createSCIMUser` returns `409` (uniqueness) instead of silently creating a - * SCIM account link for that user. Linking by email alone would give a SCIM - * token access to an account it never provisioned. - * - * - `true` restores the legacy behavior of linking any existing user that - * matches by email. Only use this with a fully trusted token-issuance flow. - * - An object enables linking only when *every* provided constraint passes. + * Allows SCIM to link an existing user by email. Disabled by default. */ linkExistingUsers?: | boolean | { /** - * Only link when the email's domain is in this allow-list - * (case-insensitive). An empty/absent list is not a match. - */ - trustedDomains?: string[]; - /** - * For organization-scoped tokens, only link a user who is already - * a member of the token's organization (never auto-add them). Has - * no effect for non-org (personal) tokens, which then never match - * on this constraint. + * Require existing membership in the token's organization. */ requireExistingOrgMembership?: boolean; /** @@ -158,7 +151,7 @@ export type SCIMOptions = { */ beforeSCIMTokenGenerated?: (payload: { user: User; - member: Member | null; + member: Member; scimToken: string; }) => Promise; /** @@ -166,25 +159,20 @@ export type SCIMOptions = { */ afterSCIMTokenGenerated?: (payload: { user: User; - member: Member | null; + member: Member; scimToken: string; scimProvider: SCIMProvider; }) => Promise; /** * Authorize who may generate a SCIM token. Runs after the built-in checks - * (org-scoped tokens still require org membership + the required role), so it - * can add restrictions but cannot loosen them. - * - * Use this to lock down *personal* (non-org-scoped) token creation, which is - * otherwise available to any authenticated user. SCIM tokens can provision - * and manage users, so return `false` to deny. `member` is `null` for - * personal tokens. + * (org membership and the required role), so it can add restrictions but + * cannot loosen them. Return `false` to deny. */ canGenerateToken?: (payload: { user: User; providerId: string; - organizationId?: string; - member: Member | null; + organizationId: string; + member: Member; }) => boolean | Promise; /** * How to store the SCIM token in the database. diff --git a/packages/scim/src/user-schemas.ts b/packages/scim/src/user-schemas.ts index 394d6bf5ba..beb621eda7 100644 --- a/packages/scim/src/user-schemas.ts +++ b/packages/scim/src/user-schemas.ts @@ -18,8 +18,8 @@ export const APIUserSchema = z.object({ }), ) .optional(), - // `false` deactivates the user (maps to the admin plugin's `banned` state and - // revokes sessions); `true` reactivates. Requires the admin plugin. + // `false` deactivates the user. Organization-scoped tokens remove membership; + // app-level tokens use the admin plugin's `banned` state. active: z.boolean().optional(), });