Skip to content

Commit 870ebce

Browse files
fix(plugin-auth)!: saving the auth settings refuses to turn off the last way to sign in (#30298)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wg4dtXyZ5LXdjKJLUCWRNk76)_ domain:server#2 · os-bill · session_01Utp6CXhZcHuALPAjkDCiiT ## 维护者速读 - 改了什么:保存认证设置时,按「这次保存写完以后」的状态判断:如果邮箱/密码登录关着,又没有任何其他能用的登录方式(凭据齐全的社交或 OIDC 登录、已登记身份提供方的企业 SSO、手机号登录、宿主声明的登录交接),这次保存被拒,整批都不写入,字段下用中英日西四语说明先配好哪一种。同一次保存里既关邮箱/密码又关 Google 会被拒;同一次保存里填好 Google 再关邮箱/密码可以通过。设置服务的写入谓词入参多了一个 `batch` 字段,里面是这次保存的全部键值。 - 为什么改:#30202 实测:没配好 SSO 就关掉邮箱/密码,提示「设置已保存」,下次退出后所有人(包括管理员)都登不进来,只能改环境变量或数据库。契约复审还指出,逐键判断时,一次同时关掉邮箱/密码和 Google 的保存会被放行。 - 风险与回滚:只在保存门加一道拒收,不改已存的值,不改登录页和 `/auth/config`。这是收窄:以前能存下的「关掉最后一种登录方式」现在存不下;`batch` 是可选的新增字段,已有谓词不用改。已经锁死的部署用 `OS_AUTH_EMAIL_PASSWORD_ENABLED=true` 重启即可恢复。回滚即撤本 PR。 - 先看:`packages/plugins/plugin-auth/src/sign-in-method-floor.ts` 的 `signInMethodFloorPredicate` 与 `googleAfter`(保存后的状态怎么算)、`otherSignInMethods`(哪些算其他登录方式);`service-settings` 的 `refuseByWritePredicates`(`batch` 从哪来)。 - 两分钟验证:`pnpm --filter @objectstack/plugin-auth exec vitest run src/sign-in-method-floor.test.ts` 预期 17/17;`pnpm --filter @objectstack/service-settings exec vitest run src/settings-write-predicate.test.ts` 预期 9/9。 - 需要你做的:无,席位落地。 Fixes #30202 Clause-②: yes (narrowing) — a save an administrator could make before is now refused at the settings save door with the existing `SETTINGS_VALIDATION` / `invalid_value` field error: a save after which email and password is off and nothing else signs anyone in, judged on the whole save, including the Google change that removes the last provider while email and password is off. No new error code or settings key. The widening half: `SettingsWritePredicateInput`, exported from `@objectstack/service-settings`, gains an optional `batch` field, the whole write a predicate is asked about. Metadata-protocol: none Skills-docs: none — no file under `skills/**` teaches `auth.email_password_enabled` or the settings write predicate; `content/docs/deployment/environment-variables.mdx` already names the `OS_AUTH_EMAIL_PASSWORD_ENABLED` override the recovery uses. ## What changes - **The refusal sits at the save door, before anything is written.** `bindAuthSettings` applies a value after it is stored, so a refusal there (the `refusable` pieces, #20412) would leave the console showing "saved" while the runtime ignores the value. The settings service already has the door for this: a write predicate (`SettingsService.registerWritePredicate`, the ADR-0144 D4 currency-lock seam), asked by `setMany` after the declared constraints and before any row or secret is written, refusing the whole batch with `SettingsValidationError` (`400 SETTINGS_VALIDATION`, `details.fields[]` ADR-0114 field errors). `AuthPlugin.bindAuthSettings` registers one predicate, from the new `sign-in-method-floor.ts`, on `auth.email_password_enabled`, `auth.google_enabled`, `auth.google_client_id` and `auth.google_client_secret`. - **The whole save is judged (`service-settings`).** `SettingsWritePredicateInput` gains `batch?: Readonly<Record<string, unknown>>`: every key the write sets in the namespace, the asked key among them, with the value it would store (`null` clears). `refuseByWritePredicates` builds it once from the patch, frozen, and passes it to every predicate it asks. Optional, so a caller that builds an input by hand and every existing predicate (the currency lock) are unchanged. It is reachable from the package entry (`index.ts` re-exports the type; the built `dist/index.d.ts` carries `batch?: Readonly<Record<string, unknown>>`), pinned by an `expectTypeOf` case that imports it from `./index.js`. - **The floor reads the state after the save.** Asked one key at a time against the configuration in force, `{ email_password_enabled: false, google_enabled: false }` on a deployment with email and password plus Google passed both keys, since each saw the other way in still standing, and wrote both (the review's must-fix). Now each key's value after the save is: the environment override when one is in force for it (it wins over what is saved); else the batch's value; else the value as it stands (the running configuration for `email_password_enabled`, the resolved setting for the `google_*` keys, read with `SettingsService.get`). A cleared key reads as its default (on, for both toggles), as `validatePatch`'s own post-write view does. Google after the save follows the rule the apply uses: off when `google_enabled` is off; else kept when declared at boot; else in force with a client ID and secret, each from the setting or its `GOOGLE_CLIENT_*` variable. A save that sets no `google_*` key leaves Google as it stands. - **Where the refusal lands.** At `email_password_enabled` when the save turns it off and nothing is in force after it; at a `google_*` key when, with email and password off after the save, the save removes a Google provider that was in force and leaves nothing else. The batch above is refused at both keys. - **What counts as another way in**, read live at save time from the running manager: the methods the login page still offers with email and password off, the same facts its "No sign-in method is available" state reads (`ui/packages/auth/src/LoginForm.tsx`, #29159), plus the one path the host states: - a social or OIDC provider in force (`getPublicConfig().socialProviders`) **whose client ID and client secret are both non-blank** in the configuration it came from (the review's should-fix). `AuthPlugin` indexes the providers declared at boot (stack config, the `GOOGLE_CLIENT_*` fallback, `auth:configure` handlers) with `providerCredentialIndex`; Google added from the settings has a pair by construction, since the apply adds it only with both. A provider listed without them draws a button that signs nobody in; - enterprise SSO switched on (`plugins.sso` / `OS_SSO_ENABLED`) AND at least one `sys_sso_provider` row, read with the boot report's `probeSsoProvidersPresence`. The switch alone signs nobody in, and an unreadable table proves no row, so neither counts; - phone-number sign-in (`features.phoneNumber`): better-auth's `/sign-in/phone-number` does not check `emailAndPassword.enabled`, and the code sign-in works when SMS can deliver; - a host sign-in handoff the host declared (`AuthPluginOptions.hostSignInHandoff`). - Not counted: `ssoOnlyMode` alone (it says an identity provider is in front; that provider is one of the above or nothing), and magic link and passkeys, which no login page offers (`PUBLIC_AUTH_FEATURES_NOT_ADVERTISED`, #7481). - **The refusal.** `code: invalid_value` at the key, `constraint: { lastSignInMethod: 'email_password' | 'google' }`, and a message in the caller's language (`en`, `zh-CN`, `ja-JP`, `es-ES`, the settings bundle's locales, picked with `resolveLocale` as the currency lock does) saying to keep or set up another sign-in method before or in that save. Turning email and password on, clearing it (its default is on), or re-saving a value in force is never refused. - **Recovery for a deployment already locked out.** No new variable: `OS_AUTH_EMAIL_PASSWORD_ENABLED=true` is the settings env override for the key, it wins over the stored `false` (source `env`), and the apply turns email and password back on at the next boot. Named in the changeset and pinned below; the refusal message speaks to the administrator in Setup, who is not locked out, so it names what to configure instead. - The edits in `auth-plugin.ts`: the import, the registration at the top of `bindAuthSettings`, and one field (`bootProviderCredentials`) set beside `configuredSocialProviders` after `auth:configure`, clear of the `mfa_required` and `allowed_ip_ranges` hunks and of `isExplicit`. - `scripts/engine-double-contract.pinned.json`: one row for the new test's `findOne` double, written by `check-engine-double-contract.mjs --write` (outside the claimed file surface; the gate requires it for a new test file with an engine double). - Changeset `.changeset/30202-keep-a-sign-in-method.md`: `@objectstack/plugin-auth` and `@objectstack/service-settings`, both `patch`. ## Tests `packages/plugins/plugin-auth/src/sign-in-method-floor.test.ts` (new, 17 cases). The real `SettingsService` (aliased to `service-settings/src` by this package's vitest config), the real `AuthPlugin` and `AuthManager`; a stub engine answers `sys_sso_provider` from each case's rows. 1. Email and password off with nothing else: `setMany` rejects with `SettingsValidationError` (`SETTINGS_VALIDATION`), one field error at `email_password_enabled` (`invalid_value`, `lastSignInMethod: email_password`); the stored value stays the default `true`, a sibling in the same batch (`password_min_length`) is not written, the manager keeps email and password on; turning it on is accepted. 2. The HTTP door, through `registerSettingsRoutes`: `PUT /api/settings/auth` with `Accept-Language: zh-CN` answers `400`, `error.code: SETTINGS_VALIDATION`, the field error at the key, its message the `zh-CN` copy. 3. Each way in that counts lets it turn off (stack-config GitHub, phone-number sign-in, host handoff, SSO with a registered provider; and Google saved on the page first). 4. The SSO switch with no provider row, and with a provider table that throws, is refused. 5. With email and password off and Google the last way in: `google_enabled: false`, `google_client_secret: null` and `google_client_id: ''` are each refused (`lastSignInMethod: google`), Google stays in force; with email and password back on, Google can go. 6. The same Google change is accepted where a stack-config GitHub keeps a way in, and a cleared secret that `GOOGLE_CLIENT_SECRET` still supplies is accepted and removes nothing. 7. Recovery: a `false` stored before the predicate exists rules at the next boot (control), and with `OS_AUTH_EMAIL_PASSWORD_ENABLED=true` in the settings env the manager has email and password on. 8. The whole save: `{ email_password_enabled: false, google_enabled: false }` and `{ email_password_enabled: false, google_client_secret: null }`, with email and password plus Google in force, are each refused with exactly two field errors (`email_password_enabled` / `email_password`, the Google key / `google`); nothing is written and both stay on. 9. Google filled in (`google_enabled`, client ID and secret) in the same save as `email_password_enabled: false` is accepted: email and password off, Google on. 10. `{ google_enabled: true, email_password_enabled: false }` over a client ID and secret already stored is accepted (the pair is read as stored). 11. `{ google_enabled: true, google_client_id, email_password_enabled: false }` with no secret anywhere is refused at `email_password_enabled`. 12. A stack-config GitHub listed with a blank secret is no way in: email and password off is refused. 13. `providerCredentialIndex` keys social and OIDC boot providers by whether both credentials are set. `packages/services/service-settings/src/settings-write-predicate.test.ts` (+2): `setMany` hands a predicate the whole write as `batch`, frozen, a clear as `null`; and `SettingsWritePredicateInput['batch']`, imported from the package entry, is `Readonly<Record<string, unknown>> | undefined`. ## Acceptance notes - **A cleared key reads as its default.** Clearing a key at organization scope can fall back to a global row rather than the default; the floor, like `validatePatch`'s post-write view, reads the default (on, for both toggles). Measured cases do not reach it; named so a review can weigh it. - **The ui half (`domain:ui`, the lane is off duty; for the seat to file).** The Setup page shows no confirmation before the switch and shows the refusal only as the field error the console's extractor already reads. The card also saw the login page draw the email and password form under `os dev`, with better-auth's English "Email and password is not enabled" on submit; at `797f9257` `LoginForm.tsx` has the no-method state from #29257 (`noSignInMethod`, :351), so the remaining shape is what the console dev server draws there. Not reproduced here. - **Other doors to the same lockout, not covered (read, not measured).** Deleting the last `sys_sso_provider` row, or a redeploy that drops the last stack-config provider or `plugins.phoneNumber`, while email and password is off. They are not settings saves, so this predicate cannot see them. - **Dogfood reds that were not this change.** On the first head, `permission-set-clone-door.dogfood.test.ts` was `main`'s red #30255 (reverted by #30262; red with the floor neutralized too). On this head, four `package-commit-revert-path-package.dogfood.test.ts` cases timed out at 5000 ms while the 73 gate families ran beside the battery; the same file alone under the lock passes 12/12. - `/auth/config` and the login page are unchanged: `emailPassword.enabled` reports the runtime truthfully. ## Verification Final head `0b89bdf1` (`main` `977be380` merged in, cleanly). `main` has three commits since, none touching `service-settings`, `plugin-auth` or the ledger; a trial merge is clean. <details><summary>Tests</summary> - `pnpm --filter @objectstack/plugin-auth exec vitest run src/sign-in-method-floor.test.ts`: 17/17. - `pnpm --filter @objectstack/service-settings exec vitest run src/settings-write-predicate.test.ts src/localization-currency-lock.test.ts`: 33/33. - Whole packages under the lock, `vitest run --maxWorkers=2`: `@objectstack/plugin-auth` Test Files 221 passed, Tests 3893 passed and 10 skipped; `@objectstack/service-settings` Test Files 67 passed, Tests 1153 passed. - `pnpm --filter @objectstack/service-settings typecheck` and `pnpm --filter @objectstack/plugin-auth typecheck` (both tsconfigs plus `check:test-typecheck`): exit 0. - `pnpm lint`: exit 0. - Full dogfood (owed by the `(narrowing)` arm), `pnpm --filter @objectstack/dogfood test` under the lock after `turbo run build --filter='!@objectstack/docs'`, on `0b89bdf1`: Test Files 418 passed, 1 failed, 3 skipped; Tests 3488 passed, 4 failed, 36 skipped; the lock's VERDICT command-exit 1. The four are `package-commit-revert-path-package.dogfood.test.ts` cases (#29390, #29414 ×3), each "Test timed out in 5000ms", run while the gate loop shared the box. The file alone under the lock on the same head: Tests 12 passed, VERDICT command-exit 0. </details> <details><summary>Ablations (each negative pin)</summary> `scripts/ablation-replace.mjs`, each anchor 1 to 0 on disk, each run restored to the HEAD blob with `git diff HEAD` empty. The test imports `./auth-plugin.js` and `./sign-in-method-floor.js` from `src`, and `@objectstack/service-settings` is aliased to its `src`, so no dist leg applies. - D, `setMany` passes no `batch`: service-settings 1 red (the batch case); plugin-auth 3 red (both-keys refusal, Google filled in the same save, switch over a stored pair). - E, the floor ignores `batch` (judges the asked key alone): 3 red (the same three). - F, a provider counts without its credential pair: 1 red (the blank-secret GitHub). - G, a key the save does not set reads as nothing instead of as stored: 2 red (the env-supplied secret case, the switch over a stored pair). - A, the email and password refusal returns nothing: 6 red. - B, the Google refusal returns nothing: 2 red (the last-provider case, the both-keys case). - C, the SSO switch counts without a provider row: 1 red. </details> <details><summary>Gates</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on `0b89bdf1`: 73 derived families (the first head's 72 plus `check:package-naming`, from the `service-settings` path). Each run with its exit code recorded before any pipe, after a full `turbo run build --filter='!@objectstack/docs'`; all 73 exit 0, and `--ran` answers 73 derived, 73 run, 0 NOT-MEASURED, 0 UNRUN. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic: `packages/spec` untouched. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: n/a. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the 维护者速读 block is Chinese by the Communication rule). --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CXhZcHuALPAjkDCiiT)_ <!-- signed: domain:server#2 · os-bill · session_01Utp6Cbvcmv8YWFhFJwzjKL --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent bc254be commit 870ebce

8 files changed

Lines changed: 910 additions & 2 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/plugin-auth': patch
3+
'@objectstack/service-settings': patch
4+
---
5+
6+
fix(plugin-auth)!: saving the auth settings refuses to turn off the last way to sign in (#30202)
7+
8+
Clause-②: yes (narrowing) — a save an administrator could make before is now refused at the settings save door with the existing `SETTINGS_VALIDATION` / `invalid_value` field error: a save after which email and password is off and nothing else signs anyone in, judged on the whole save, including the Google change that removes the last provider while email and password is off. No new error code or settings key. The widening half: `SettingsWritePredicateInput`, exported from `@objectstack/service-settings`, gains an optional `batch` field, the whole write a predicate is asked about.
9+
Metadata-protocol: none
10+
Skills-docs: none — no file under `skills/**` teaches `auth.email_password_enabled` or the settings write predicate; `content/docs/deployment/environment-variables.mdx` already names the `OS_AUTH_EMAIL_PASSWORD_ENABLED` override used for recovery below.
11+
12+
<!-- adr-0087: not-required (no-migration-prescription) No key of any schema is added, removed, renamed or re-shaped: auth.email_password_enabled and the auth.google_* keys keep their declarations, and the save door now refuses only the save that leaves no way to sign in. The predicate input's new batch field is optional and additive. A value stored before is not rewritten (the existing OS_AUTH_EMAIL_PASSWORD_ENABLED override recovers it), so there is nothing at rest for a migration to rewrite. -->
13+
14+
**BREAKING** accept-set narrowing at the auth settings save door (`PUT /api/settings/auth`, `SettingsService.setMany('auth', …)`): a save that would leave no way to sign in is refused, where it was saved before.
15+
16+
- **Before.** Setup → Settings → Authentication, "Enable email/password login" off, save: "Settings saved", with no social provider and SSO off. After the next sign-out nobody could sign in, the administrator included.
17+
- **Now.** A save after which email and password is off and no other way in is in force answers `400 SETTINGS_VALIDATION`. The field error sits at `email_password_enabled` (`code: invalid_value`, `constraint.lastSignInMethod: email_password`), and its message, in the caller's language (en, zh-CN, ja-JP, es-ES), says to keep or set up another sign-in method before or in that save. Nothing in that batch is written, and email and password stays on.
18+
- **The whole save is judged.** `{ email_password_enabled: false, google_enabled: false }` (or with `google_client_secret` cleared) on a deployment signing in with email and password plus Google is refused, with a field error at each of the two keys. Google filled in (switch, client ID and secret) in the same save that turns email and password off is accepted, and so is the switch alone over a client ID and secret already stored.
19+
- **What counts as another way in.** A social or OIDC provider in force whose client ID and client secret are both set; enterprise SSO switched on with at least one identity provider registered (the switch alone does not count); phone-number sign-in; a host sign-in handoff the host declared. `ssoOnlyMode` alone, magic link, passkeys, and a provider listed without its credentials do not count.
20+
- **The other direction.** While email and password is off, `google_enabled: false`, or clearing `google_client_id` / `google_client_secret` when no environment variable or stack config supplies it, is refused (`constraint.lastSignInMethod: google`) if Google is the last way in.
21+
- **Already locked out.** Set `OS_AUTH_EMAIL_PASSWORD_ENABLED=true` and restart: the environment override wins over the stored `false`, and email and password sign-in comes back. Then set up another method before turning it off again.
22+
- **For a write predicate's author (`@objectstack/service-settings`).** `SettingsWritePredicateInput.batch` holds every key the write sets in the namespace, the asked key among them, with the value it would store (`null` clears). `setMany` always passes it, frozen. A rule over several keys judges the state after the whole write from it instead of one key against the rest as they stand. Existing predicates need no change.

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

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ import {
109109
reportIfNoSignInAccountExists,
110110
type SignInPathConfigView,
111111
} from './boot-sign-in-reachability.js';
112+
import { providerCredentialIndex, providerCredentialsFrom, registerSignInMethodFloor } from './sign-in-method-floor.js';
112113
import { judgePlatformAdmin, isPlatformAdminUser, type PlatformAdminActor } from './platform-admin-gate.js';
113114
import {
114115
decideDemoStaffEnvironmentGate,
@@ -554,6 +555,8 @@ export class AuthPlugin implements Plugin {
554555
/** [#30203] Whether the running allow-list is the one a stored setting gave it. */
555556
private allowedIpRangesFromSettings = false;
556557
private configuredSocialProviders: SocialProviderConfig | undefined;
558+
/** Whether each provider declared at boot holds a credential pair (the sign-in floor's count). */
559+
private bootProviderCredentials: ReadonlyMap<string, boolean> = new Map();
557560
// ADR-0092 D6 — the EFFECTIVE better-auth secondaryStorage (host-supplied or
558561
// the kernel-cache adapter wired in init). The identity write guard's
559562
// session-snapshot refresh reads through this; undefined = refresh no-ops.
@@ -751,6 +754,7 @@ export class AuthPlugin implements Plugin {
751754
this.configuredSocialProviders = authConfig.socialProviders
752755
? { ...authConfig.socialProviders }
753756
: undefined;
757+
this.bootProviderCredentials = providerCredentialIndex(authConfig.socialProviders, authConfig.oidcProviders);
754758

755759
// [ADR-0105 D8] Lazy handle to plugin-security's `invitation-placement`
756760
// service. LAZY on purpose: plugin-security starts after plugin-auth, so
@@ -2079,6 +2083,29 @@ export class AuthPlugin implements Plugin {
20792083
}
20802084
if (!settings || typeof settings.getNamespace !== 'function') return;
20812085

2086+
// The save door refuses turning off the last sign-in method, before the
2087+
// value is stored; the pass below would only apply it. See the module.
2088+
const floorManager = this.authManager;
2089+
registerSignInMethodFloor(
2090+
settings,
2091+
{
2092+
publicConfig: () => floorManager.getPublicConfig(),
2093+
engine: () => {
2094+
try {
2095+
return ctx.getService<IDataEngine>('objectql');
2096+
} catch {
2097+
return undefined;
2098+
}
2099+
},
2100+
hostSignInHandoff: this.options.hostSignInHandoff === true,
2101+
stackConfiguredGoogle: () => !!this.configuredSocialProviders?.google,
2102+
// Read at save time: the index is set in init, after `auth:configure`.
2103+
providerHasCredentials: (provider) => providerCredentialsFrom(this.bootProviderCredentials)(provider),
2104+
env: () => ((globalThis as any)?.process?.env ?? {}) as Record<string, string | undefined>,
2105+
},
2106+
ctx.logger,
2107+
);
2108+
20822109
const applySettings = async (): Promise<void> => {
20832110
if (!this.authManager) return;
20842111
const authManager = this.authManager;

0 commit comments

Comments
 (0)