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
19 changes: 19 additions & 0 deletions .changeset/30300-keep-the-last-identity-provider.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@objectstack/plugin-auth': patch
---

fix(plugin-auth)!: deleting the last identity provider is refused, and a boot that serves no sign-in method says so (#30300)

Clause-②: yes (narrowing) — a request an administrator could make before is now refused: `POST /api/v1/auth/sso/delete-provider` (Setup's "Delete SSO Provider") on the last identity provider, while email and password is off and nothing else signs anyone in, answers `409 DELETE_RESTRICTED` (an existing standard code, the one an organization delete refused for the same reason answers), or `503 SERVICE_UNAVAILABLE` when the provider list cannot be read to tell. No new error code, setting or field. The widening half: `AuthManager` gains `setSsoProviderDeleteFloor()`, marked `@internal`, through which `AuthPlugin` hands it the judge. The boot report is a new `error` log line only; nothing about the boot's outcome changes.
Metadata-protocol: none
Skills-docs: none — no file under `skills/**` teaches deleting an SSO provider or the sign-in boot reports; `content/docs/deployment/self-hosting.mdx` gains "No sign-in method left" with the three doors and the recovery.

<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped: sys_sso_provider keeps its columns and the delete endpoint keeps its request shape; it now refuses only the delete that leaves no way to sign in. Nothing stored is rewritten, so there is nothing at rest for a migration to rewrite. -->

**BREAKING** accept-set narrowing at `POST /api/v1/auth/sso/delete-provider`: deleting the identity provider that is the last way to sign in is refused, where it was deleted before.

- **Before.** Email and password off, one SSO identity provider registered and nothing else in force. Setup → the provider → "Delete SSO Provider": `200 {"success":true}`, the row gone. From then on nobody could sign in, the administrator included (measured on a real boot).
- **Now.** That delete answers `409` with `code: DELETE_RESTRICTED`, `lastSignInMethod: enterprise_sso`, and a message in the caller's language (en, zh-CN, ja-JP, es-ES) saying to turn email and password back on, or register another identity provider or set up another sign-in method, first. Nothing is deleted. When the provider list cannot be read to tell, it answers `503 SERVICE_UNAVAILABLE` instead of letting the delete through.
- **Still allowed.** Deleting a provider while another one remains, while email and password is on, while enterprise SSO is switched off, or while another way in counts: a social or OIDC provider with a client ID and secret, phone-number sign-in, or a host sign-in handoff. An unknown provider is still the vendor's `404`, and a caller with no session the vendor's `401`; the vendor's own provider-access check still decides who may delete which provider.
- **The redeploy door.** A boot whose configuration no longer declares the last way in (for example a redeploy without the `GOOGLE_CLIENT_*` pair while email and password is off) still boots, as before, and now reports at `error` under the grep token `no_sign_in_method_at_boot`, naming the consequence and the recovery. It is a positive claim only: a provider table that cannot be read says nothing. Refusing such a boot is a later option, not done: a deployment that signs people in through a door the auth plugin cannot see must keep starting.
- **Already locked out.** Set `OS_AUTH_EMAIL_PASSWORD_ENABLED=true` and restart: the environment override wins over a stored `auth.email_password_enabled = false` and over the stack config, and email and password sign-in comes back.
22 changes: 22 additions & 0 deletions content/docs/deployment/self-hosting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,28 @@ logins too, or leave the directory empty until the first administrator exists.
A deployment that boots with zero human rows is the case both paths above
describe, and it needs none of this.

### No sign-in method left

Accounts are not enough if nothing serves a way to use them. With email and
password sign-in off, the deployment depends on its other sign-in methods: a
social or OIDC provider with a client ID and secret, enterprise SSO with at
least one registered identity provider, phone-number sign-in, or a host
sign-in handoff. Three doors used to remove the last of them without a word;
each now answers:

| Door | What happens now |
|:---|:---|
| The auth settings save turns email and password off, or removes Google, leaving nothing | Refused `400 SETTINGS_VALIDATION` at the field |
| Setup's **Delete SSO Provider** on the last identity provider (`POST /api/v1/auth/sso/delete-provider`) | Refused `409 DELETE_RESTRICTED`, nothing deleted; `503 SERVICE_UNAVAILABLE` when the provider list cannot be read to tell |
| A redeploy whose configuration no longer declares the last provider (for example, without the `GOOGLE_CLIENT_*` pair) | Boots, and reports at **error** under the grep token `no_sign_in_method_at_boot` |

The boot is not refused: a deployment that signs people in through a door the
auth plugin cannot see keeps starting. **Recovery:** set
`OS_AUTH_EMAIL_PASSWORD_ENABLED=true` and restart. The environment override
wins over a stored `auth.email_password_enabled = false` and over the stack
config, and email and password sign-in comes back. Or restore the sign-in
method the configuration no longer declares.

## Who may sign up — and what each posture tells a stranger

`auth.audience.posture` is the one declaration answering who may become a user
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ export interface AuthInstanceHost {
assertPhoneOtpSendAllowed: AuthManager['assertPhoneOtpSendAllowed'];
assertRemoveMemberPermitted: AuthManager['assertRemoveMemberPermitted'];
assertRevokeSessionIdentifiesRecord: AuthManager['assertRevokeSessionIdentifiesRecord'];
assertSsoProviderDeletable: AuthManager['assertSsoProviderDeletable'];
auditRefusedSignIn: AuthManager['auditRefusedSignIn'];
buildPluginList: AuthManager['buildPluginList'];
composeDatabaseHooks: AuthManager['composeDatabaseHooks'];
Expand Down Expand Up @@ -780,6 +781,7 @@ export async function createAuthInstance(this: AuthInstanceHost): Promise<Auth<a
assertPhoneOtpSendAllowed: (...a) => this.assertPhoneOtpSendAllowed(...a),
assertRemoveMemberPermitted: (...a) => this.assertRemoveMemberPermitted(...a),
assertRevokeSessionIdentifiesRecord: (...a) => this.assertRevokeSessionIdentifiesRecord(...a),
assertSsoProviderDeletable: (...a) => this.assertSsoProviderDeletable(...a),
get config() { return readConfig(); },
existingAddressSignUpAnswersSuccess: (...a) => this.existingAddressSignUpAnswersSuccess(...a),
hasExistingUserFor: (...a) => this.hasExistingUserFor(...a),
Expand Down
13 changes: 13 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager-before-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ import {
} from './last-local-credential.js';
import { ORGANIZATION_DELETE_PATH } from './organization-delete.js';
import { carriesPrincipalSession } from './principal-session-mint.js';
import { SSO_PROVIDER_DELETE_PATH } from './sign-in-method-floor.js';
import { snapshotRoleChangeGrants, UPDATE_MEMBER_ROLE_PATH } from './role-change-granted-set.js';
import { refuseVisitorResendWithoutLiveTransport } from './visitor-verification-resend.js';

Expand All @@ -91,6 +92,7 @@ export interface BeforeHookHost {
assertPhoneOtpSendAllowed: AuthManager['assertPhoneOtpSendAllowed'];
assertRemoveMemberPermitted: AuthManager['assertRemoveMemberPermitted'];
assertRevokeSessionIdentifiesRecord: AuthManager['assertRevokeSessionIdentifiesRecord'];
assertSsoProviderDeletable: AuthManager['assertSsoProviderDeletable'];
readonly config: AuthManager['config'];
existingAddressSignUpAnswersSuccess: AuthManager['existingAddressSignUpAnswersSuccess'];
hasExistingUserFor: AuthManager['hasExistingUserFor'];
Expand Down Expand Up @@ -308,6 +310,17 @@ export async function globalBeforeHook(
// fall through — the vendor's own provider-access check still runs
}

// ── [#30300] nor delete the last way to sign in ───────────────────
// With email and password off and one identity provider as the only
// other way in, Setup's "Delete SSO Provider" answered 200 and nobody
// could sign in again (measured on a real boot). Asked after the
// held-caller refusal above and before the vendor's first write; the
// sign-in method floor decides (`sign-in-method-floor.ts`).
if (ctx?.path === SSO_PROVIDER_DELETE_PATH) {
await this.assertSsoProviderDeletable(ctx);
// fall through — the vendor's own provider-access check still runs
}

// ── ADR-0135 D6 + ADR-0068 D4: registering an identity provider is ──
// a PLATFORM-OPERATOR action.
//
Expand Down
47 changes: 47 additions & 0 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ import type { TenancyService } from './tenancy-service.js';
import { OtpSendGuard, assertOtpCooldownSeconds } from './otp-send-guard.js';
import { assertPasswordResetEndsSessions } from './password-reset-ends-sessions.js';
import type { CounterStore } from './rate-limit-storage.js';
import type { SsoProviderDeleteRefusal } from './sign-in-method-floor.js';
import {
applyUserBan,
applyUserUnban,
Expand Down Expand Up @@ -1588,6 +1589,22 @@ export class AuthManager {
setOrganizationDeleteStandingJudge(judge: (organizationId: string) => Promise<void>): void {
this.organizationDeleteStandingJudge = judge;
}

/**
* [#30300] The sign-in method floor's question about an identity-provider
* delete (`sign-in-method-floor.ts` `ssoProviderDeleteRefusal`), handed over
* by `AuthPlugin`, which holds what the floor reads beyond this manager (the
* host's sign-in handoff, the boot's provider credentials). Absent on a
* manager no plugin composed, where nothing judges the delete.
*/
private ssoProviderDeleteFloor?: (providerId: string, locale?: string) => Promise<SsoProviderDeleteRefusal | undefined>;

/** @internal [#30300] See {@link ssoProviderDeleteFloor}; called by `AuthPlugin`. */
setSsoProviderDeleteFloor(
judge: (providerId: string, locale?: string) => Promise<SsoProviderDeleteRefusal | undefined>,
): void {
this.ssoProviderDeleteFloor = judge;
}
// ADR-0069 — cached "does any org require MFA" flag (per-org tightening).
// Refreshed lazily with a TTL so isAuthGateActive() stays synchronous + cheap.
private _orgMfaCache: { value: boolean; at: number } = { value: false, at: 0 };
Expand Down Expand Up @@ -1744,6 +1761,7 @@ export class AuthManager {
assertPhoneOtpSendAllowed: (...a) => this.assertPhoneOtpSendAllowed(...a),
assertRemoveMemberPermitted: (...a) => this.assertRemoveMemberPermitted(...a),
assertRevokeSessionIdentifiesRecord: (...a) => this.assertRevokeSessionIdentifiesRecord(...a),
assertSsoProviderDeletable: (...a) => this.assertSsoProviderDeletable(...a),
auditRefusedSignIn: (...a) => this.auditRefusedSignIn(...a),
buildPluginList: (...a) => this.buildPluginList(...a),
composeDatabaseHooks: (...a) => this.composeDatabaseHooks(...a),
Expand Down Expand Up @@ -5034,6 +5052,35 @@ export class AuthManager {
return assertOrganizationDeletable.call(host, ctx);
}

/**
* [#30300] `/sso/delete-provider` admission: refuse the delete that would
* remove the last way to sign in, before the vendor deletes anything
* ({@link ssoProviderDeleteFloor} decides; `409 DELETE_RESTRICTED`, or `503
* SERVICE_UNAVAILABLE` when the provider list cannot be read). Asked only of
* an authenticated caller with a `providerId`: what the vendor itself answers
* (no session, a malformed body) falls through to it. The refusal states a
* deployment-wide fact `/auth/config` already publishes (email and password
* off, what else signs anyone in), so it is not held back for a caller the
* vendor would refuse as not the provider's owner.
*/
private async assertSsoProviderDeletable(ctx: any): Promise<void> {
const judge = this.ssoProviderDeleteFloor;
if (!judge) return;
const providerId = typeof ctx?.body?.providerId === 'string' ? ctx.body.providerId : '';
if (!providerId) return;
const actor = await this.resolveActor(ctx);
if (!actor?.userId) return;
const header = acceptLanguageHeader(ctx);
const refusal = await judge(providerId, header ? preferredLocaleFromHeader(header) : undefined);
if (!refusal) return;
const { APIError } = await import('better-auth/api');
throw new APIError(refusal.status, {
code: refusal.code,
message: refusal.message,
lastSignInMethod: refusal.lastSignInMethod,
});
}

private assertRemoveMemberPermitted(ctx: any): Promise<void> {
// [#25224 cut 1] The body moved to `auth-manager-actor-guards.ts` (see `ActorGuardHost`).
const readOrgRolesMap = () => this.orgRolesMap;
Expand Down
78 changes: 57 additions & 21 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,14 @@ import {
reportIfNoSignInAccountExists,
type SignInPathConfigView,
} from './boot-sign-in-reachability.js';
import { providerCredentialIndex, providerCredentialsFrom, registerSignInMethodFloor } from './sign-in-method-floor.js';
import {
providerCredentialIndex,
providerCredentialsFrom,
registerSignInMethodFloor,
reportIfNoSignInMethodAtBoot,
ssoProviderDeleteRefusal,
type SignInMethodFloorDeps,
} from './sign-in-method-floor.js';
import { judgePlatformAdmin, isPlatformAdminUser, type PlatformAdminActor } from './platform-admin-gate.js';
import {
decideDemoStaffEnvironmentGate,
Expand Down Expand Up @@ -800,6 +807,12 @@ export class AuthPlugin implements Plugin {
// ADR-0092 D6 — remember the storage better-auth will actually use so the
// identity write guard can keep cached session snapshots coherent.
this.effectiveSecondaryStorage = authConfig.secondaryStorage;
// [#30300] Deleting the last identity provider is judged by the same
// sign-in method floor as the auth settings save (`sign-in-method-floor.ts`).
const floorDeps = this.signInMethodFloorDeps(ctx, this.authManager);
this.authManager.setSsoProviderDeleteFloor((providerId, locale) =>
ssoProviderDeleteRefusal(floorDeps, providerId, locale),
);

// Register auth service
ctx.registerService('auth', this.authManager);
Expand Down Expand Up @@ -1509,6 +1522,23 @@ export class AuthPlugin implements Plugin {
ctx.logger,
);
}

// [#30300, ruling A] "Is there ANY way to sign in?" — the question the
// account report above does not ask: a redeploy that drops the last
// identity provider while email and password is off boots a deployment
// that serves no sign-in method at all, and said nothing. Said at
// `error` in every posture, NEVER refused: a platform-SSO tenant that
// signs in through a door this plugin cannot see must keep booting.
// Judged after the auth settings are applied, by the same floor as the
// settings save (`sign-in-method-floor.ts`).
if (this.authManager) {
try {
await this.ensureAuthSettingsBound(ctx);
} catch {
// The binding reports its own failure; judge what is in force.
}
await reportIfNoSignInMethodAtBoot(this.signInMethodFloorDeps(ctx, this.authManager), ctx.logger);
}
});

// Dev-only: provision a known, loginable platform admin on an empty DB.
Expand Down Expand Up @@ -2013,6 +2043,31 @@ export class AuthPlugin implements Plugin {
return this.authSettingsBinding;
}

/**
* [#30202, #30300] The facts the sign-in method floor reads, live: the
* manager's advertisement, the data engine `sys_sso_provider` is read from,
* the host handoff declaration, and the boot credential index. One builder
* for the three doors that judge by it — the auth settings save, the delete
* of an identity provider, and the boot report — so they cannot drift apart.
*/
private signInMethodFloorDeps(ctx: PluginContext, manager: AuthManager): SignInMethodFloorDeps {
return {
publicConfig: () => manager.getPublicConfig(),
engine: () => {
try {
return ctx.getService<IDataEngine>('objectql');
} catch {
return undefined;
}
},
hostSignInHandoff: this.options.hostSignInHandoff === true,
stackConfiguredGoogle: () => !!this.configuredSocialProviders?.google,
// Read when judged: the index is set in init, after `auth:configure`.
providerHasCredentials: (provider) => providerCredentialsFrom(this.bootProviderCredentials)(provider),
env: () => ((globalThis as any)?.process?.env ?? {}) as Record<string, string | undefined>,
};
}

/**
* [#23367, ruling A] Record the API-key lifecycle policy's landing — the
* anchor of the inactivity clock for keys with no recorded use, and of the
Expand Down Expand Up @@ -2086,26 +2141,7 @@ export class AuthPlugin implements Plugin {

// The save door refuses turning off the last sign-in method, before the
// value is stored; the pass below would only apply it. See the module.
const floorManager = this.authManager;
registerSignInMethodFloor(
settings,
{
publicConfig: () => floorManager.getPublicConfig(),
engine: () => {
try {
return ctx.getService<IDataEngine>('objectql');
} catch {
return undefined;
}
},
hostSignInHandoff: this.options.hostSignInHandoff === true,
stackConfiguredGoogle: () => !!this.configuredSocialProviders?.google,
// Read at save time: the index is set in init, after `auth:configure`.
providerHasCredentials: (provider) => providerCredentialsFrom(this.bootProviderCredentials)(provider),
env: () => ((globalThis as any)?.process?.env ?? {}) as Record<string, string | undefined>,
},
ctx.logger,
);
registerSignInMethodFloor(settings, this.signInMethodFloorDeps(ctx, this.authManager), ctx.logger);

const applySettings = async (): Promise<void> => {
if (!this.authManager) return;
Expand Down
Loading
Loading