From 850f14ebd316a82b83ea2afaaaaf4e9dae673814 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 03:21:53 +0000 Subject: [PATCH 1/7] fix(plugin-auth): the auth settings save refuses turning off the last sign-in method Turning email and password off while no other sign-in method is in force used to save, and the next sign-out locked everyone out. The plugin now registers write predicates on the settings service for email_password_enabled and the Google keys, so such a save is refused before anything is written, with a localized reason naming what to set up first. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Utp6CXhZcHuALPAjkDCiiT --- .changeset/30202-keep-a-sign-in-method.md | 15 + .../plugins/plugin-auth/src/auth-plugin.ts | 22 ++ .../src/sign-in-method-floor.test.ts | 318 ++++++++++++++++++ .../plugin-auth/src/sign-in-method-floor.ts | 262 +++++++++++++++ 4 files changed, 617 insertions(+) create mode 100644 .changeset/30202-keep-a-sign-in-method.md create mode 100644 packages/plugins/plugin-auth/src/sign-in-method-floor.test.ts create mode 100644 packages/plugins/plugin-auth/src/sign-in-method-floor.ts diff --git a/.changeset/30202-keep-a-sign-in-method.md b/.changeset/30202-keep-a-sign-in-method.md new file mode 100644 index 00000000000..2bd46d59874 --- /dev/null +++ b/.changeset/30202-keep-a-sign-in-method.md @@ -0,0 +1,15 @@ +--- +'@objectstack/plugin-auth': patch +--- + +fix(plugin-auth): saving the auth settings refuses to turn off the last way to sign in (#30202) + +Clause-②: no (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: email and password off while nothing else signs anyone in, and, while email and password is already off, the Google change that removes the last provider. No new error code, export or settings key. Nothing is widened. +Metadata-protocol: none +Skills-docs: none — no file under `skills/**` teaches `auth.email_password_enabled`; `content/docs/deployment/environment-variables.mdx` already names the `OS_AUTH_EMAIL_PASSWORD_ENABLED` override used for recovery below. + +- **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. +- **Now.** `PUT /api/settings/auth` with `email_password_enabled: false` answers `400 SETTINGS_VALIDATION`, with a field error at `email_password_enabled` (`code: invalid_value`, `constraint.lastSignInMethod: email_password`) whose message, in the caller's language (en, zh-CN, ja-JP, es-ES), says to set up and save another sign-in method first. Nothing in that batch is written, and email and password stays on. +- **What counts as another way in.** A social or OIDC provider in force; 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 and passkeys do not count. +- **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. +- **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. diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 1a10724e0d2..1b534ace9ef 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -108,6 +108,7 @@ import { reportIfNoSignInAccountExists, type SignInPathConfigView, } from './boot-sign-in-reachability.js'; +import { registerSignInMethodFloor } from './sign-in-method-floor.js'; import { judgePlatformAdmin, isPlatformAdminUser, type PlatformAdminActor } from './platform-admin-gate.js'; import { decideDemoStaffEnvironmentGate, @@ -2047,6 +2048,27 @@ export class AuthPlugin implements Plugin { } if (!settings || typeof settings.getNamespace !== 'function') return; + // 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('objectql'); + } catch { + return undefined; + } + }, + hostSignInHandoff: this.options.hostSignInHandoff === true, + stackConfiguredGoogle: () => !!this.configuredSocialProviders?.google, + env: () => ((globalThis as any)?.process?.env ?? {}) as Record, + }, + ctx.logger, + ); + const applySettings = async (): Promise => { if (!this.authManager) return; const authManager = this.authManager; diff --git a/packages/plugins/plugin-auth/src/sign-in-method-floor.test.ts b/packages/plugins/plugin-auth/src/sign-in-method-floor.test.ts new file mode 100644 index 00000000000..2eb9ad4d8ba --- /dev/null +++ b/packages/plugins/plugin-auth/src/sign-in-method-floor.test.ts @@ -0,0 +1,318 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The auth settings save keeps a way to sign in (`sign-in-method-floor.ts`). + * + * Through the REAL settings service (`@objectstack/service-settings`, aliased + * to its source) and the real `AuthPlugin` / `AuthManager`: the plugin + * registers the write predicates when it binds the `auth` namespace, and a + * save goes through `setMany`, or through the real `PUT /api/settings/:ns` + * route for the envelope. The data engine is a stub that answers + * `sys_sso_provider` reads from the case's rows and `[]` for everything else. + * + * Pinned: + * 1. email and password off with nothing else in force is refused before + * anything is written: the stored value and the runtime stay on, and the + * batch it rode in writes nothing; + * 2. the HTTP door answers 400 `SETTINGS_VALIDATION` with the field error, in + * the caller's language; + * 3. each way in that counts lets it turn off; the SSO switch alone, or an + * unreadable provider table, does not; + * 4. with email and password off, the Google change that removes the last + * provider is refused, and the same change is accepted where something + * else keeps a way in; + * 5. recovery: a stored `false` from before this check is overridden by + * `OS_AUTH_EMAIL_PASSWORD_ENABLED=true` (control: without it, it rules). + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { PluginContext } from '@objectstack/core'; +import type { IHttpRequest, IHttpResponse, IHttpServer, RouteHandler } from '@objectstack/spec/contracts'; +import { assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/objectql'; +import { + InMemoryCryptoProvider, + SettingsService, + SettingsValidationError, + builtinSettingsManifests, + registerSettingsRoutes, +} from '@objectstack/service-settings'; +import { AuthPlugin } from './auth-plugin.js'; +import type { AuthManager } from './auth-manager.js'; +import { SSO_PROVIDER_OBJECT } from './sso-client-secret.js'; +import { SIGN_IN_FLOOR_COPY } from './sign-in-method-floor.js'; + +const SECRET = 'test-secret-at-least-32-chars-long'; + +const ENV_KEYS = [ + 'OS_AUTH_EMAIL_PASSWORD_ENABLED', + 'OS_AUTH_GOOGLE_ENABLED', + 'GOOGLE_CLIENT_ID', + 'GOOGLE_CLIENT_SECRET', + 'OS_SSO_ENABLED', + 'OS_AUTH_SSO_ONLY', + 'OS_SCIM_ENABLED', +] as const; + +const GOOGLE = { + google_enabled: true, + google_client_id: 'google-settings-client-id', + google_client_secret: 'google-settings-client-secret', +}; + +/** The error `setMany` refused with, or a failure naming what happened instead. */ +async function refusalOf(write: Promise): Promise { + const outcome = await write.then( + () => undefined, + (err: unknown) => err, + ); + expect(outcome, 'the save went through').toBeInstanceOf(SettingsValidationError); + return outcome as SettingsValidationError; +} + +describe('the auth settings save keeps a way to sign in', () => { + const saved: Partial> = {}; + let ssoRows: () => Promise; + + beforeEach(() => { + for (const key of ENV_KEYS) { + saved[key] = process.env[key]; + delete process.env[key]; + } + ssoRows = async () => []; + }); + + afterEach(() => { + for (const key of ENV_KEYS) { + if (saved[key] === undefined) delete process.env[key]; + else process.env[key] = saved[key]; + } + }); + + const makeEngine = () => ({ + insert: vi.fn(async (_object: string, row: any) => row), + find: vi.fn(async (object: string) => (object === SSO_PROVIDER_OBJECT ? ssoRows() : [])), + findOne: vi.fn(async (object: string, query?: EngineFindOneQueryInput) => { + assertEngineFindOnePredicate(object, query); + return null; + }), + }); + + const makeSettings = (env: Record = {}) => { + // A crypto provider, so the Google client secret can be stored. + const settings = new SettingsService({ env, cryptoProvider: new InMemoryCryptoProvider() }); + for (const manifest of builtinSettingsManifests) { + if (manifest.namespace === 'auth') settings.registerManifest(manifest); + } + return settings; + }; + + /** Boot as a host does: init → start → kernel:ready (the auth namespace binds there). */ + const boot = async (opts: { settings?: SettingsService; pluginOptions?: Record } = {}) => { + const settings = opts.settings ?? makeSettings(); + const engine = makeEngine(); + const hooks = new Map Promise>>(); + const ctx = { + registerService: vi.fn(), + getService: vi.fn((name: string) => { + if (name === 'manifest') return { register: vi.fn() }; + if (name === 'settings') return settings; + if (name === 'data' || name === 'objectql') return engine; + return undefined; + }), + getServices: vi.fn(() => new Map()), + hook: vi.fn((name: string, handler: () => Promise) => { + if (!hooks.has(name)) hooks.set(name, []); + hooks.get(name)!.push(handler); + }), + trigger: vi.fn(), + logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() }, + getKernel: vi.fn(), + } as unknown as PluginContext; + const plugin = new AuthPlugin({ secret: SECRET, baseUrl: 'http://localhost:3000', ...(opts.pluginOptions ?? {}) }); + await plugin.init(ctx); + const manager = (ctx.registerService as any).mock.calls.find(([name]: [string]) => name === 'auth')?.[1] as AuthManager; + await plugin.start(ctx); + for (const handler of hooks.get('kernel:ready') ?? []) await handler(); + return { settings, manager }; + }; + + const passwordOn = (manager: AuthManager) => manager.getPublicConfig().emailPassword.enabled; + const googleOn = (manager: AuthManager) => + manager.getPublicConfig().socialProviders.some((p: { id: string }) => p.id === 'google'); + + /** Save Google through the console and wait for the pass to apply it. */ + const saveGoogle = async (settings: SettingsService, manager: AuthManager) => { + await settings.setMany('auth', GOOGLE); + await vi.waitFor(() => expect(googleOn(manager)).toBe(true)); + }; + + // ── 1 + 2. The refusal ───────────────────────────────────────────────────── + + it('refuses email and password off when nothing else signs anyone in; nothing in the batch is written', async () => { + const { settings, manager } = await boot(); + + const refusal = await refusalOf( + settings.setMany('auth', { email_password_enabled: false, password_min_length: 12 }), + ); + expect(refusal.code).toBe('SETTINGS_VALIDATION'); + expect(refusal.fields).toEqual([ + expect.objectContaining({ + field: 'email_password_enabled', + code: 'invalid_value', + constraint: { lastSignInMethod: 'email_password' }, + }), + ]); + + expect(await settings.get('auth', 'email_password_enabled')).toMatchObject({ value: true, source: 'default' }); + expect(await settings.get('auth', 'password_min_length')).toMatchObject({ source: 'default' }); + expect(passwordOn(manager)).toBe(true); + // Turning it back on, or saving the value in force, is never refused. + await expect(settings.setMany('auth', { email_password_enabled: true })).resolves.toBeDefined(); + }); + + it('the settings PUT door answers 400 SETTINGS_VALIDATION at the key, in the caller language', async () => { + const { settings, manager } = await boot(); + const routes = new Map(); + const add = (method: string) => (path: string, handler: RouteHandler) => { + routes.set(`${method} ${path}`, handler); + return http; + }; + const http = { + get: add('GET'), + post: add('POST'), + put: add('PUT'), + delete: add('DELETE'), + patch: add('PATCH'), + use: () => http, + listen: async () => {}, + close: async () => {}, + getInstance: () => null, + } as unknown as IHttpServer; + registerSettingsRoutes(http, settings, { + contextFromRequest: () => ({ enforced: true, permissions: ['manage_platform_settings'], userId: 'usr_admin' }), + }); + + const state: { status: number; body?: any } = { status: 200 }; + const res = { + json: vi.fn((data: unknown) => { + state.body = data; + }), + send: vi.fn(), + status: vi.fn((code: number) => { + state.status = code; + return res; + }), + header: vi.fn(() => res), + } as unknown as IHttpResponse; + const req = { + params: { namespace: 'auth' }, + query: {}, + body: { email_password_enabled: false }, + headers: { 'accept-language': 'zh-CN,zh;q=0.9' }, + method: 'PUT', + path: '/api/settings/auth', + } as unknown as IHttpRequest; + await routes.get('PUT /api/settings/:namespace')!(req, res); + + expect(state.status).toBe(400); + expect(state.body.error.code).toBe('SETTINGS_VALIDATION'); + const [field] = state.body.error.details.fields; + expect(field).toMatchObject({ field: 'email_password_enabled', code: 'invalid_value' }); + expect(field.message).toBe(SIGN_IN_FLOOR_COPY['zh-CN'].emailPassword); + expect(passwordOn(manager)).toBe(true); + }); + + // ── 3. What counts ───────────────────────────────────────────────────────── + + const COUNTED: Array<{ name: string; pluginOptions: Record; rows?: unknown[] }> = [ + { name: 'a social provider in the stack config', pluginOptions: { socialProviders: { github: { clientId: 'gh-id', clientSecret: 'gh-secret' } } } }, + { name: 'phone-number sign-in', pluginOptions: { plugins: { phoneNumber: true } } }, + { name: 'a host sign-in handoff', pluginOptions: { hostSignInHandoff: true } }, + { name: 'enterprise SSO with an identity provider registered', pluginOptions: { plugins: { sso: true } }, rows: [{ id: 'sso_1' }] }, + ]; + + it.each(COUNTED)('$name lets email and password turn off', async ({ pluginOptions, rows = [] }) => { + ssoRows = async () => rows; + const { settings, manager } = await boot({ pluginOptions }); + + await settings.setMany('auth', { email_password_enabled: false }); + await vi.waitFor(() => expect(passwordOn(manager)).toBe(false)); + }); + + it('Google saved on the same page first lets email and password turn off', async () => { + const { settings, manager } = await boot(); + await saveGoogle(settings, manager); + + await settings.setMany('auth', { email_password_enabled: false }); + await vi.waitFor(() => expect(passwordOn(manager)).toBe(false)); + }); + + it('the SSO switch with no identity provider registered, or an unreadable provider table, does not count', async () => { + const { settings, manager } = await boot({ pluginOptions: { plugins: { sso: true } } }); + await refusalOf(settings.setMany('auth', { email_password_enabled: false })); + + ssoRows = async () => { + throw new Error('store unavailable'); + }; + await refusalOf(settings.setMany('auth', { email_password_enabled: false })); + expect(passwordOn(manager)).toBe(true); + }); + + // ── 4. The last provider, while email and password is off ───────────────── + + it('refuses removing Google when it is the last way in: switched off, or a credential cleared', async () => { + const { settings, manager } = await boot(); + await saveGoogle(settings, manager); + await settings.setMany('auth', { email_password_enabled: false }); + await vi.waitFor(() => expect(passwordOn(manager)).toBe(false)); + + for (const patch of [{ google_enabled: false }, { google_client_secret: null }, { google_client_id: '' }]) { + const refusal = await refusalOf(settings.setMany('auth', patch)); + expect(refusal.fields).toEqual([ + expect.objectContaining({ code: 'invalid_value', constraint: { lastSignInMethod: 'google' } }), + ]); + } + expect(googleOn(manager)).toBe(true); + + // With email and password back on, Google may go. + await settings.setMany('auth', { email_password_enabled: true }); + await vi.waitFor(() => expect(passwordOn(manager)).toBe(true)); + await settings.setMany('auth', { google_enabled: false }); + await vi.waitFor(() => expect(googleOn(manager)).toBe(false)); + }); + + it('accepts the same Google change where something else keeps a way in, or the env still supplies it', async () => { + const withGithub = await boot({ + pluginOptions: { socialProviders: { github: { clientId: 'gh-id', clientSecret: 'gh-secret' } } }, + }); + await saveGoogle(withGithub.settings, withGithub.manager); + await withGithub.settings.setMany('auth', { email_password_enabled: false }); + await vi.waitFor(() => expect(passwordOn(withGithub.manager)).toBe(false)); + await withGithub.settings.setMany('auth', { google_enabled: false }); + await vi.waitFor(() => expect(googleOn(withGithub.manager)).toBe(false)); + + // A cleared secret the env variable still supplies removes nothing. + const alone = await boot(); + await saveGoogle(alone.settings, alone.manager); + await alone.settings.setMany('auth', { email_password_enabled: false }); + await vi.waitFor(() => expect(passwordOn(alone.manager)).toBe(false)); + process.env.GOOGLE_CLIENT_SECRET = 'google-env-client-secret'; + await alone.settings.setMany('auth', { google_client_secret: null }); + expect(googleOn(alone.manager)).toBe(true); + }); + + // ── 5. Recovery for a deployment that already stored the dead end ───────── + + it('OS_AUTH_EMAIL_PASSWORD_ENABLED=true overrides a stored false (control: without it, the stored false rules)', async () => { + // The pre-fix save: written before any predicate is registered. + const lockedOut = makeSettings(); + await lockedOut.setMany('auth', { email_password_enabled: false }); + expect(passwordOn((await boot({ settings: lockedOut })).manager)).toBe(false); + + const env: Record = {}; + const recovered = makeSettings(env); + await recovered.setMany('auth', { email_password_enabled: false }); + env.OS_AUTH_EMAIL_PASSWORD_ENABLED = 'true'; + expect(passwordOn((await boot({ settings: recovered })).manager)).toBe(true); + }); +}); diff --git a/packages/plugins/plugin-auth/src/sign-in-method-floor.ts b/packages/plugins/plugin-auth/src/sign-in-method-floor.ts new file mode 100644 index 00000000000..115908f83d0 --- /dev/null +++ b/packages/plugins/plugin-auth/src/sign-in-method-floor.ts @@ -0,0 +1,262 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The auth settings save keeps a way to sign in. + * + * Measured at `main` `b7075d9908`: Setup → Settings → Authentication, "Enable + * email/password login" off, save — "Settings saved", while `socialProviders` + * was empty and SSO off. From then on `POST /sign-in/email` answers 400 + * `EMAIL_PASSWORD_DISABLED` and the login page offers nothing that works, so + * the next sign-out locks everyone out, the administrator included. + * + * `bindAuthSettings` applies a value AFTER it is stored, so a refusal there + * leaves the console showing a saved value the runtime ignores. The refusal is + * made at the save door instead: the settings service's write predicate + * (`registerWritePredicate`), asked before any row is written. It refuses the + * whole batch with `SETTINGS_VALIDATION` and an `invalid_value` field error at + * the key, whose message says what to set up first. + * + * ## What counts as another way in + * + * What the login page still offers with email and password off — the facts its + * "no sign-in method is available" state reads — plus the one path the host + * states: + * + * - a social or OIDC provider in force (`getPublicConfig().socialProviders`); + * - enterprise SSO switched on AND at least one `sys_sso_provider` row. The + * switch alone signs nobody in, and a store that cannot be read proves no + * row, so neither counts ({@link probeSsoProvidersPresence}); + * - phone-number sign-in (`features.phoneNumber`): better-auth serves phone + * plus password with email and password off, and the code sign-in when SMS + * can deliver; + * - a host sign-in handoff the host declared (`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`). + * + * ## The changes refused + * + * - `email_password_enabled` off while nothing else is in force; + * - while email and password is already off, the Google change that removes + * the last provider: `google_enabled` off, or clearing a client credential + * that no environment variable and no stack config backs. + * + * Each is judged on its own key against the RUNNING configuration (a predicate + * sees one key), so a provider saved in the same batch does not count yet: the + * message says to save it first. + * + * A deployment that stored the dead-end state before this check existed gets + * back in with `OS_AUTH_EMAIL_PASSWORD_ENABLED=true`, the settings environment + * override, which wins over the stored value, and a restart. + */ + +import { resolveLocale } from '@objectstack/core'; +import { probeSsoProvidersPresence, type BootProbeEngine } from './boot-sign-in-reachability.js'; + +/** A way in other than email and password, as {@link otherSignInMethods} names it. */ +export type OtherSignInMethod = 'social_provider' | 'enterprise_sso' | 'phone_number' | 'host_handoff'; + +/** The subset of `AuthManager.getPublicConfig()` read here. */ +export interface SignInMethodConfigView { + emailPassword?: { enabled?: boolean }; + socialProviders?: Array<{ id?: string; type?: string; enabled?: boolean }>; + features?: { sso?: boolean; phoneNumber?: boolean }; +} + +/** Live readers, asked at save time, never recorded at boot. */ +export interface SignInMethodFloorDeps { + publicConfig(): SignInMethodConfigView; + /** The data engine `sys_sso_provider` is read from; `undefined` proves no row. */ + engine(): BootProbeEngine | undefined; + hostSignInHandoff: boolean; + /** + * Whether Google was declared at boot (stack config, or both `GOOGLE_CLIENT_*` + * variables), so no settings credential is what keeps it. + */ + stackConfiguredGoogle(): boolean; + env(): Record; +} + +/** + * The ways in besides email and password that are in force now. `withoutGoogle` + * leaves the settings-managed Google provider out, to judge a change that + * removes it. + */ +export async function otherSignInMethods( + deps: SignInMethodFloorDeps, + options: { withoutGoogle?: boolean } = {}, +): Promise { + const config = deps.publicConfig(); + const methods: OtherSignInMethod[] = []; + const providers = (config.socialProviders ?? []).filter( + (p) => p?.enabled !== false && !(options.withoutGoogle && p?.id === 'google' && p?.type !== 'oidc'), + ); + if (providers.length > 0) methods.push('social_provider'); + if (config.features?.phoneNumber === true) methods.push('phone_number'); + if (deps.hostSignInHandoff) methods.push('host_handoff'); + if (config.features?.sso === true && (await probeSsoProvidersPresence(deps.engine())) === 'present') { + methods.push('enterprise_sso'); + } + return methods; +} + +// ── Copy ───────────────────────────────────────────────────────────────── + +interface SignInFloorCopy { + emailPassword: string; + google: string; +} + +/** One entry per locale the settings bundle ships, as the currency lock's copy. */ +export const SIGN_IN_FLOOR_COPY: Record<'en' | 'zh-CN' | 'ja-JP' | 'es-ES', SignInFloorCopy> = { + en: { + emailPassword: + 'Email and password is the only way to sign in here, so turning it off would lock everyone out, ' + + 'administrators included. First set up and save another sign-in method: Google sign-in on this page ' + + '(client ID and secret), another social or OIDC provider, an enterprise SSO identity provider, or ' + + 'phone-number sign-in. Then turn this off.', + google: + 'With email and password off, Google is the only way to sign in here, so this change would lock ' + + 'everyone out, administrators included. First turn email and password sign-in back on, or set up ' + + 'another sign-in method.', + }, + 'zh-CN': { + emailPassword: + '邮箱/密码是目前唯一的登录方式,关闭后所有人(包括管理员)都将无法登录。请先配置并保存另一种登录方式:' + + '本页的 Google 登录(客户端 ID 和密钥)、其他社交或 OIDC 登录、企业 SSO 身份提供方,或手机号登录,然后再关闭此项。', + google: + '邮箱/密码登录已关闭,Google 是目前唯一的登录方式,这项修改会让所有人(包括管理员)都无法登录。' + + '请先重新开启邮箱/密码登录,或配置另一种登录方式。', + }, + 'ja-JP': { + emailPassword: + 'メール/パスワードは現在唯一のログイン方法のため、オフにすると管理者を含む全員がログインできなくなります。' + + '先に別のログイン方法を設定して保存してください(このページの Google ログイン(クライアント ID とシークレット)、' + + '他のソーシャルまたは OIDC プロバイダー、エンタープライズ SSO の ID プロバイダー、または電話番号ログイン)。' + + 'その後でオフにしてください。', + google: + 'メール/パスワードログインがオフのため、Google が唯一のログイン方法です。この変更を行うと管理者を含む全員が' + + 'ログインできなくなります。先にメール/パスワードログインをオンに戻すか、別のログイン方法を設定してください。', + }, + 'es-ES': { + emailPassword: + 'El correo y la contraseña son la única forma de iniciar sesión aquí, así que desactivarlos dejaría fuera ' + + 'a todo el mundo, incluidos los administradores. Configure y guarde primero otro método de inicio de sesión: ' + + 'el inicio de sesión con Google de esta página (ID y secreto de cliente), otro proveedor social u OIDC, un ' + + 'proveedor de identidad de SSO empresarial o el inicio de sesión con número de teléfono. Después, desactive ' + + 'esta opción.', + google: + 'Con el correo y la contraseña desactivados, Google es la única forma de iniciar sesión aquí, así que este ' + + 'cambio dejaría fuera a todo el mundo, incluidos los administradores. Vuelva a activar primero el inicio de ' + + 'sesión con correo y contraseña o configure otro método.', + }, +}; + +const COPY_LOCALES = Object.keys(SIGN_IN_FLOOR_COPY); + +/** The copy for a caller's locale: exact, then by language, else English. */ +export function signInFloorCopy(locale: string | undefined): SignInFloorCopy { + const picked = locale ? resolveLocale(locale, COPY_LOCALES) : undefined; + return SIGN_IN_FLOOR_COPY[(picked ?? 'en') as keyof typeof SIGN_IN_FLOOR_COPY]; +} + +// ── The predicates ─────────────────────────────────────────────────────── + +/** What the settings service asks a write predicate (the fields read here). */ +export interface SignInFloorWriteInput { + key: string; + next: unknown; + locale?: string; +} + +/** A refusal, in the settings service's `SettingsWriteRefusal` shape. */ +export interface SignInFloorRefusal { + message: string; + constraint: { lastSignInMethod: 'email_password' | 'google' }; +} + +/** The same reading `bindAuthSettings` gives a stored toggle. */ +function readToggle(value: unknown, fallback: boolean): boolean { + if (typeof value === 'boolean') return value; + if (typeof value === 'string') return value.toLowerCase() !== 'false'; + if (typeof value === 'number') return value !== 0; + return fallback; +} + +const isBlank = (value: unknown): boolean => + value === null || value === undefined || (typeof value === 'string' && value.trim() === ''); + +/** The keys guarded, each with the env variable `bindAuthSettings` falls back to. */ +const GOOGLE_CREDENTIAL_ENV: Record = { + google_client_id: 'GOOGLE_CLIENT_ID', + google_client_secret: 'GOOGLE_CLIENT_SECRET', +}; + +export const SIGN_IN_FLOOR_KEYS = ['email_password_enabled', 'google_enabled', ...Object.keys(GOOGLE_CREDENTIAL_ENV)]; + +/** + * Would this write remove the settings-managed Google provider? `google_enabled` + * off removes it whatever declared it; a cleared credential removes it unless + * the stack config declares Google or the env variable supplies the credential. + */ +function removesGoogle(deps: SignInMethodFloorDeps, key: string, next: unknown): boolean { + if (key === 'google_enabled') return !readToggle(next, true); + if (!isBlank(next) || deps.stackConfiguredGoogle()) return false; + return isBlank(deps.env()[GOOGLE_CREDENTIAL_ENV[key]!]); +} + +/** The write predicate shared by {@link SIGN_IN_FLOOR_KEYS}. */ +export function signInMethodFloorPredicate(deps: SignInMethodFloorDeps) { + return async ({ key, next, locale }: SignInFloorWriteInput): Promise => { + if (key === 'email_password_enabled') { + if (readToggle(next, true)) return undefined; + if ((await otherSignInMethods(deps)).length > 0) return undefined; + return { message: signInFloorCopy(locale).emailPassword, constraint: { lastSignInMethod: 'email_password' } }; + } + const config = deps.publicConfig(); + if (config.emailPassword?.enabled !== false) return undefined; + const googleInForce = (config.socialProviders ?? []).some((p) => p?.id === 'google' && p?.type !== 'oidc'); + if (!googleInForce || !removesGoogle(deps, key, next)) return undefined; + if ((await otherSignInMethods(deps, { withoutGoogle: true })).length > 0) return undefined; + return { message: signInFloorCopy(locale).google, constraint: { lastSignInMethod: 'google' } }; + }; +} + +/** The settings service surface this registers on (structural: no package edge). */ +interface WritePredicateHost { + registerWritePredicate( + namespace: string, + key: string, + predicate: (input: SignInFloorWriteInput) => Promise, + ): void; +} + +/** + * Register the floor on the `settings` service. A service without write + * predicates cannot refuse the save, which is said at `warn`; a key the service + * does not carry cannot be saved, so there is nothing to guard and nothing said. + */ +export function registerSignInMethodFloor( + settings: unknown, + deps: SignInMethodFloorDeps, + logger: { warn(message: string): void }, +): void { + const host = settings as Partial | undefined; + if (typeof host?.registerWritePredicate !== 'function') { + logger.warn( + '[auth] the settings service takes no write predicates, so saving the auth settings cannot refuse turning ' + + 'off the last sign-in method (auth.email_password_enabled, auth.google_*). Check another sign-in method ' + + 'is in force before turning email and password off.', + ); + return; + } + const predicate = signInMethodFloorPredicate(deps); + for (const key of SIGN_IN_FLOOR_KEYS) { + try { + host.registerWritePredicate('auth', key, predicate); + } catch { + // No `auth` namespace or no such key on this service: nothing saves it. + } + } +} From 4165af1004b062674819752b951e0511338770cf Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 03:25:16 +0000 Subject: [PATCH 2/7] test(plugin-auth): store the Google client secret through a confidential adapter Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Utp6CXhZcHuALPAjkDCiiT --- .../src/sign-in-method-floor.test.ts | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/plugins/plugin-auth/src/sign-in-method-floor.test.ts b/packages/plugins/plugin-auth/src/sign-in-method-floor.test.ts index 2eb9ad4d8ba..997a2b264e1 100644 --- a/packages/plugins/plugin-auth/src/sign-in-method-floor.test.ts +++ b/packages/plugins/plugin-auth/src/sign-in-method-floor.test.ts @@ -30,11 +30,11 @@ import type { PluginContext } from '@objectstack/core'; import type { IHttpRequest, IHttpResponse, IHttpServer, RouteHandler } from '@objectstack/spec/contracts'; import { assertEngineFindOnePredicate, type EngineFindOneQueryInput } from '@objectstack/objectql'; import { - InMemoryCryptoProvider, SettingsService, SettingsValidationError, builtinSettingsManifests, registerSettingsRoutes, + type CryptoAdapter, } from '@objectstack/service-settings'; import { AuthPlugin } from './auth-plugin.js'; import type { AuthManager } from './auth-manager.js'; @@ -43,6 +43,20 @@ import { SIGN_IN_FLOOR_COPY } from './sign-in-method-floor.js'; const SECRET = 'test-secret-at-least-32-chars-long'; +/** Declares confidentiality, so the settings service stores the Google client secret. */ +class ConfidentialTestAdapter implements CryptoAdapter { + readonly confidential = true; + async encrypt(plaintext: string): Promise { + return 'kms:' + Buffer.from(plaintext, 'utf8').toString('base64'); + } + async decrypt(ciphertext: string): Promise { + return Buffer.from(ciphertext.replace(/^kms:/, ''), 'base64').toString('utf8'); + } + digest(): string { + return 'fnv32:deadbeef'; + } +} + const ENV_KEYS = [ 'OS_AUTH_EMAIL_PASSWORD_ENABLED', 'OS_AUTH_GOOGLE_ENABLED', @@ -98,8 +112,7 @@ describe('the auth settings save keeps a way to sign in', () => { }); const makeSettings = (env: Record = {}) => { - // A crypto provider, so the Google client secret can be stored. - const settings = new SettingsService({ env, cryptoProvider: new InMemoryCryptoProvider() }); + const settings = new SettingsService({ env, crypto: new ConfidentialTestAdapter() }); for (const manifest of builtinSettingsManifests) { if (manifest.namespace === 'auth') settings.registerManifest(manifest); } @@ -298,6 +311,8 @@ describe('the auth settings save keeps a way to sign in', () => { await vi.waitFor(() => expect(passwordOn(alone.manager)).toBe(false)); process.env.GOOGLE_CLIENT_SECRET = 'google-env-client-secret'; await alone.settings.setMany('auth', { google_client_secret: null }); + expect(await alone.settings.get('auth', 'google_client_secret')).toMatchObject({ source: 'default' }); + await new Promise((resolve) => setTimeout(resolve, 10)); // the pass re-runs on the change expect(googleOn(alone.manager)).toBe(true); }); From bbb462809d4f897844e9aa55517adb8edcaac26e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 03:29:56 +0000 Subject: [PATCH 3/7] chore(scripts): pin the sign-in floor test's engine double in the ledger Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Utp6CXhZcHuALPAjkDCiiT --- scripts/engine-double-contract.pinned.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index eacc92da399..66b275f7f6c 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -3816,6 +3816,11 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/plugins/plugin-auth/src/sign-in-method-floor.test.ts", + "verb": "findOne", + "pinned": 1 + }, { "file": "packages/plugins/plugin-auth/src/sys-session-ttl-sweep.test.ts", "verb": "delete", From db95d0dc0049d4be6a5e581866cdd2fe02909d7f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 03:45:47 +0000 Subject: [PATCH 4/7] chore(changeset): answer the ADR-0087 ledger question for the sign-in floor Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Utp6CXhZcHuALPAjkDCiiT --- .changeset/30202-keep-a-sign-in-method.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/30202-keep-a-sign-in-method.md b/.changeset/30202-keep-a-sign-in-method.md index 2bd46d59874..ffa738b700a 100644 --- a/.changeset/30202-keep-a-sign-in-method.md +++ b/.changeset/30202-keep-a-sign-in-method.md @@ -8,6 +8,8 @@ Clause-②: no (narrowing) — a save an administrator could make before is now Metadata-protocol: none Skills-docs: none — no file under `skills/**` teaches `auth.email_password_enabled`; `content/docs/deployment/environment-variables.mdx` already names the `OS_AUTH_EMAIL_PASSWORD_ENABLED` override used for recovery below. + + - **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. - **Now.** `PUT /api/settings/auth` with `email_password_enabled: false` answers `400 SETTINGS_VALIDATION`, with a field error at `email_password_enabled` (`code: invalid_value`, `constraint.lastSignInMethod: email_password`) whose message, in the caller's language (en, zh-CN, ja-JP, es-ES), says to set up and save another sign-in method first. Nothing in that batch is written, and email and password stays on. - **What counts as another way in.** A social or OIDC provider in force; 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 and passkeys do not count. From deadfe9fef937f3c3ead8a3f0a0b6f0aeac64f52 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 04:23:11 +0000 Subject: [PATCH 5/7] chore(changeset): mark the sign-in floor narrowing as breaking, as narrowings are Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Utp6CXhZcHuALPAjkDCiiT --- .changeset/30202-keep-a-sign-in-method.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/30202-keep-a-sign-in-method.md b/.changeset/30202-keep-a-sign-in-method.md index ffa738b700a..d0ed302cabf 100644 --- a/.changeset/30202-keep-a-sign-in-method.md +++ b/.changeset/30202-keep-a-sign-in-method.md @@ -2,7 +2,7 @@ '@objectstack/plugin-auth': patch --- -fix(plugin-auth): saving the auth settings refuses to turn off the last way to sign in (#30202) +fix(plugin-auth)!: saving the auth settings refuses to turn off the last way to sign in (#30202) Clause-②: no (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: email and password off while nothing else signs anyone in, and, while email and password is already off, the Google change that removes the last provider. No new error code, export or settings key. Nothing is widened. Metadata-protocol: none @@ -10,6 +10,8 @@ Skills-docs: none — no file under `skills/**` teaches `auth.email_password_ena +**BREAKING** accept-set narrowing at the auth settings save door (`PUT /api/settings/auth`, `SettingsService.setMany('auth', …)`): a change that would leave no way to sign in is refused, where it was saved before. + - **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. - **Now.** `PUT /api/settings/auth` with `email_password_enabled: false` answers `400 SETTINGS_VALIDATION`, with a field error at `email_password_enabled` (`code: invalid_value`, `constraint.lastSignInMethod: email_password`) whose message, in the caller's language (en, zh-CN, ja-JP, es-ES), says to set up and save another sign-in method first. Nothing in that batch is written, and email and password stays on. - **What counts as another way in.** A social or OIDC provider in force; 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 and passkeys do not count. From 5cc78ec52729c06b56740d1f6f9423adf0ca7057 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 05:57:38 +0000 Subject: [PATCH 6/7] fix(plugin-auth,service-settings): judge the sign-in floor on the whole save, and count a provider only with its credential pair A write predicate is now handed the whole write as batch, so the auth sign-in floor judges the state after the save: email and password off together with Google off is refused at both keys, and Google filled in within the same save is accepted. A social or OIDC provider counts only with a client ID and secret. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Utp6CXhZcHuALPAjkDCiiT --- .changeset/30202-keep-a-sign-in-method.md | 15 +- .../plugins/plugin-auth/src/auth-plugin.ts | 8 +- .../src/sign-in-method-floor.test.ts | 89 ++++++- .../plugin-auth/src/sign-in-method-floor.ts | 238 +++++++++++++----- .../service-settings/src/settings-service.ts | 9 +- .../src/settings-service.types.ts | 9 + .../src/settings-write-predicate.test.ts | 22 +- 7 files changed, 316 insertions(+), 74 deletions(-) diff --git a/.changeset/30202-keep-a-sign-in-method.md b/.changeset/30202-keep-a-sign-in-method.md index d0ed302cabf..193b4a4597b 100644 --- a/.changeset/30202-keep-a-sign-in-method.md +++ b/.changeset/30202-keep-a-sign-in-method.md @@ -1,19 +1,22 @@ --- '@objectstack/plugin-auth': patch +'@objectstack/service-settings': patch --- fix(plugin-auth)!: saving the auth settings refuses to turn off the last way to sign in (#30202) -Clause-②: no (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: email and password off while nothing else signs anyone in, and, while email and password is already off, the Google change that removes the last provider. No new error code, export or settings key. Nothing is widened. +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`; `content/docs/deployment/environment-variables.mdx` already names the `OS_AUTH_EMAIL_PASSWORD_ENABLED` override used for recovery below. +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. - + -**BREAKING** accept-set narrowing at the auth settings save door (`PUT /api/settings/auth`, `SettingsService.setMany('auth', …)`): a change that would leave no way to sign in is refused, where it was saved before. +**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. - **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. -- **Now.** `PUT /api/settings/auth` with `email_password_enabled: false` answers `400 SETTINGS_VALIDATION`, with a field error at `email_password_enabled` (`code: invalid_value`, `constraint.lastSignInMethod: email_password`) whose message, in the caller's language (en, zh-CN, ja-JP, es-ES), says to set up and save another sign-in method first. Nothing in that batch is written, and email and password stays on. -- **What counts as another way in.** A social or OIDC provider in force; 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 and passkeys do not count. +- **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. +- **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. +- **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. - **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. - **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. +- **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. diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 1b534ace9ef..968f36d9993 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -108,7 +108,7 @@ import { reportIfNoSignInAccountExists, type SignInPathConfigView, } from './boot-sign-in-reachability.js'; -import { registerSignInMethodFloor } from './sign-in-method-floor.js'; +import { providerCredentialIndex, registerSignInMethodFloor } from './sign-in-method-floor.js'; import { judgePlatformAdmin, isPlatformAdminUser, type PlatformAdminActor } from './platform-admin-gate.js'; import { decideDemoStaffEnvironmentGate, @@ -532,6 +532,8 @@ export class AuthPlugin implements Plugin { private allowListUndeterminedWarned = false; private allowListUntrustedHeaderWarned = false; private configuredSocialProviders: SocialProviderConfig | undefined; + /** Whether each provider declared at boot holds a credential pair (the sign-in floor's count). */ + private bootProviderCredentials: ReadonlyMap = new Map(); // ADR-0092 D6 — the EFFECTIVE better-auth secondaryStorage (host-supplied or // the kernel-cache adapter wired in init). The identity write guard's // session-snapshot refresh reads through this; undefined = refresh no-ops. @@ -729,6 +731,7 @@ export class AuthPlugin implements Plugin { this.configuredSocialProviders = authConfig.socialProviders ? { ...authConfig.socialProviders } : undefined; + this.bootProviderCredentials = providerCredentialIndex(authConfig.socialProviders, authConfig.oidcProviders); // [ADR-0105 D8] Lazy handle to plugin-security's `invitation-placement` // service. LAZY on purpose: plugin-security starts after plugin-auth, so @@ -2064,6 +2067,9 @@ export class AuthPlugin implements Plugin { }, hostSignInHandoff: this.options.hostSignInHandoff === true, stackConfiguredGoogle: () => !!this.configuredSocialProviders?.google, + // Not declared at boot = the Google the settings added, which the apply + // adds only with a client ID and secret. + providerHasCredentials: ({ id, type }) => this.bootProviderCredentials.get(`${type}:${id}`) ?? true, env: () => ((globalThis as any)?.process?.env ?? {}) as Record, }, ctx.logger, diff --git a/packages/plugins/plugin-auth/src/sign-in-method-floor.test.ts b/packages/plugins/plugin-auth/src/sign-in-method-floor.test.ts index 997a2b264e1..bf52164e359 100644 --- a/packages/plugins/plugin-auth/src/sign-in-method-floor.test.ts +++ b/packages/plugins/plugin-auth/src/sign-in-method-floor.test.ts @@ -22,7 +22,13 @@ * provider is refused, and the same change is accepted where something * else keeps a way in; * 5. recovery: a stored `false` from before this check is overridden by - * `OS_AUTH_EMAIL_PASSWORD_ENABLED=true` (control: without it, it rules). + * `OS_AUTH_EMAIL_PASSWORD_ENABLED=true` (control: without it, it rules); + * 6. the whole save is judged, not each key against the rest as they stand: + * email and password off together with Google off (or its secret cleared) + * is refused at both keys, and Google filled in, or switched on over a + * stored credential pair, in the same save as email and password off is + * accepted; + * 7. a provider listed without a credential pair is no way in. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -39,7 +45,7 @@ import { import { AuthPlugin } from './auth-plugin.js'; import type { AuthManager } from './auth-manager.js'; import { SSO_PROVIDER_OBJECT } from './sso-client-secret.js'; -import { SIGN_IN_FLOOR_COPY } from './sign-in-method-floor.js'; +import { SIGN_IN_FLOOR_COPY, providerCredentialIndex } from './sign-in-method-floor.js'; const SECRET = 'test-secret-at-least-32-chars-long'; @@ -316,6 +322,85 @@ describe('the auth settings save keeps a way to sign in', () => { expect(googleOn(alone.manager)).toBe(true); }); + // ── 6. The whole save, not one key at a time ────────────────────────────── + + it('refuses email and password off together with Google off, or its secret cleared, at both keys', async () => { + const { settings, manager } = await boot(); + await saveGoogle(settings, manager); + + for (const [googleKey, value] of [['google_enabled', false], ['google_client_secret', null]] as const) { + const refusal = await refusalOf(settings.setMany('auth', { email_password_enabled: false, [googleKey]: value })); + expect(refusal.fields).toEqual( + expect.arrayContaining([ + expect.objectContaining({ field: 'email_password_enabled', constraint: { lastSignInMethod: 'email_password' } }), + expect.objectContaining({ field: googleKey, constraint: { lastSignInMethod: 'google' } }), + ]), + ); + expect(refusal.fields).toHaveLength(2); + } + expect(await settings.get('auth', 'email_password_enabled')).toMatchObject({ value: true, source: 'default' }); + expect(await settings.get('auth', 'google_enabled')).toMatchObject({ value: true }); + expect(await settings.get('auth', 'google_client_secret')).toMatchObject({ value: GOOGLE.google_client_secret }); + expect(passwordOn(manager)).toBe(true); + expect(googleOn(manager)).toBe(true); + }); + + it('accepts Google filled in, in the same save that turns email and password off', async () => { + const { settings, manager } = await boot(); + + await settings.setMany('auth', { ...GOOGLE, email_password_enabled: false }); + await vi.waitFor(() => expect(passwordOn(manager)).toBe(false)); + expect(googleOn(manager)).toBe(true); + }); + + it('accepts Google switched on over a stored credential pair, in the same save', async () => { + const { settings, manager } = await boot(); + await settings.setMany('auth', { ...GOOGLE, google_enabled: false }); + await vi.waitFor(() => expect(googleOn(manager)).toBe(false)); + + // Only the switch rides in this save: the pair is read as stored. + await settings.setMany('auth', { google_enabled: true, email_password_enabled: false }); + await vi.waitFor(() => expect(passwordOn(manager)).toBe(false)); + expect(googleOn(manager)).toBe(true); + }); + + it('refuses Google switched on with half a credential pair, in the same save', async () => { + const { settings, manager } = await boot(); + const refusal = await refusalOf( + settings.setMany('auth', { google_enabled: true, google_client_id: 'only-the-id', email_password_enabled: false }), + ); + expect(refusal.fields).toEqual([ + expect.objectContaining({ field: 'email_password_enabled', constraint: { lastSignInMethod: 'email_password' } }), + ]); + expect(passwordOn(manager)).toBe(true); + }); + + // ── 7. A provider without a credential pair ────────────────────────────── + + it('a stack-config provider listed without a credential pair is no way in', async () => { + const { settings, manager } = await boot({ + pluginOptions: { socialProviders: { github: { clientId: 'gh-id', clientSecret: ' ' } } }, + }); + expect(manager.getPublicConfig().socialProviders.map((p: { id: string }) => p.id)).toContain('github'); + + await refusalOf(settings.setMany('auth', { email_password_enabled: false })); + expect(passwordOn(manager)).toBe(true); + }); + + it('indexes each boot provider, social and OIDC, by whether it holds both credentials', () => { + const index = providerCredentialIndex( + { github: { clientId: 'id', clientSecret: 'secret' }, gitlab: { clientId: 'id', clientSecret: '' } }, + [{ providerId: 'okta', clientId: 'id', clientSecret: 'secret' }, { providerId: 'azure', clientId: '', clientSecret: 's' }], + ); + expect(Object.fromEntries(index)).toEqual({ + 'social:github': true, + 'social:gitlab': false, + 'oidc:okta': true, + 'oidc:azure': false, + }); + expect(providerCredentialIndex(undefined, undefined).size).toBe(0); + }); + // ── 5. Recovery for a deployment that already stored the dead end ───────── it('OS_AUTH_EMAIL_PASSWORD_ENABLED=true overrides a stored false (control: without it, the stored false rules)', async () => { diff --git a/packages/plugins/plugin-auth/src/sign-in-method-floor.ts b/packages/plugins/plugin-auth/src/sign-in-method-floor.ts index 115908f83d0..aa06ed2890b 100644 --- a/packages/plugins/plugin-auth/src/sign-in-method-floor.ts +++ b/packages/plugins/plugin-auth/src/sign-in-method-floor.ts @@ -22,7 +22,10 @@ * "no sign-in method is available" state reads — plus the one path the host * states: * - * - a social or OIDC provider in force (`getPublicConfig().socialProviders`); + * - a social or OIDC provider in force (`getPublicConfig().socialProviders`) + * whose credential pair resolves: a non-blank `clientId` AND `clientSecret` + * in the configuration it came from. A provider listed without them draws a + * button that signs nobody in, so it does not count; * - enterprise SSO switched on AND at least one `sys_sso_provider` row. The * switch alone signs nobody in, and a store that cannot be read proves no * row, so neither counts ({@link probeSsoProvidersPresence}); @@ -37,14 +40,24 @@ * * ## The changes refused * - * - `email_password_enabled` off while nothing else is in force; - * - while email and password is already off, the Google change that removes - * the last provider: `google_enabled` off, or clearing a client credential - * that no environment variable and no stack config backs. + * - `email_password_enabled` off when nothing else is in force after the save; + * - with email and password off after the save, the Google change that + * removes a Google provider in force and leaves nothing else: + * `google_enabled` off, or clearing a client credential that no environment + * variable and no stack config backs. * - * Each is judged on its own key against the RUNNING configuration (a predicate - * sees one key), so a provider saved in the same batch does not count yet: the - * message says to save it first. + * Each is judged on the state AFTER the whole save (`batch`, every key the save + * sets), not on one key against the rest as they stand: asked one key at a + * time, `{ email_password_enabled: false, google_enabled: false }` on a + * deployment with email and password plus Google passes both keys, since each + * sees the other way in still standing, and writes both. So that batch is + * refused, at both keys, and Google filled in together with email and password + * turned off is accepted. A key the save does not set is read as it stands + * (the running configuration, or for the `google_*` keys their resolved + * settings); a key the save sets is read from the batch unless an environment + * override is in force for it, which wins over what is saved; a cleared key + * reads as its default (on, for both toggles), as the settings service's own + * save-time view does. * * A deployment that stored the dead-end state before this check existed gets * back in with `OS_AUTH_EMAIL_PASSWORD_ENABLED=true`, the settings environment @@ -71,17 +84,64 @@ export interface SignInMethodFloorDeps { engine(): BootProbeEngine | undefined; hostSignInHandoff: boolean; /** - * Whether Google was declared at boot (stack config, or both `GOOGLE_CLIENT_*` - * variables), so no settings credential is what keeps it. + * Whether Google was declared at boot (stack config, both `GOOGLE_CLIENT_*` + * variables, or an `auth:configure` handler), so no settings credential is + * what keeps it. */ stackConfiguredGoogle(): boolean; + /** + * Whether a provider in force holds a credential pair: a non-blank `clientId` + * AND `clientSecret` in the configuration it came from. Google added from the + * settings has one by construction (the apply adds it only with both). + */ + providerHasCredentials(provider: { id: string; type: 'social' | 'oidc' }): boolean; env(): Record; } +const hasCredentialPair = (entry: unknown): boolean => { + const { clientId, clientSecret } = (entry ?? {}) as { clientId?: unknown; clientSecret?: unknown }; + return typeof clientId === 'string' && clientId.trim() !== '' + && typeof clientSecret === 'string' && clientSecret.trim() !== ''; +}; + +/** + * Whether each provider declared at boot (stack config, the env fallback, an + * `auth:configure` handler) holds a credential pair, keyed `social:` and + * `oidc:`: what {@link SignInMethodFloorDeps.providerHasCredentials} + * answers from. + */ +export function providerCredentialIndex(socialProviders: unknown, oidcProviders: unknown): Map { + const index = new Map(); + if (socialProviders && typeof socialProviders === 'object') { + for (const [id, entry] of Object.entries(socialProviders as Record)) { + index.set(`social:${id}`, hasCredentialPair(entry)); + } + } + if (Array.isArray(oidcProviders)) { + for (const entry of oidcProviders) { + const id = (entry as { providerId?: unknown } | null)?.providerId; + if (typeof id === 'string') index.set(`oidc:${id}`, hasCredentialPair(entry)); + } + } + return index; +} + +const isSettingsGoogle = (p: { id?: string; type?: string } | undefined): boolean => + p?.id === 'google' && p?.type !== 'oidc'; + +/** A provider listed in force that can sign anyone in (see {@link SignInMethodFloorDeps.providerHasCredentials}). */ +function providerCounts( + deps: SignInMethodFloorDeps, + p: { id?: string; type?: string; enabled?: boolean } | undefined, +): boolean { + if (!p || p.enabled === false || typeof p.id !== 'string') return false; + return deps.providerHasCredentials({ id: p.id, type: p.type === 'oidc' ? 'oidc' : 'social' }); +} + /** * The ways in besides email and password that are in force now. `withoutGoogle` - * leaves the settings-managed Google provider out, to judge a change that - * removes it. + * leaves the settings-managed Google provider out, to judge a save that may + * remove or add it. */ export async function otherSignInMethods( deps: SignInMethodFloorDeps, @@ -90,7 +150,7 @@ export async function otherSignInMethods( const config = deps.publicConfig(); const methods: OtherSignInMethod[] = []; const providers = (config.socialProviders ?? []).filter( - (p) => p?.enabled !== false && !(options.withoutGoogle && p?.id === 'google' && p?.type !== 'oidc'), + (p) => providerCounts(deps, p) && !(options.withoutGoogle && isSettingsGoogle(p)), ); if (providers.length > 0) methods.push('social_provider'); if (config.features?.phoneNumber === true) methods.push('phone_number'); @@ -112,44 +172,45 @@ interface SignInFloorCopy { export const SIGN_IN_FLOOR_COPY: Record<'en' | 'zh-CN' | 'ja-JP' | 'es-ES', SignInFloorCopy> = { en: { emailPassword: - 'Email and password is the only way to sign in here, so turning it off would lock everyone out, ' + - 'administrators included. First set up and save another sign-in method: Google sign-in on this page ' + - '(client ID and secret), another social or OIDC provider, an enterprise SSO identity provider, or ' + - 'phone-number sign-in. Then turn this off.', + 'Turning email and password off would leave no way to sign in, so everyone would be locked out, ' + + 'administrators included. Keep or set up another sign-in method, before or in this save: Google sign-in on ' + + 'this page (client ID and secret), another social or OIDC provider, an enterprise SSO identity provider, or ' + + 'phone-number sign-in.', google: - 'With email and password off, Google is the only way to sign in here, so this change would lock ' + - 'everyone out, administrators included. First turn email and password sign-in back on, or set up ' + - 'another sign-in method.', + 'With email and password off, this change to Google would leave no way to sign in, so everyone would be ' + + 'locked out, administrators included. Keep email and password sign-in on, or set up another sign-in method, ' + + 'before or in this save.', }, 'zh-CN': { emailPassword: - '邮箱/密码是目前唯一的登录方式,关闭后所有人(包括管理员)都将无法登录。请先配置并保存另一种登录方式:' + - '本页的 Google 登录(客户端 ID 和密钥)、其他社交或 OIDC 登录、企业 SSO 身份提供方,或手机号登录,然后再关闭此项。', + '关闭邮箱/密码后将没有任何登录方式,所有人(包括管理员)都将无法登录。请在本次保存之前或同时保留或配置另一种登录方式:' + + '本页的 Google 登录(客户端 ID 和密钥)、其他社交或 OIDC 登录、企业 SSO 身份提供方,或手机号登录。', google: - '邮箱/密码登录已关闭,Google 是目前唯一的登录方式,这项修改会让所有人(包括管理员)都无法登录。' + - '请先重新开启邮箱/密码登录,或配置另一种登录方式。', + '邮箱/密码登录关闭时,这项 Google 修改会导致没有任何登录方式,所有人(包括管理员)都将无法登录。' + + '请保持邮箱/密码登录开启,或在本次保存之前或同时配置另一种登录方式。', }, 'ja-JP': { emailPassword: - 'メール/パスワードは現在唯一のログイン方法のため、オフにすると管理者を含む全員がログインできなくなります。' + - '先に別のログイン方法を設定して保存してください(このページの Google ログイン(クライアント ID とシークレット)、' + - '他のソーシャルまたは OIDC プロバイダー、エンタープライズ SSO の ID プロバイダー、または電話番号ログイン)。' + - 'その後でオフにしてください。', + 'メール/パスワードをオフにするとログイン方法がなくなり、管理者を含む全員がログインできなくなります。' + + 'この保存の前か同じ保存で、別のログイン方法を残すか設定してください(このページの Google ログイン' + + '(クライアント ID とシークレット)、他のソーシャルまたは OIDC プロバイダー、エンタープライズ SSO の ID ' + + 'プロバイダー、または電話番号ログイン)。', google: - 'メール/パスワードログインがオフのため、Google が唯一のログイン方法です。この変更を行うと管理者を含む全員が' + - 'ログインできなくなります。先にメール/パスワードログインをオンに戻すか、別のログイン方法を設定してください。', + 'メール/パスワードログインがオフの状態でこの Google の変更を行うとログイン方法がなくなり、管理者を含む全員が' + + 'ログインできなくなります。メール/パスワードログインをオンのままにするか、この保存の前か同じ保存で別のログイン' + + '方法を設定してください。', }, 'es-ES': { emailPassword: - 'El correo y la contraseña son la única forma de iniciar sesión aquí, así que desactivarlos dejaría fuera ' + - 'a todo el mundo, incluidos los administradores. Configure y guarde primero otro método de inicio de sesión: ' + - 'el inicio de sesión con Google de esta página (ID y secreto de cliente), otro proveedor social u OIDC, un ' + - 'proveedor de identidad de SSO empresarial o el inicio de sesión con número de teléfono. Después, desactive ' + - 'esta opción.', + 'Desactivar el correo y la contraseña dejaría sin ninguna forma de iniciar sesión, así que todo el mundo ' + + 'quedaría fuera, incluidos los administradores. Mantenga o configure otro método de inicio de sesión, antes ' + + 'o en este mismo guardado: el inicio de sesión con Google de esta página (ID y secreto de cliente), otro ' + + 'proveedor social u OIDC, un proveedor de identidad de SSO empresarial o el inicio de sesión con número de ' + + 'teléfono.', google: - 'Con el correo y la contraseña desactivados, Google es la única forma de iniciar sesión aquí, así que este ' + - 'cambio dejaría fuera a todo el mundo, incluidos los administradores. Vuelva a activar primero el inicio de ' + - 'sesión con correo y contraseña o configure otro método.', + 'Con el correo y la contraseña desactivados, este cambio en Google dejaría sin ninguna forma de iniciar ' + + 'sesión, así que todo el mundo quedaría fuera, incluidos los administradores. Mantenga activado el inicio de ' + + 'sesión con correo y contraseña o configure otro método, antes o en este mismo guardado.', }, }; @@ -168,6 +229,10 @@ export interface SignInFloorWriteInput { key: string; next: unknown; locale?: string; + /** Every key the save sets (`SettingsWritePredicateInput.batch`); absent, this key alone. */ + batch?: Readonly>; + /** The caller's settings context, handed back to {@link AuthSettingReader}. */ + ctx?: unknown; } /** A refusal, in the settings service's `SettingsWriteRefusal` shape. */ @@ -176,6 +241,12 @@ export interface SignInFloorRefusal { constraint: { lastSignInMethod: 'email_password' | 'google' }; } +/** Reads one resolved `auth` setting as it stands (`SettingsService.get`). */ +export type AuthSettingReader = ( + key: string, + ctx: unknown, +) => Promise<{ value?: unknown; source?: string } | undefined>; + /** The same reading `bindAuthSettings` gives a stored toggle. */ function readToggle(value: unknown, fallback: boolean): boolean { if (typeof value === 'boolean') return value; @@ -187,39 +258,79 @@ function readToggle(value: unknown, fallback: boolean): boolean { const isBlank = (value: unknown): boolean => value === null || value === undefined || (typeof value === 'string' && value.trim() === ''); -/** The keys guarded, each with the env variable `bindAuthSettings` falls back to. */ +/** The credential keys, each with the env variable `bindAuthSettings` falls back to. */ const GOOGLE_CREDENTIAL_ENV: Record = { google_client_id: 'GOOGLE_CLIENT_ID', google_client_secret: 'GOOGLE_CLIENT_SECRET', }; -export const SIGN_IN_FLOOR_KEYS = ['email_password_enabled', 'google_enabled', ...Object.keys(GOOGLE_CREDENTIAL_ENV)]; +const GOOGLE_KEYS = ['google_enabled', ...Object.keys(GOOGLE_CREDENTIAL_ENV)]; + +export const SIGN_IN_FLOOR_KEYS = ['email_password_enabled', ...GOOGLE_KEYS]; /** - * Would this write remove the settings-managed Google provider? `google_enabled` - * off removes it whatever declared it; a cleared credential removes it unless - * the stack config declares Google or the env variable supplies the credential. + * A key's value once the save is written: an environment override in force + * wins; else the batch's value (`null` = cleared); else the value as it stands. */ -function removesGoogle(deps: SignInMethodFloorDeps, key: string, next: unknown): boolean { - if (key === 'google_enabled') return !readToggle(next, true); - if (!isBlank(next) || deps.stackConfiguredGoogle()) return false; - return isBlank(deps.env()[GOOGLE_CREDENTIAL_ENV[key]!]); +async function valueAfter( + read: AuthSettingReader, + input: SignInFloorWriteInput, + batch: Readonly>, + key: string, +): Promise { + const now = await read(key, input.ctx); + if (now?.source === 'env') return now.value; + if (Object.prototype.hasOwnProperty.call(batch, key)) return batch[key] ?? null; + return now?.value; } -/** The write predicate shared by {@link SIGN_IN_FLOOR_KEYS}. */ -export function signInMethodFloorPredicate(deps: SignInMethodFloorDeps) { - return async ({ key, next, locale }: SignInFloorWriteInput): Promise => { - if (key === 'email_password_enabled') { - if (readToggle(next, true)) return undefined; - if ((await otherSignInMethods(deps)).length > 0) return undefined; - return { message: signInFloorCopy(locale).emailPassword, constraint: { lastSignInMethod: 'email_password' } }; - } +/** + * Whether the settings-managed Google provider is in force once the save is + * written, by the rule `bindAuthSettings` applies: 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 it as it stands. + */ +async function googleAfter( + deps: SignInMethodFloorDeps, + read: AuthSettingReader, + input: SignInFloorWriteInput, + batch: Readonly>, + googleNow: boolean, +): Promise { + if (!GOOGLE_KEYS.some((k) => Object.prototype.hasOwnProperty.call(batch, k))) return googleNow; + if (!readToggle(await valueAfter(read, input, batch, 'google_enabled'), true)) return false; + if (deps.stackConfiguredGoogle()) return deps.providerHasCredentials({ id: 'google', type: 'social' }); + const env = deps.env(); + for (const [key, envName] of Object.entries(GOOGLE_CREDENTIAL_ENV)) { + const value = await valueAfter(read, input, batch, key); + if (isBlank(value) && isBlank(env[envName])) return false; + } + return true; +} + +/** + * The write predicate shared by {@link SIGN_IN_FLOOR_KEYS}. It refuses when the + * state after the whole save leaves email and password off and no other way in: + * at `email_password_enabled` when the save turns it off, and at a `google_*` + * key when the save removes a Google provider that was in force. + */ +export function signInMethodFloorPredicate(deps: SignInMethodFloorDeps, read: AuthSettingReader) { + return async (input: SignInFloorWriteInput): Promise => { + const { key, next, locale } = input; + const batch = input.batch ?? { [key]: next }; const config = deps.publicConfig(); - if (config.emailPassword?.enabled !== false) return undefined; - const googleInForce = (config.socialProviders ?? []).some((p) => p?.id === 'google' && p?.type !== 'oidc'); - if (!googleInForce || !removesGoogle(deps, key, next)) return undefined; + const emailPasswordAfter = Object.prototype.hasOwnProperty.call(batch, 'email_password_enabled') + ? readToggle(await valueAfter(read, input, batch, 'email_password_enabled'), true) + : config.emailPassword?.enabled !== false; + if (emailPasswordAfter) return undefined; + const googleNow = (config.socialProviders ?? []).some((p) => isSettingsGoogle(p) && providerCounts(deps, p)); + if (key !== 'email_password_enabled' && !googleNow) return undefined; if ((await otherSignInMethods(deps, { withoutGoogle: true })).length > 0) return undefined; - return { message: signInFloorCopy(locale).google, constraint: { lastSignInMethod: 'google' } }; + if (await googleAfter(deps, read, input, batch, googleNow)) return undefined; + return key === 'email_password_enabled' + ? { message: signInFloorCopy(locale).emailPassword, constraint: { lastSignInMethod: 'email_password' } } + : { message: signInFloorCopy(locale).google, constraint: { lastSignInMethod: 'google' } }; }; } @@ -230,11 +341,13 @@ interface WritePredicateHost { key: string, predicate: (input: SignInFloorWriteInput) => Promise, ): void; + get(namespace: string, key: string, ctx?: unknown): Promise<{ value?: unknown; source?: string }>; } /** * Register the floor on the `settings` service. A service without write - * predicates cannot refuse the save, which is said at `warn`; a key the service + * predicates (or without `get`, which reads the keys a save leaves as they + * stand) cannot refuse the save, which is said at `warn`; a key the service * does not carry cannot be saved, so there is nothing to guard and nothing said. */ export function registerSignInMethodFloor( @@ -243,7 +356,7 @@ export function registerSignInMethodFloor( logger: { warn(message: string): void }, ): void { const host = settings as Partial | undefined; - if (typeof host?.registerWritePredicate !== 'function') { + if (typeof host?.registerWritePredicate !== 'function' || typeof host.get !== 'function') { logger.warn( '[auth] the settings service takes no write predicates, so saving the auth settings cannot refuse turning ' + 'off the last sign-in method (auth.email_password_enabled, auth.google_*). Check another sign-in method ' + @@ -251,7 +364,8 @@ export function registerSignInMethodFloor( ); return; } - const predicate = signInMethodFloorPredicate(deps); + const get = host.get.bind(host); + const predicate = signInMethodFloorPredicate(deps, (key, ctx) => get('auth', key, ctx as never)); for (const key of SIGN_IN_FLOOR_KEYS) { try { host.registerWritePredicate('auth', key, predicate); diff --git a/packages/services/service-settings/src/settings-service.ts b/packages/services/service-settings/src/settings-service.ts index 3e0927da0d4..d4a3681b505 100644 --- a/packages/services/service-settings/src/settings-service.ts +++ b/packages/services/service-settings/src/settings-service.ts @@ -2653,6 +2653,9 @@ export class SettingsService { /** * Ask each patched key's {@link SettingsWritePredicate}, when the patch * changes the key, and refuse the whole batch with every refusal at once. + * Each is handed the whole patch as `batch`, so a rule over several keys + * judges the state after the write rather than one key against the rest + * as they stand. * * "Changes" compares the submitted value (`null` / `undefined` read as a * clear) with the value the caller's cascade resolves today, so re-saving @@ -2669,6 +2672,10 @@ export class SettingsService { ): Promise { const byKey = this.writePredicates.get(namespace); if (!byKey) return; + // The whole write, so a rule over several keys judges the state after it. + const batch: Readonly> = Object.freeze( + Object.fromEntries(Object.entries(patch).map(([key, submitted]) => [key, submitted ?? null])), + ); const refusals: FieldError[] = []; for (const [key, submitted] of Object.entries(patch)) { const predicate = byKey.get(key); @@ -2678,7 +2685,7 @@ export class SettingsService { if (stableStringify(next) === stableStringify(current)) continue; const scope = reg.scopes.get(key)!; const organizationId = scope === 'global' || !reach ? undefined : organizationReachRowOwner(reach) ?? null; - const refusal = await predicate({ namespace, key, next, current, organizationId, ctx, locale: display.locale }); + const refusal = await predicate({ namespace, key, next, current, organizationId, ctx, locale: display.locale, batch }); if (!refusal) continue; const spec = ((reg.manifest.specifiers ?? []) as Array>) .find((s) => s.key === key); diff --git a/packages/services/service-settings/src/settings-service.types.ts b/packages/services/service-settings/src/settings-service.types.ts index 1d5231f8518..58f6bd978dc 100644 --- a/packages/services/service-settings/src/settings-service.types.ts +++ b/packages/services/service-settings/src/settings-service.types.ts @@ -357,6 +357,15 @@ export interface SettingsWritePredicateInput { ctx: SettingsContext; /** The language the refusal is written in — {@link SettingsDisplayOptions.locale}. */ locale?: string; + /** + * Every key this write sets in the namespace, this one among them, with the + * value it would store (`null` clears; a secret as submitted, as `next` + * carries it). `setMany` always passes it. A rule that spans keys judges the + * state AFTER the whole write from it: asked one key at a time against the + * values in force, two changes can each pass while together they break the + * rule. + */ + batch?: Readonly>; } /** diff --git a/packages/services/service-settings/src/settings-write-predicate.test.ts b/packages/services/service-settings/src/settings-write-predicate.test.ts index fbb96bb354c..0a003c47f8e 100644 --- a/packages/services/service-settings/src/settings-write-predicate.test.ts +++ b/packages/services/service-settings/src/settings-write-predicate.test.ts @@ -4,10 +4,13 @@ * The settings write predicate (ADR-0144 D4): a rule over engine state that a * package registers on one manifest key, asked by `setMany` when a write * CHANGES that key. The home-currency lock is its first user - * (`localization-currency-lock.test.ts`); this suite pins the mechanism. + * (`localization-currency-lock.test.ts`); this suite pins the mechanism, + * including the whole write it is handed as `batch` (a rule over several keys, + * the auth sign-in floor in `plugin-auth`, judges the state after the write). */ -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, expectTypeOf, it, vi } from 'vitest'; +import type { SettingsWritePredicateInput as PackageEntryInput } from './index.js'; import { SettingsService } from './settings-service.js'; import { localizationSettingsManifest } from './manifests/localization.manifest.js'; import { @@ -88,6 +91,21 @@ describe('SettingsService write predicate', () => { expect((await svc.get('localization', 'currency', ctx)).value ?? null).toBeNull(); }); + it('is handed the whole write as `batch`, frozen, a clear as null, so a rule over several keys sees all of them', async () => { + const { svc, spy } = rig(() => undefined); + await svc.set('localization', 'timezone', 'Europe/Paris', ctx); + await svc.setMany('localization', { currency: 'EUR', timezone: null }, ctx); + expect(spy).toHaveBeenCalledTimes(1); + const input = spy.mock.calls[0][0]; + expect(input).toMatchObject({ key: 'currency', next: 'EUR' }); + expect(input.batch).toEqual({ currency: 'EUR', timezone: null }); + expect(Object.isFrozen(input.batch)).toBe(true); + }); + + it('`batch` is part of the input type the package entry exports', () => { + expectTypeOf().toEqualTypeOf> | undefined>(); + }); + it('refuses a predicate for an unregistered namespace or an undeclared key', () => { const { svc } = rig(() => undefined); expect(() => svc.registerWritePredicate('nope', 'currency', () => undefined)).toThrow(UnknownNamespaceError); From 9bf77d1adce1964a27d171fc08bbf6d8d04bc618 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 07:23:48 +0000 Subject: [PATCH 7/7] fix(plugin-auth): a provider the boot did not declare counts as a way in only as the settings-added Google Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01Utp6CXhZcHuALPAjkDCiiT --- packages/plugins/plugin-auth/src/auth-plugin.ts | 7 +++---- .../plugin-auth/src/sign-in-method-floor.test.ts | 15 ++++++++++++--- .../plugin-auth/src/sign-in-method-floor.ts | 16 +++++++++++++++- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 968f36d9993..474e2adee03 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -108,7 +108,7 @@ import { reportIfNoSignInAccountExists, type SignInPathConfigView, } from './boot-sign-in-reachability.js'; -import { providerCredentialIndex, registerSignInMethodFloor } from './sign-in-method-floor.js'; +import { providerCredentialIndex, providerCredentialsFrom, registerSignInMethodFloor } from './sign-in-method-floor.js'; import { judgePlatformAdmin, isPlatformAdminUser, type PlatformAdminActor } from './platform-admin-gate.js'; import { decideDemoStaffEnvironmentGate, @@ -2067,9 +2067,8 @@ export class AuthPlugin implements Plugin { }, hostSignInHandoff: this.options.hostSignInHandoff === true, stackConfiguredGoogle: () => !!this.configuredSocialProviders?.google, - // Not declared at boot = the Google the settings added, which the apply - // adds only with a client ID and secret. - providerHasCredentials: ({ id, type }) => this.bootProviderCredentials.get(`${type}:${id}`) ?? true, + // 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, }, ctx.logger, diff --git a/packages/plugins/plugin-auth/src/sign-in-method-floor.test.ts b/packages/plugins/plugin-auth/src/sign-in-method-floor.test.ts index bf52164e359..37064e0fd46 100644 --- a/packages/plugins/plugin-auth/src/sign-in-method-floor.test.ts +++ b/packages/plugins/plugin-auth/src/sign-in-method-floor.test.ts @@ -28,7 +28,8 @@ * is refused at both keys, and Google filled in, or switched on over a * stored credential pair, in the same save as email and password off is * accepted; - * 7. a provider listed without a credential pair is no way in. + * 7. a provider listed without a credential pair is no way in, and one the + * boot did not declare counts only as the settings-added Google. */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -45,7 +46,7 @@ import { import { AuthPlugin } from './auth-plugin.js'; import type { AuthManager } from './auth-manager.js'; import { SSO_PROVIDER_OBJECT } from './sso-client-secret.js'; -import { SIGN_IN_FLOOR_COPY, providerCredentialIndex } from './sign-in-method-floor.js'; +import { SIGN_IN_FLOOR_COPY, providerCredentialIndex, providerCredentialsFrom } from './sign-in-method-floor.js'; const SECRET = 'test-secret-at-least-32-chars-long'; @@ -387,7 +388,7 @@ describe('the auth settings save keeps a way to sign in', () => { expect(passwordOn(manager)).toBe(true); }); - it('indexes each boot provider, social and OIDC, by whether it holds both credentials', () => { + it('indexes each boot provider, social and OIDC, by whether it holds both credentials; an unindexed one other than Google does not count', () => { const index = providerCredentialIndex( { github: { clientId: 'id', clientSecret: 'secret' }, gitlab: { clientId: 'id', clientSecret: '' } }, [{ providerId: 'okta', clientId: 'id', clientSecret: 'secret' }, { providerId: 'azure', clientId: '', clientSecret: 's' }], @@ -399,6 +400,14 @@ describe('the auth settings save keeps a way to sign in', () => { 'oidc:azure': false, }); expect(providerCredentialIndex(undefined, undefined).size).toBe(0); + + // Outside the index only the settings-added social Google counts. + const has = providerCredentialsFrom(index); + expect(has({ id: 'gitlab', type: 'social' })).toBe(false); + expect(has({ id: 'google', type: 'social' })).toBe(true); + expect(has({ id: 'google', type: 'oidc' })).toBe(false); + expect(has({ id: 'bitbucket', type: 'social' })).toBe(false); + expect(has({ id: 'keycloak', type: 'oidc' })).toBe(false); }); // ── 5. Recovery for a deployment that already stored the dead end ───────── diff --git a/packages/plugins/plugin-auth/src/sign-in-method-floor.ts b/packages/plugins/plugin-auth/src/sign-in-method-floor.ts index aa06ed2890b..6b2fcf634a1 100644 --- a/packages/plugins/plugin-auth/src/sign-in-method-floor.ts +++ b/packages/plugins/plugin-auth/src/sign-in-method-floor.ts @@ -92,7 +92,8 @@ export interface SignInMethodFloorDeps { /** * Whether a provider in force holds a credential pair: a non-blank `clientId` * AND `clientSecret` in the configuration it came from. Google added from the - * settings has one by construction (the apply adds it only with both). + * settings has one by construction (the apply adds it only with both); a + * provider of unknown origin does not count ({@link providerCredentialsFrom}). */ providerHasCredentials(provider: { id: string; type: 'social' | 'oidc' }): boolean; env(): Record; @@ -126,6 +127,19 @@ export function providerCredentialIndex(socialProviders: unknown, oidcProviders: return index; } +/** + * {@link SignInMethodFloorDeps.providerHasCredentials} over a boot index. A + * provider the index holds answers for itself. Of the providers it does not + * hold, only the social Google counts: the settings add it at runtime, and + * only with a client ID and secret. Any other provider the boot did not declare + * is unaccounted for, so it is not counted as a way in. + */ +export function providerCredentialsFrom( + index: ReadonlyMap, +): SignInMethodFloorDeps['providerHasCredentials'] { + return ({ id, type }) => index.get(`${type}:${id}`) ?? (type === 'social' && id === 'google'); +} + const isSettingsGoogle = (p: { id?: string; type?: string } | undefined): boolean => p?.id === 'google' && p?.type !== 'oidc';