@@ -149,6 +149,16 @@ function readDisableSignUpEnv(): boolean | undefined {
149149 return readBooleanEnv ( 'OS_DISABLE_SIGNUP' ) ;
150150}
151151
152+ /**
153+ * SSO-only ("enforced") login mode from the deployment env. Self-host ops set
154+ * `OS_AUTH_SSO_ONLY=true` to lock the team to the configured IdP (parity with
155+ * the `OS_DISABLE_SIGNUP` self-host knob). The cloud runtime drives the same
156+ * behaviour per-env via the `ssoOnlyMode` config field instead.
157+ */
158+ function readSsoOnlyEnv ( ) : boolean | undefined {
159+ return readBooleanEnv ( 'OS_AUTH_SSO_ONLY' ) ;
160+ }
161+
152162/**
153163 * Extended options for AuthManager
154164 */
@@ -394,7 +404,12 @@ export class AuthManager {
394404 // lock the registration policy without relying on UI state.
395405 emailAndPassword : ( ( ) => {
396406 const disableSignUpFromEnv = readDisableSignUpEnv ( ) ;
397- const effectiveDisableSignUp = disableSignUpFromEnv ?? this . config . emailAndPassword ?. disableSignUp ;
407+ // SSO-only ("enforced") forces self-registration off (the managed team
408+ // signs in via the IdP). `enabled` stays true so the break-glass
409+ // password endpoint keeps working for the env owner / local admin.
410+ const effectiveDisableSignUp = this . resolveSsoOnly ( )
411+ ? true
412+ : ( disableSignUpFromEnv ?? this . config . emailAndPassword ?. disableSignUp ) ;
398413 return {
399414 enabled : this . config . emailAndPassword ?. enabled ?? true ,
400415 ...( passwordHasher ? { password : passwordHasher } : { } ) ,
@@ -502,8 +517,10 @@ export class AuthManager {
502517
503518 // Database hooks (fired by better-auth's adapter writes — these run
504519 // for SSO JIT-provisioning too, unlike kernel-level ObjectQL
505- // middleware which better-auth's adapter bypasses).
506- ...( this . config . databaseHooks ? { databaseHooks : this . config . databaseHooks } : { } ) ,
520+ // middleware which better-auth's adapter bypasses). The framework's
521+ // identity-source stamp (`account.create.after`) is always composed in,
522+ // preserving any host-supplied hooks.
523+ databaseHooks : this . composeDatabaseHooks ( this . config . databaseHooks ) ,
507524
508525 // Bootstrap bypass for `disableSignUp`. The first-run owner wizard
509526 // (`/_account/setup`) calls `POST /auth/sign-up/email` to create
@@ -577,6 +594,68 @@ export class AuthManager {
577594 }
578595 return ;
579596 }
597+
598+ // ── Break-glass: never remove the LAST local-password login ──────
599+ // Under enforced SSO the managed team holds no local credential; the
600+ // env owner / a local admin keeps one as the break-glass escape hatch
601+ // so an IdP outage can never lock the org out. Refuse to delete or
602+ // ban the last user holding a `credential` account. Generic over the
603+ // IdP. Managed (credential-less) users are unaffected. Fail-open on
604+ // lookup hiccups (a transient query error must not block legit ops).
605+ if (
606+ ctx ?. path === '/delete-user' ||
607+ ctx ?. path === '/admin/remove-user' ||
608+ ctx ?. path === '/admin/ban-user'
609+ ) {
610+ let isLastLocalCredential = false ;
611+ try {
612+ const adapter = ctx . context . adapter ;
613+ let targetId : string | undefined = ctx ?. body ?. userId ?? ctx ?. body ?. user_id ;
614+ if ( ! targetId && ctx . path === '/delete-user' ) {
615+ const { getSessionFromCtx } = await import ( 'better-auth/api' ) ;
616+ const s : any = await getSessionFromCtx ( ctx as any ) . catch ( ( ) => null ) ;
617+ targetId = s ?. user ?. id ?? s ?. session ?. userId ;
618+ }
619+ if ( targetId ) {
620+ // Only guard when the target actually holds a local credential —
621+ // removing a credential-less (managed) user can't cause lockout.
622+ const targetCred = await adapter . findOne ( {
623+ model : 'account' ,
624+ where : [
625+ { field : 'userId' , value : targetId } ,
626+ { field : 'providerId' , value : 'credential' } ,
627+ ] ,
628+ } ) ;
629+ if ( targetCred ) {
630+ const creds : any [ ] = await adapter . findMany ( {
631+ model : 'account' ,
632+ where : [ { field : 'providerId' , value : 'credential' } ] ,
633+ } ) ;
634+ const otherHolders = new Set (
635+ ( creds ?? [ ] )
636+ . map ( ( a : any ) => a ?. userId ?? a ?. user_id )
637+ . filter ( ( id : any ) => id && id !== targetId ) ,
638+ ) ;
639+ isLastLocalCredential = otherHolders . size === 0 ;
640+ }
641+ }
642+ } catch {
643+ // Fail-open — never block a legitimate op on a lookup error.
644+ }
645+ if ( isLastLocalCredential ) {
646+ const { APIError } = await import ( 'better-auth/api' ) ;
647+ throw new APIError ( 'CONFLICT' , {
648+ message :
649+ 'Cannot remove the last local password login. At least one ' +
650+ 'break-glass account with a password must remain so an identity-' +
651+ 'provider outage can never lock the organization out. Add another ' +
652+ 'local password first, then retry.' ,
653+ code : 'LAST_LOCAL_CREDENTIAL' ,
654+ } ) ;
655+ }
656+ // fall through to better-auth's own handler
657+ }
658+
580659 if ( ctx ?. path !== '/sign-up/email' ) return ;
581660 const ep = ctx ?. context ?. options ?. emailAndPassword ;
582661 if ( ! ep ?. disableSignUp ) return ;
@@ -1447,6 +1526,19 @@ export class AuthManager {
14471526 // AuthPluginConfig.
14481527 // ---------------------------------------------------------------------------
14491528
1529+ /**
1530+ * SSO-only ("enforced") login mode: the login UI hides the local password
1531+ * form + self-registration so the team signs in via the IdP only.
1532+ * `OS_AUTH_SSO_ONLY` (when set) wins over the `ssoOnlyMode` config knob —
1533+ * parity with the `disableSignUp` env override — so a deployment can force
1534+ * it regardless of the per-env/config value. Break-glass is preserved: this
1535+ * NEVER disables `emailAndPassword.enabled`; it only forces `disableSignUp`
1536+ * and signals the UI to hide the password form. Generic over the IdP.
1537+ */
1538+ private resolveSsoOnly ( ) : boolean {
1539+ return readSsoOnlyEnv ( ) ?? ( this . config . ssoOnlyMode ?? false ) ;
1540+ }
1541+
14501542 getPublicConfig ( ) {
14511543 // Extract social providers info (without sensitive data)
14521544 const socialProviders = [ ] ;
@@ -1493,9 +1585,13 @@ export class AuthManager {
14931585 // `emailAndPassword.disableSignUp` (default `false`).
14941586 const emailPasswordConfig : Partial < EmailAndPasswordConfig > = this . config . emailAndPassword ?? { } ;
14951587 const disableSignUpFromEnv = readDisableSignUpEnv ( ) ;
1588+ // SSO-only ("enforced") hides the local password form + self-registration.
1589+ // `enabled` stays true (break-glass), but signup is forced off and the UI
1590+ // suppresses the password form via `features.ssoEnforced` below.
1591+ const ssoOnly = this . resolveSsoOnly ( ) ;
14961592 const emailPassword = {
14971593 enabled : emailPasswordConfig . enabled !== false , // Default to true
1498- disableSignUp : disableSignUpFromEnv ?? emailPasswordConfig . disableSignUp ?? false ,
1594+ disableSignUp : ssoOnly ? true : ( disableSignUpFromEnv ?? emailPasswordConfig . disableSignUp ?? false ) ,
14991595 requireEmailVerification : emailPasswordConfig . requireEmailVerification ?? false ,
15001596 } ;
15011597
@@ -1550,6 +1646,10 @@ export class AuthManager {
15501646 // `isSsoUsable()` so the login UI can hide the "Sign in with SSO" button
15511647 // both when SSO is off AND when it's on but no IdP exists yet.
15521648 sso : this . isSsoWired ( ) ,
1649+ // SSO-only ("enforced"): tell the login UI to hide the local password
1650+ // form + self-registration. A break-glass "use a password" link remains
1651+ // for the env owner / local admin. Driven by `ssoOnlyMode` / `OS_AUTH_SSO_ONLY`.
1652+ ssoEnforced : ssoOnly ,
15531653 deviceAuthorization : pluginConfig . deviceAuthorization ?? false ,
15541654 admin : pluginConfig . admin ?? false ,
15551655 ...( termsUrl ? { termsUrl } : { } ) ,
@@ -1599,6 +1699,96 @@ export class AuthManager {
15991699 }
16001700 }
16011701
1702+ /**
1703+ * Compose the framework's identity-source stamp (`account.create.after`)
1704+ * with any host-supplied `databaseHooks`, preserving BOTH. The cloud passes
1705+ * `user.create.after` (personal-org provisioning) + `session.create.before`
1706+ * (active-org) — different model/op, so no collision — but if a host ever
1707+ * adds its own `account.create.after` we chain it after the stamp rather
1708+ * than silently dropping one.
1709+ */
1710+ private composeDatabaseHooks (
1711+ host ?: BetterAuthOptions [ 'databaseHooks' ] ,
1712+ ) : BetterAuthOptions [ 'databaseHooks' ] {
1713+ const stamp = ( account : any , ctx : any ) => this . stampIdentitySource ( account , ctx ) ;
1714+ const hostAccountAfter = ( host as any ) ?. account ?. create ?. after ;
1715+ const after = hostAccountAfter
1716+ ? async ( account : any , ctx : any ) => {
1717+ await stamp ( account , ctx ) ;
1718+ return hostAccountAfter ( account , ctx ) ;
1719+ }
1720+ : stamp ;
1721+ return {
1722+ ...( host ?? { } ) ,
1723+ account : {
1724+ ...( ( host as any ) ?. account ?? { } ) ,
1725+ create : {
1726+ ...( ( host as any ) ?. account ?. create ?? { } ) ,
1727+ after,
1728+ } ,
1729+ } ,
1730+ } as BetterAuthOptions [ 'databaseHooks' ] ;
1731+ }
1732+
1733+ /**
1734+ * Maintain `sys_user.source` (ADR-0024 D4 provenance) as accounts are linked.
1735+ * Drives the managed-vs-native user-mgmt gating: a managed (`idp-provisioned`)
1736+ * user holds no local credential, so the password / identity-edit actions
1737+ * hide for them — preventing a managed user from self-minting a local
1738+ * password that would bypass enforced SSO.
1739+ *
1740+ * Two cases, both break-glass safe and idempotent (only writes on a real
1741+ * change, so trackHistory stays quiet):
1742+ *
1743+ * • A **federated** account (any non-`credential` provider — the cloud-as-IdP
1744+ * `objectstack-cloud` provider OR a customer's own OIDC/SAML IdP) is
1745+ * linked AND the user holds NO local credential → mark `idp-provisioned`.
1746+ * A user who already has a `credential` account (an env-native user who
1747+ * linked SSO) is left `env-native` — they keep a usable password.
1748+ *
1749+ * • A **credential** account is created (local signup, or the break-glass
1750+ * owner's password set via set-initial-password — which can land AFTER the
1751+ * first SSO link) → ensure `env-native`. This flips a previously-stamped
1752+ * owner back, so the break-glass admin never loses self-service password
1753+ * management.
1754+ *
1755+ * Best-effort: any failure leaves the prior value (the gate fails open — a
1756+ * managed user might transiently show a password action that simply errors —
1757+ * never a hard login failure).
1758+ */
1759+ private async stampIdentitySource ( account : any , _ctx ?: unknown ) : Promise < void > {
1760+ try {
1761+ const providerId = account ?. providerId ?? account ?. provider_id ;
1762+ const userId = account ?. userId ?? account ?. user_id ;
1763+ if ( ! userId || ! providerId ) return ;
1764+ const engine = this . getDataEngine ( ) ;
1765+ if ( ! engine ) return ;
1766+ const SYSTEM_CTX = { isSystem : true , roles : [ ] , permissions : [ ] } ;
1767+
1768+ if ( providerId === 'credential' ) {
1769+ // Gained a local password → env-native. Only write if currently
1770+ // managed (avoids a no-op history row on every local signup).
1771+ const u = await engine . findOne ( 'sys_user' , {
1772+ filter : { id : userId } , fields : [ 'id' , 'source' ] , context : SYSTEM_CTX ,
1773+ } as any ) ;
1774+ if ( u && u . source === 'idp_provisioned' ) {
1775+ await engine . update ( 'sys_user' , { id : userId , source : 'env_native' } , { context : SYSTEM_CTX } as any ) ;
1776+ }
1777+ return ;
1778+ }
1779+
1780+ // Federated link → managed, unless a local credential already exists.
1781+ const credentialCount = await engine . count ( 'sys_account' , {
1782+ filter : { user_id : userId , provider_id : 'credential' } ,
1783+ context : SYSTEM_CTX ,
1784+ } as any ) ;
1785+ if ( typeof credentialCount === 'number' && credentialCount > 0 ) return ;
1786+ await engine . update ( 'sys_user' , { id : userId , source : 'idp_provisioned' } , { context : SYSTEM_CTX } as any ) ;
1787+ } catch {
1788+ // Provenance stamp must never break federated login. Leave the prior value.
1789+ }
1790+ }
1791+
16021792 /**
16031793 * Returns the data engine wired into this auth manager. Used by route
16041794 * handlers (e.g. bootstrap-status) that need to query identity tables
0 commit comments