Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .changeset/declared-scim-sso-explicit-config-wins.md
Original file line numberDiff line numberDiff line change
@@ -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).

<!-- adr-0087: not-required (no-migration-prescription) Additive declaration of three previously-undeclared optional keys plus a documented precedence flip: no key is removed, renamed or re-shaped, no tombstone exists, and nothing mechanical for `objectstack migrate meta` to rewrite. The affected quadrant (explicit value AND env var set, relying on env winning) has a measured population of one writer — the cloud control plane — which requires the new order. -->
6 changes: 6 additions & 0 deletions content/docs/references/system/auth-config.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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`

Expand DownExpand Up@@ -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. |


---
Expand Down
172 changes: 166 additions & 6 deletions packages/plugins/plugin-auth/src/auth-manager.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) => {
Expand DownExpand Up@@ -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) => {
Expand DownExpand Up@@ -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';
Expand DownExpand Up@@ -2575,15 +2643,14 @@ 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;
process.env.OS_SSO_ENABLED = val;
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 {
Expand All@@ -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({
Expand Down
29 changes: 21 additions & 8 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 ②).
Expand All@@ -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 = {
Expand All@@ -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,
};

Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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`).
Expand All@@ -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;
}

/**
Expand All@@ -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;
}

/**
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat(spec,plugin-auth): declare plugins.scim/sso/ssoDomainVerification, explicit config wins over env by claude[bot] · Pull Request #14066 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .changeset/declared-scim-sso-explicit-config-wins.md
Original file line numberDiff line numberDiff line change
@@ -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).

<!-- adr-0087: not-required (no-migration-prescription) Additive declaration of three previously-undeclared optional keys plus a documented precedence flip: no key is removed, renamed or re-shaped, no tombstone exists, and nothing mechanical for `objectstack migrate meta` to rewrite. The affected quadrant (explicit value AND env var set, relying on env winning) has a measured population of one writer — the cloud control plane — which requires the new order. -->
6 changes: 6 additions & 0 deletions content/docs/references/system/auth-config.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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`

Expand DownExpand Up@@ -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. |


---
Expand Down
172 changes: 166 additions & 6 deletions packages/plugins/plugin-auth/src/auth-manager.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) => {
Expand DownExpand Up@@ -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) => {
Expand DownExpand Up@@ -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';
Expand DownExpand Up@@ -2575,15 +2643,14 @@ 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;
process.env.OS_SSO_ENABLED = val;
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 {
Expand All@@ -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({
Expand Down
29 changes: 21 additions & 8 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 ②).
Expand All@@ -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 = {
Expand All@@ -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,
};

Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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`).
Expand All@@ -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;
}

/**
Expand All@@ -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;
}

/**
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(spec,plugin-auth): declare plugins.scim/sso/ssoDomainVerification, explicit config wins over env by claude[bot] · Pull Request #14066 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .changeset/declared-scim-sso-explicit-config-wins.md
Original file line numberDiff line numberDiff line change
@@ -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).

<!-- adr-0087: not-required (no-migration-prescription) Additive declaration of three previously-undeclared optional keys plus a documented precedence flip: no key is removed, renamed or re-shaped, no tombstone exists, and nothing mechanical for `objectstack migrate meta` to rewrite. The affected quadrant (explicit value AND env var set, relying on env winning) has a measured population of one writer — the cloud control plane — which requires the new order. -->
6 changes: 6 additions & 0 deletions content/docs/references/system/auth-config.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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`

Expand DownExpand Up@@ -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. |


---
Expand Down
172 changes: 166 additions & 6 deletions packages/plugins/plugin-auth/src/auth-manager.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) => {
Expand DownExpand Up@@ -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) => {
Expand DownExpand Up@@ -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';
Expand DownExpand Up@@ -2575,15 +2643,14 @@ 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;
process.env.OS_SSO_ENABLED = val;
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 {
Expand All@@ -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({
Expand Down
29 changes: 21 additions & 8 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 ②).
Expand All@@ -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 = {
Expand All@@ -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,
};

Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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`).
Expand All@@ -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;
}

/**
Expand All@@ -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;
}

/**
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(spec,plugin-auth): declare plugins.scim/sso/ssoDomainVerification, explicit config wins over env by claude[bot] · Pull Request #14066 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .changeset/declared-scim-sso-explicit-config-wins.md
Original file line numberDiff line numberDiff line change
@@ -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).

<!-- adr-0087: not-required (no-migration-prescription) Additive declaration of three previously-undeclared optional keys plus a documented precedence flip: no key is removed, renamed or re-shaped, no tombstone exists, and nothing mechanical for `objectstack migrate meta` to rewrite. The affected quadrant (explicit value AND env var set, relying on env winning) has a measured population of one writer — the cloud control plane — which requires the new order. -->
6 changes: 6 additions & 0 deletions content/docs/references/system/auth-config.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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`

Expand DownExpand Up@@ -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. |


---
Expand Down
172 changes: 166 additions & 6 deletions packages/plugins/plugin-auth/src/auth-manager.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) => {
Expand DownExpand Up@@ -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) => {
Expand DownExpand Up@@ -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';
Expand DownExpand Up@@ -2575,15 +2643,14 @@ 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;
process.env.OS_SSO_ENABLED = val;
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 {
Expand All@@ -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({
Expand Down
29 changes: 21 additions & 8 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 ②).
Expand All@@ -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 = {
Expand All@@ -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,
};

Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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`).
Expand All@@ -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;
}

/**
Expand All@@ -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;
}

/**
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat(spec,plugin-auth): declare plugins.scim/sso/ssoDomainVerification, explicit config wins over env by claude[bot] · Pull Request #14066 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .changeset/declared-scim-sso-explicit-config-wins.md
Original file line numberDiff line numberDiff line change
@@ -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).

<!-- adr-0087: not-required (no-migration-prescription) Additive declaration of three previously-undeclared optional keys plus a documented precedence flip: no key is removed, renamed or re-shaped, no tombstone exists, and nothing mechanical for `objectstack migrate meta` to rewrite. The affected quadrant (explicit value AND env var set, relying on env winning) has a measured population of one writer — the cloud control plane — which requires the new order. -->
6 changes: 6 additions & 0 deletions content/docs/references/system/auth-config.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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`

Expand DownExpand Up@@ -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. |


---
Expand Down
172 changes: 166 additions & 6 deletions packages/plugins/plugin-auth/src/auth-manager.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) => {
Expand DownExpand Up@@ -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) => {
Expand DownExpand Up@@ -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';
Expand DownExpand Up@@ -2575,15 +2643,14 @@ 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;
process.env.OS_SSO_ENABLED = val;
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 {
Expand All@@ -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({
Expand Down
29 changes: 21 additions & 8 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 ②).
Expand All@@ -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 = {
Expand All@@ -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,
};

Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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`).
Expand All@@ -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;
}

/**
Expand All@@ -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;
}

/**
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(spec,plugin-auth): declare plugins.scim/sso/ssoDomainVerification, explicit config wins over env by claude[bot] · Pull Request #14066 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .changeset/declared-scim-sso-explicit-config-wins.md
Original file line numberDiff line numberDiff line change
@@ -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).

<!-- adr-0087: not-required (no-migration-prescription) Additive declaration of three previously-undeclared optional keys plus a documented precedence flip: no key is removed, renamed or re-shaped, no tombstone exists, and nothing mechanical for `objectstack migrate meta` to rewrite. The affected quadrant (explicit value AND env var set, relying on env winning) has a measured population of one writer — the cloud control plane — which requires the new order. -->
6 changes: 6 additions & 0 deletions content/docs/references/system/auth-config.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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`

Expand DownExpand Up@@ -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. |


---
Expand Down
172 changes: 166 additions & 6 deletions packages/plugins/plugin-auth/src/auth-manager.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) => {
Expand DownExpand Up@@ -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) => {
Expand DownExpand Up@@ -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';
Expand DownExpand Up@@ -2575,15 +2643,14 @@ 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;
process.env.OS_SSO_ENABLED = val;
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 {
Expand All@@ -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({
Expand Down
29 changes: 21 additions & 8 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 ②).
Expand All@@ -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 = {
Expand All@@ -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,
};

Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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`).
Expand All@@ -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;
}

/**
Expand All@@ -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;
}

/**
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(spec,plugin-auth): declare plugins.scim/sso/ssoDomainVerification, explicit config wins over env by claude[bot] · Pull Request #14066 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .changeset/declared-scim-sso-explicit-config-wins.md
Original file line numberDiff line numberDiff line change
@@ -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).

<!-- adr-0087: not-required (no-migration-prescription) Additive declaration of three previously-undeclared optional keys plus a documented precedence flip: no key is removed, renamed or re-shaped, no tombstone exists, and nothing mechanical for `objectstack migrate meta` to rewrite. The affected quadrant (explicit value AND env var set, relying on env winning) has a measured population of one writer — the cloud control plane — which requires the new order. -->
6 changes: 6 additions & 0 deletions content/docs/references/system/auth-config.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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`

Expand DownExpand Up@@ -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. |


---
Expand Down
172 changes: 166 additions & 6 deletions packages/plugins/plugin-auth/src/auth-manager.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) => {
Expand DownExpand Up@@ -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) => {
Expand DownExpand Up@@ -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';
Expand DownExpand Up@@ -2575,15 +2643,14 @@ 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;
process.env.OS_SSO_ENABLED = val;
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 {
Expand All@@ -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({
Expand Down
29 changes: 21 additions & 8 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 ②).
Expand All@@ -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 = {
Expand All@@ -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,
};

Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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`).
Expand All@@ -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;
}

/**
Expand All@@ -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;
}

/**
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat(spec,plugin-auth): declare plugins.scim/sso/ssoDomainVerification, explicit config wins over env by claude[bot] · Pull Request #14066 · objectstack-ai/objectstack · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .changeset/declared-scim-sso-explicit-config-wins.md
Original file line numberDiff line numberDiff line change
@@ -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).

<!-- adr-0087: not-required (no-migration-prescription) Additive declaration of three previously-undeclared optional keys plus a documented precedence flip: no key is removed, renamed or re-shaped, no tombstone exists, and nothing mechanical for `objectstack migrate meta` to rewrite. The affected quadrant (explicit value AND env var set, relying on env winning) has a measured population of one writer — the cloud control plane — which requires the new order. -->
6 changes: 6 additions & 0 deletions content/docs/references/system/auth-config.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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`

Expand DownExpand Up@@ -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. |


---
Expand Down
172 changes: 166 additions & 6 deletions packages/plugins/plugin-auth/src/auth-manager.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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) => {
Expand DownExpand Up@@ -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) => {
Expand DownExpand Up@@ -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';
Expand DownExpand Up@@ -2575,15 +2643,14 @@ 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;
process.env.OS_SSO_ENABLED = val;
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 {
Expand All@@ -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({
Expand Down
29 changes: 21 additions & 8 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 ②).
Expand All@@ -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 = {
Expand All@@ -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,
};

Expand DownExpand Up@@ -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
Expand DownExpand Up@@ -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`).
Expand All@@ -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;
}

/**
Expand All@@ -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;
}

/**
Expand Down
Loading
Loading