Skip to content

Commit 73f4dc7

Browse files
os-zhuangclaude
andauthored
feat(auth): SSO-only ("enforced") login primitive + identity-source provenance (ADR-0024 D5.2/D4) (#2349)
Open mechanism (ADR-0002) for per-deployment SSO-only login — drives the cloud's per-env enforced mode AND self-host OSS/EE (via OS_AUTH_SSO_ONLY). Generic over the IdP (cloud-as-IdP or a customer's own OIDC/SAML). - AuthConfig.ssoOnlyMode + AuthFeatures.ssoEnforced (spec). When on: emailAndPassword.disableSignUp is forced true + /auth/config emits features.ssoEnforced so the login UI hides the local password form + self-registration. `enabled` stays true → the env owner keeps a break-glass password escape hatch (managed users simply hold no credential). - sys_user.source (idp-provisioned|env-native, ADR-0024 D4 provenance), stamped by a framework-generic account.create.after hook composed with any host databaseHooks. Break-glass safe + idempotent: a user holding a local credential stays env-native; a credential gained after an SSO link flips a previously-managed owner back. - Managed-vs-native gating: change_my_password / change_my_email / delete_my_account hide for idp-provisioned users (can't self-mint a local password that bypasses enforced SSO). - Break-glass guard: refuse to delete/ban the LAST holder of a local credential (LAST_LOCAL_CREDENTIAL) so an IdP outage can never lock the org out. Inert until a consumer sets ssoOnlyMode (cloud per-env / OS_AUTH_SSO_ONLY). Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f44c1bd commit 73f4dc7

5 files changed

Lines changed: 245 additions & 8 deletions

File tree

packages/platform-objects/src/identity/sys-user.object.ts

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,11 @@ export const SysUser = ObjectSchema.create({
174174
locations: ['record_header', 'record_more', 'record_section'],
175175
type: 'api',
176176
target: '/api/v1/auth/change-password',
177-
visible: 'record.id == ctx.user.id',
177+
// Managed (IdP-provisioned) users hold no local credential — hide the
178+
// password form so they can't self-mint a password that bypasses
179+
// enforced SSO. The break-glass owner (env-native, or flipped back when
180+
// their break-glass password is set) keeps it. ADR-0024 D4/D5.2.
181+
visible: 'record.id == ctx.user.id && record.source != "idp_provisioned"',
178182
successMessage: 'Password changed',
179183
refreshAfter: false,
180184
params: [
@@ -191,7 +195,9 @@ export const SysUser = ObjectSchema.create({
191195
locations: ['record_header', 'record_more', 'record_section'],
192196
type: 'api',
193197
target: '/api/v1/auth/change-email',
194-
visible: 'record.id == ctx.user.id',
198+
// A managed user's email is owned by the IdP — a local change would
199+
// desync. Hide for IdP-provisioned; env-native users keep it.
200+
visible: 'record.id == ctx.user.id && record.source != "idp_provisioned"',
195201
successMessage: 'Verification email sent — check the new address to confirm.',
196202
refreshAfter: false,
197203
params: [
@@ -222,7 +228,9 @@ export const SysUser = ObjectSchema.create({
222228
locations: ['record_more', 'record_section'],
223229
type: 'api',
224230
target: '/api/v1/auth/delete-user',
225-
visible: 'record.id == ctx.user.id',
231+
// Self-delete needs a local password; managed users are deprovisioned
232+
// via the IdP (org-removal / SCIM), not local self-service. Hide for them.
233+
visible: 'record.id == ctx.user.id && record.source != "idp_provisioned"',
226234
confirmText: 'Permanently delete your account? This cannot be undone — all your sessions will be terminated and all data you own will be removed per the configured retention policy.',
227235
successMessage: 'Account deleted',
228236
refreshAfter: false,
@@ -306,7 +314,7 @@ export const SysUser = ObjectSchema.create({
306314
name: 'all_users',
307315
label: 'All Users',
308316
data: { provider: 'object', object: 'sys_user' },
309-
columns: ['name', 'email', 'email_verified', 'two_factor_enabled', 'created_at'],
317+
columns: ['name', 'email', 'email_verified', 'source', 'two_factor_enabled', 'created_at'],
310318
sort: [{ field: 'name', order: 'asc' }],
311319
pagination: { pageSize: 50 },
312320
},
@@ -438,6 +446,29 @@ export const SysUser = ObjectSchema.create({
438446
}),
439447

440448
// ── System (auto-managed, hidden from create/edit forms) ─────
449+
// Identity provenance (ADR-0024 D4). `idp_provisioned` users were
450+
// JIT-created on first federated login (a `sys_account` exists for an
451+
// external/OIDC provider — e.g. the cloud-as-IdP `objectstack-cloud`
452+
// provider, or a customer's own IdP); `env_native` users registered
453+
// locally (email/password) or are app end-users. Stamped automatically by
454+
// the AuthManager `account.create.after` hook — never edited by hand.
455+
// Drives the managed-vs-native user-mgmt UI gating (the password /
456+
// identity-edit actions hide for managed users, who hold no local
457+
// credential) and is the marker SCIM lifecycle keys off. Owned by objectql
458+
// (better-auth is oblivious to this column — like `ai_access`).
459+
source: Field.select({
460+
label: 'Identity Source',
461+
required: false,
462+
readonly: true,
463+
group: 'System',
464+
defaultValue: 'env_native',
465+
options: [
466+
{ label: 'IdP-Provisioned', value: 'idp_provisioned' },
467+
{ label: 'Env-Native', value: 'env_native' },
468+
],
469+
description: 'How this identity was created — idp_provisioned (federated SSO JIT) or env_native (local signup / app end-user). System-managed; do not edit.',
470+
}),
471+
441472
id: Field.text({
442473
label: 'User ID',
443474
required: true,

packages/plugins/plugin-auth/src/auth-manager.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1047,6 +1047,7 @@ describe('AuthManager', () => {
10471047
organization: true,
10481048
oidcProvider: false,
10491049
sso: false,
1050+
ssoEnforced: false,
10501051
deviceAuthorization: false,
10511052
admin: false,
10521053
multiOrgEnabled: false,

packages/plugins/plugin-auth/src/auth-manager.ts

Lines changed: 194 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -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

packages/spec/src/api/auth-endpoints.zod.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,9 @@ export const AuthFeaturesConfigSchema = lazySchema(() => z.object({
198198
passkeys: z.boolean().default(false).describe('Passkey/WebAuthn support enabled'),
199199
magicLink: z.boolean().default(false).describe('Magic link login enabled'),
200200
organization: z.boolean().default(false).describe('Multi-tenant organization support enabled'),
201+
ssoEnforced: z.boolean().optional().describe(
202+
'SSO-only login enforced: the UI hides the local password form + self-registration (a break-glass "use a password" link remains)',
203+
),
201204
}));
202205

203206
/**

packages/spec/src/system/auth-config.zod.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,18 @@ export const AuthConfigSchema = lazySchema(() => z.object({
278278
emailAndPassword: EmailAndPasswordConfigSchema,
279279
emailVerification: EmailVerificationConfigSchema,
280280
advanced: AdvancedAuthConfigSchema,
281+
/**
282+
* SSO-only ("enforced") login mode. When `true`, the login UI hides the
283+
* local email/password form and self-registration so the team signs in via
284+
* the configured IdP only (cloud-as-IdP, or an external OIDC/SAML provider).
285+
* The break-glass password endpoint stays enabled — managed (IdP-provisioned)
286+
* users simply hold no local credential, while the env owner retains a
287+
* password escape hatch. Generic over the IdP; orthogonal to which providers
288+
* are wired. Self-host can also set this via `OS_AUTH_SSO_ONLY=true`.
289+
*/
290+
ssoOnlyMode: z.boolean().optional().describe(
291+
'SSO-only login: hide the local password form + self-registration (the break-glass password endpoint stays enabled)',
292+
),
281293
mutualTls: MutualTLSConfigSchema.optional().describe('Mutual TLS (mTLS) configuration'),
282294
}).catchall(z.unknown()));
283295

0 commit comments

Comments
 (0)