diff --git a/.changeset/declared-scim-sso-explicit-config-wins.md b/.changeset/declared-scim-sso-explicit-config-wins.md new file mode 100644 index 0000000000..bb41a7dd22 --- /dev/null +++ b/.changeset/declared-scim-sso-explicit-config-wins.md @@ -0,0 +1,42 @@ +--- +"@objectstack/spec": minor +"@objectstack/plugin-auth": minor +--- + +feat(spec,plugin-auth): declare `plugins.scim` / `plugins.sso` / `plugins.ssoDomainVerification`, and let an explicit config value win over the env var (#13439) + +**Behavior change** (maintainer ruling 2026-08-31 on #13439). + +Two halves of one contract gap: + +- **Declaration.** `AuthPluginConfigSchema` now declares `scim`, `sso` and + `ssoDomainVerification` as tri-state `z.boolean().optional()`, following the + `dynamicClientRegistration` template. Previously the keys were read by + `plugin-auth` through an `as any` cast while no schema declared them — a key + an author could write, that typechecked only via the cast, that no + publish-time validation would ever reject or confirm. +- **Precedence flip.** For these three keys an EXPLICIT config value now wins + over the corresponding env var (`OS_SCIM_ENABLED` / `OS_SSO_ENABLED` / + `OS_SSO_DOMAIN_VERIFICATION`); the env var decides only where the config + leaves the key UNSET (absent env ⇒ off). Previously the env var always won, + so `plugins: { scim: false }` had no effect at all whenever + `OS_SCIM_ENABLED` was set — a line that read as a security control, passed + review and typecheck, and did nothing. The operator per-environment override + is preserved for every deployment that leaves the keys unset. The other + env-paired keys (`oidcProvider`, `dynamicClientRegistration`, `twoFactor`, + `passwordRejectBreached`) deliberately keep their documented env-wins order. + +The ADR-0071 forced-admin coupling is unchanged in shape +(`admin: pluginConfig.admin ?? scimEffective`): effective SCIM still forces +the better-auth `admin` plugin on when `admin` is unset — but the flipped +resolution flows through it, so an explicit `plugins.scim: false` now also +declines the admin plugin it would have dragged in. The admin coupling itself +is out of this change's scope (#13816 tracks it). + +**Known risk, named:** a deployment that writes BOTH an explicit value and the +env var and depends on the env winning will flip. The only known explicit +writer is the cloud control plane, which requires the new order (its +plan-derived `plugins.scim` must be authoritative; cloud#1265's refuse-to-build +workaround can retire once this lands). + + diff --git a/content/docs/references/system/auth-config.mdx b/content/docs/references/system/auth-config.mdx index 376df47c5b..c3a370ce88 100644 --- a/content/docs/references/system/auth-config.mdx +++ b/content/docs/references/system/auth-config.mdx @@ -104,6 +104,9 @@ Advanced / low-level Better-Auth options | **deviceAuthorization** | `boolean` | optional (default: `false`) | Enable RFC 8628 Device Authorization Grant (CLI / TV-style login) | | **admin** | `boolean` | optional (default: `false`) | Enable platform admin operations (ban/unban, set-password, impersonate, set-role) | | **phoneNumber** | `boolean` | optional (default: `false`) | Enable phone-number sign-in (phone + password; OTP sign-in/reset when an SMS service is configured) | +| **scim** | `boolean` | optional | Enable the SCIM 2.0 provisioning surface. Unset: OS_SCIM_ENABLED decides (absent = off); an explicit value wins over the env var. Effective SCIM forces the admin plugin on unless admin is set. | +| **sso** | `boolean` | optional | Enable enterprise SSO (domain-routed OIDC/SAML sign-in). Unset: OS_SSO_ENABLED decides (absent = off); an explicit value wins over the env var. | +| **ssoDomainVerification** | `boolean` | optional | Enable DNS domain-verification for SSO providers (requires sso). Unset: OS_SSO_DOMAIN_VERIFICATION decides (absent = off); an explicit value wins over the env var. | ### Nested Shape: `AuthConfig.session` @@ -211,6 +214,9 @@ OIDC / Generic OAuth2 provider configuration for enterprise SSO | **deviceAuthorization** | `boolean` | optional (default: `false`) | Enable RFC 8628 Device Authorization Grant (CLI / TV-style login) | | **admin** | `boolean` | optional (default: `false`) | Enable platform admin operations (ban/unban, set-password, impersonate, set-role) | | **phoneNumber** | `boolean` | optional (default: `false`) | Enable phone-number sign-in (phone + password; OTP sign-in/reset when an SMS service is configured) | +| **scim** | `boolean` | optional | Enable the SCIM 2.0 provisioning surface. Unset: OS_SCIM_ENABLED decides (absent = off); an explicit value wins over the env var. Effective SCIM forces the admin plugin on unless admin is set. | +| **sso** | `boolean` | optional | Enable enterprise SSO (domain-routed OIDC/SAML sign-in). Unset: OS_SSO_ENABLED decides (absent = off); an explicit value wins over the env var. | +| **ssoDomainVerification** | `boolean` | optional | Enable DNS domain-verification for SSO providers (requires sso). Unset: OS_SSO_DOMAIN_VERIFICATION decides (absent = off); an explicit value wins over the env var. | --- diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index 9ff8cbbe6a..32860623b5 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -441,8 +441,9 @@ describe('AuthManager', () => { // @better-auth/scim mounts the SCIM 2.0 Service Provider so an external IdP // can auto-provision/deprovision this env's users (ADR-0071). It is opt-in - // via OS_SCIM_ENABLED and FORCES the admin plugin on (active:false → ban - // runs through admin). + // via `plugins.scim` (explicit value wins, #13439) or OS_SCIM_ENABLED + // (decides where the config leaves it unset), and effective SCIM FORCES + // the admin plugin on (active:false → ban runs through admin). it('should NOT register the scim plugin by default', async () => { let capturedConfig: any; (betterAuth as any).mockImplementation((config: any) => { @@ -486,6 +487,73 @@ describe('AuthManager', () => { } }); + // #13439 (maintainer ruling 2026-08-31) — an EXPLICIT `plugins.scim` + // value wins over `OS_SCIM_ENABLED`; the env var decides only where the + // config leaves the key unset. This is the cloud control plane's case: + // its plan-derived `plugins.scim: false` must be authoritative even in a + // deployment env that carries an ambient OS_SCIM_ENABLED (cloud#1265). + // The forced-admin coupling (ADR-0071) follows the EFFECTIVE scim value, + // so declining scim also declines the admin plugin it would have dragged + // in (unless `admin` is set explicitly). + it('should NOT register the scim plugin (nor force admin on) when plugins.scim=false despite OS_SCIM_ENABLED', async () => { + let capturedConfig: any; + (betterAuth as any).mockImplementation((config: any) => { + capturedConfig = config; + return { handler: vi.fn(), api: {} }; + }); + const prev = process.env.OS_SCIM_ENABLED; + process.env.OS_SCIM_ENABLED = 'true'; + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const manager = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + baseUrl: 'http://localhost:3000', + plugins: { scim: false }, + }); + await manager.getAuthInstance(); + const ids = capturedConfig.plugins.map((p: any) => p.id); + expect(ids).not.toContain('scim'); + expect(ids).not.toContain('admin'); + // The /auth/config features block recomputes the admin default from + // the same flipped chain (its own inline scim resolution) — it must + // agree with the wired plugin list. + expect(manager.getPublicConfig().features.admin).toBe(false); + } finally { + if (prev === undefined) delete process.env.OS_SCIM_ENABLED; + else process.env.OS_SCIM_ENABLED = prev; + warnSpy.mockRestore(); + } + }); + + it('should register the scim plugin (and force admin on) when plugins.scim=true with no env set', async () => { + let capturedConfig: any; + (betterAuth as any).mockImplementation((config: any) => { + capturedConfig = config; + return { handler: vi.fn(), api: {} }; + }); + const prev = process.env.OS_SCIM_ENABLED; + delete process.env.OS_SCIM_ENABLED; + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const manager = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + baseUrl: 'http://localhost:3000', + plugins: { scim: true }, + }); + await manager.getAuthInstance(); + const ids = capturedConfig.plugins.map((p: any) => p.id); + expect(ids).toContain('scim'); + // ADR-0071 — the forced-admin coupling is unchanged: effective SCIM + // still drags the admin plugin in when `admin` is left unset. + expect(ids).toContain('admin'); + expect(manager.getPublicConfig().features.admin).toBe(true); + } finally { + if (prev === undefined) delete process.env.OS_SCIM_ENABLED; + else process.env.OS_SCIM_ENABLED = prev; + warnSpy.mockRestore(); + } + }); + it('blocks slug change when the org has active environments', async () => { let capturedConfig: any; (betterAuth as any).mockImplementation((config: any) => { @@ -2530,14 +2598,14 @@ describe('AuthManager', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const manager = new AuthManager({ secret: 'test-secret-at-least-32-chars-long', - plugins: { sso: true } as any, + plugins: { sso: true }, }); warnSpy.mockRestore(); expect(manager.getPublicConfig().features.sso).toBe(true); }); - it('should let OS_SSO_ENABLED env override the config (matches buildPlugins wiring)', () => { + it('should let OS_SSO_ENABLED decide when the config leaves sso unset (matches buildPlugins wiring)', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const prev = process.env.OS_SSO_ENABLED; process.env.OS_SSO_ENABLED = 'true'; @@ -2575,7 +2643,7 @@ describe('AuthManager', () => { ); it.each(['0', 'false', 'off', 'no'])( - 'should treat OS_SSO_ENABLED=%s as disabled even when plugins.sso=true', + 'should treat OS_SSO_ENABLED=%s as disabled when the config leaves sso unset', (val) => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const prev = process.env.OS_SSO_ENABLED; @@ -2583,7 +2651,6 @@ describe('AuthManager', () => { try { const manager = new AuthManager({ secret: 'test-secret-at-least-32-chars-long', - plugins: { sso: true } as any, }); expect(manager.getPublicConfig().features.sso).toBe(false); } finally { @@ -2594,6 +2661,99 @@ describe('AuthManager', () => { }, ); + // #13439 (maintainer ruling 2026-08-31) — an EXPLICIT `plugins.sso` + // value wins over `OS_SSO_ENABLED`; the env var decides only where the + // config leaves the key unset. Before the flip, a host that wrote + // `plugins: { sso: false }` got no effect whenever the env var was set — + // a line that read as a security control and did nothing. + it.each(['0', 'false', 'off', 'no'])( + 'should keep sso ENABLED when plugins.sso=true despite OS_SSO_ENABLED=%s (explicit config wins)', + (val) => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const prev = process.env.OS_SSO_ENABLED; + process.env.OS_SSO_ENABLED = val; + try { + const manager = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + plugins: { sso: true }, + }); + expect(manager.getPublicConfig().features.sso).toBe(true); + } finally { + if (prev === undefined) delete process.env.OS_SSO_ENABLED; + else process.env.OS_SSO_ENABLED = prev; + warnSpy.mockRestore(); + } + }, + ); + + // The empty string is a PRESENT env value (readBooleanEnv resolves it to + // a boolean — it only returns undefined for an ABSENT variable), so it + // must lose to an explicit config value like any other present value. + it.each(['1', 'true', 'yes', 'on', ''])( + 'should keep sso DISABLED when plugins.sso=false despite OS_SSO_ENABLED=%j (explicit config wins)', + (val) => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const prev = process.env.OS_SSO_ENABLED; + process.env.OS_SSO_ENABLED = val; + try { + const manager = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + plugins: { sso: false }, + }); + expect(manager.getPublicConfig().features.sso).toBe(false); + expect(manager.isSsoWired()).toBe(false); + } finally { + if (prev === undefined) delete process.env.OS_SSO_ENABLED; + else process.env.OS_SSO_ENABLED = prev; + warnSpy.mockRestore(); + } + }, + ); + + // #13439 — ssoDomainVerification follows the same explicit-config-wins + // order (and still requires sso to be wired at all). + it('should let plugins.ssoDomainVerification=false win over OS_SSO_DOMAIN_VERIFICATION', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const prevSso = process.env.OS_SSO_ENABLED; + const prevDv = process.env.OS_SSO_DOMAIN_VERIFICATION; + process.env.OS_SSO_DOMAIN_VERIFICATION = 'true'; + delete process.env.OS_SSO_ENABLED; + try { + const manager = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + plugins: { sso: true, ssoDomainVerification: false }, + }); + expect(manager.isSsoDomainVerificationEnabled()).toBe(false); + } finally { + if (prevSso === undefined) delete process.env.OS_SSO_ENABLED; + else process.env.OS_SSO_ENABLED = prevSso; + if (prevDv === undefined) delete process.env.OS_SSO_DOMAIN_VERIFICATION; + else process.env.OS_SSO_DOMAIN_VERIFICATION = prevDv; + warnSpy.mockRestore(); + } + }); + + it('should let OS_SSO_DOMAIN_VERIFICATION decide when the config leaves it unset', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const prevSso = process.env.OS_SSO_ENABLED; + const prevDv = process.env.OS_SSO_DOMAIN_VERIFICATION; + process.env.OS_SSO_DOMAIN_VERIFICATION = 'true'; + delete process.env.OS_SSO_ENABLED; + try { + const manager = new AuthManager({ + secret: 'test-secret-at-least-32-chars-long', + plugins: { sso: true }, + }); + expect(manager.isSsoDomainVerificationEnabled()).toBe(true); + } finally { + if (prevSso === undefined) delete process.env.OS_SSO_ENABLED; + else process.env.OS_SSO_ENABLED = prevSso; + if (prevDv === undefined) delete process.env.OS_SSO_DOMAIN_VERIFICATION; + else process.env.OS_SSO_DOMAIN_VERIFICATION = prevDv; + warnSpy.mockRestore(); + } + }); + it('should filter out disabled providers', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const manager = new AuthManager({ diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index e2f20149c5..1fdd73ebf2 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -2410,6 +2410,17 @@ export class AuthManager { // platform-standard truthy set (`true`/`1`/`yes`/`on`, case-insensitive) // instead of only the literal string `'true'` — a repeated operator footgun // (`OS_SSO_ENABLED=1` silently parsed as disabled). + // + // Precedence for `scim` / `sso` / `ssoDomainVerification` (#13439, + // maintainer ruling 2026-08-31): an EXPLICIT config value wins over the + // env var; the env var decides only where the config leaves the key unset + // (tri-state `z.boolean().optional()` in AuthPluginConfigSchema). This is + // deliberately the OPPOSITE of the env-wins order the OIDC/2FA/HIBP keys + // above keep: for these three, config is how the code constructing the + // AuthPlugin states a value the deployment env must not silently outrank + // (the cloud control plane's plan-derived `plugins.scim` is the known + // writer), while the operator per-environment override survives for every + // deployment that leaves the keys unset. const ssoFromEnv = readBooleanEnv('OS_SSO_ENABLED'); const scimFromEnv = readBooleanEnv('OS_SCIM_ENABLED'); // Opt-in DNS domain-verification for external SSO providers (ADR-0024 ②). @@ -2424,7 +2435,7 @@ export class AuthManager { // @better-auth/scim's `active:false` → ban runs through the admin plugin, // and org-scoped tokens need the organization plugin — so enabling SCIM // forces `admin` on (organization already defaults on). See ADR-0071. - const scimEffective = scimFromEnv ?? (pluginConfig as any).scim ?? false; + const scimEffective = pluginConfig.scim ?? scimFromEnv ?? false; const twoFactorFromEnv = readBooleanEnv('OS_AUTH_TWO_FACTOR'); const hibpFromEnv = readBooleanEnv('OS_AUTH_PASSWORD_REJECT_BREACHED'); const enabled = { @@ -2441,8 +2452,8 @@ export class AuthManager { // #2766 V1.5 — phone+password sign-in. Opt-in; OTP flows stay off until // SMS infrastructure exists (tracked separately). phoneNumber: (pluginConfig as any).phoneNumber ?? false, - sso: ssoFromEnv ?? (pluginConfig as any).sso ?? false, - ssoDomainVerification: ssoDomainVerifyFromEnv ?? (pluginConfig as any).ssoDomainVerification ?? false, + sso: pluginConfig.sso ?? ssoFromEnv ?? false, + ssoDomainVerification: pluginConfig.ssoDomainVerification ?? ssoDomainVerifyFromEnv ?? false, scim: scimEffective, }; @@ -5062,7 +5073,7 @@ export class AuthManager { // plugin on, ADR-0071) — previously `?? false`, which advertised the // admin surface as absent in SCIM-enabled deployments where it was // actually mounted, hiding the admin sys_user actions (#2766 V1). - admin: pluginConfig.admin ?? (readBooleanEnv('OS_SCIM_ENABLED') ?? (pluginConfig as any).scim ?? false), + admin: pluginConfig.admin ?? (pluginConfig.scim ?? readBooleanEnv('OS_SCIM_ENABLED') ?? false), // #2766 V1.5 — mirrors `enabled.phoneNumber` in buildPluginList(). phoneNumber: (pluginConfig as any).phoneNumber ?? false, // #2780 — OTP sign-in / self-service reset is only advertised when the @@ -5096,9 +5107,11 @@ export class AuthManager { /** * Coarse "is the domain-routed `@better-auth/sso` plugin wired" flag. * Resolved with the EXACT logic that decides whether the plugin is mounted - * in `buildPlugins()` (`ssoFromEnv ?? pluginConfig.sso ?? false`) so the + * in `buildPlugins()` (`pluginConfig.sso ?? ssoFromEnv ?? false`) so the * advertised capability can never disagree with the actual `/sign-in/sso` - * route. `OS_SSO_ENABLED` (when set) wins over the config-file setting. + * route. An explicit `plugins.sso` wins over `OS_SSO_ENABLED`; the env var + * decides only when the config leaves it unset (#13439, maintainer ruling + * 2026-08-31). * Public so `AuthPlugin` can gate the Setup-nav "SSO Providers" entry on it * (captures both self-host `OS_SSO_ENABLED` and the cloud per-env * `planAllowsSso` config, since that arrives via `plugins.sso`). @@ -5107,7 +5120,7 @@ export class AuthManager { // Same parser as `buildPluginList` (`readBooleanEnv`) so the advertised // capability can never disagree with the actually-mounted route. const ssoFromEnv = readBooleanEnv('OS_SSO_ENABLED'); - return ssoFromEnv ?? (this.config.plugins as any)?.sso ?? false; + return this.config.plugins?.sso ?? ssoFromEnv ?? false; } /** @@ -5122,7 +5135,7 @@ export class AuthManager { public isSsoDomainVerificationEnabled(): boolean { if (!this.isSsoWired()) return false; const fromEnv = readBooleanEnv('OS_SSO_DOMAIN_VERIFICATION'); - return fromEnv ?? (this.config.plugins as any)?.ssoDomainVerification ?? false; + return this.config.plugins?.ssoDomainVerification ?? fromEnv ?? false; } /** diff --git a/packages/spec/authorable-surface/system.json b/packages/spec/authorable-surface/system.json index cb468d1d75..d67930f673 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -81,6 +81,9 @@ "system/AuthPluginConfig:passkeys", "system/AuthPluginConfig:passwordRejectBreached", "system/AuthPluginConfig:phoneNumber", + "system/AuthPluginConfig:scim", + "system/AuthPluginConfig:sso", + "system/AuthPluginConfig:ssoDomainVerification", "system/AuthPluginConfig:twoFactor", "system/AuthProviderConfig:clientId", "system/AuthProviderConfig:clientSecret", diff --git a/packages/spec/src/system/auth-config.zod.ts b/packages/spec/src/system/auth-config.zod.ts index 981e2ff968..222255734b 100644 --- a/packages/spec/src/system/auth-config.zod.ts +++ b/packages/spec/src/system/auth-config.zod.ts @@ -119,6 +119,55 @@ export const AuthPluginConfigSchema = lazySchema(() => z.object({ phoneNumber: z.boolean().default(false).describe( 'Enable phone-number sign-in (phone + password; OTP sign-in/reset when an SMS service is configured)', ), + /** + * Enable the SCIM 2.0 Service Provider (`@better-auth/scim`) so an external + * IdP (Okta, Entra, …) can auto-provision and deprovision this + * environment's users. + * + * Tri-state on purpose (#13439): when left UNSET, the `OS_SCIM_ENABLED` + * env var decides (absent ⇒ off), preserving the operator's ability to + * toggle SCIM per environment without touching the application bundle. An + * EXPLICIT value here wins over the env var — this is the declared way for + * the code that constructs the auth plugin to state a value the deployment + * env cannot silently outrank (maintainer ruling 2026-08-31; note this is + * the opposite precedence from `dynamicClientRegistration`'s documented + * env-wins behaviour, deliberately). + * + * Enabling SCIM (from either source) also forces the better-auth `admin` + * plugin on when `admin` is left unset — SCIM's `active:false` → + * ban/unban runs through it (ADR-0071). + */ + scim: z.boolean().optional().describe( + 'Enable the SCIM 2.0 provisioning surface. Unset: OS_SCIM_ENABLED decides (absent = off); ' + + 'an explicit value wins over the env var. Effective SCIM forces the admin plugin on unless admin is set.', + ), + /** + * Enable enterprise SSO (`@better-auth/sso`): domain-routed sign-in against + * operator-registered OIDC/SAML identity providers (`/sign-in/sso`, + * `sys_sso_provider`). + * + * Tri-state on purpose (#13439): when left UNSET, the `OS_SSO_ENABLED` env + * var decides (absent ⇒ off). An EXPLICIT value here wins over the env var + * (maintainer ruling 2026-08-31 — see `scim` for the precedence rationale). + */ + sso: z.boolean().optional().describe( + 'Enable enterprise SSO (domain-routed OIDC/SAML sign-in). Unset: OS_SSO_ENABLED decides ' + + '(absent = off); an explicit value wins over the env var.', + ), + /** + * Enable opt-in DNS domain-verification for external SSO providers + * (ADR-0024 ②): mounts `/sso/{request-domain-verification,verify-domain}` + * and enforces the hard "provider domain must be DNS-verified to log in" + * gate. Only honored when the SSO plugin itself is enabled (see `sso`). + * + * Tri-state on purpose (#13439): when left UNSET, the + * `OS_SSO_DOMAIN_VERIFICATION` env var decides (absent ⇒ off). An EXPLICIT + * value here wins over the env var (maintainer ruling 2026-08-31). + */ + ssoDomainVerification: z.boolean().optional().describe( + 'Enable DNS domain-verification for SSO providers (requires sso). Unset: OS_SSO_DOMAIN_VERIFICATION ' + + 'decides (absent = off); an explicit value wins over the env var.', + ), })); /**