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..193b4a4597b --- /dev/null +++ b/.changeset/30202-keep-a-sign-in-method.md @@ -0,0 +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-②: 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 used for recovery below. + + + +**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.** 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 d5d60c8d9ee..3cc6eefc156 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -109,6 +109,7 @@ import { reportIfNoSignInAccountExists, type SignInPathConfigView, } from './boot-sign-in-reachability.js'; +import { providerCredentialIndex, providerCredentialsFrom, registerSignInMethodFloor } from './sign-in-method-floor.js'; import { judgePlatformAdmin, isPlatformAdminUser, type PlatformAdminActor } from './platform-admin-gate.js'; import { decideDemoStaffEnvironmentGate, @@ -554,6 +555,8 @@ export class AuthPlugin implements Plugin { /** [#30203] Whether the running allow-list is the one a stored setting gave it. */ private allowedIpRangesFromSettings = 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. @@ -751,6 +754,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 @@ -2079,6 +2083,29 @@ 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, + // 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, + ); + 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..37064e0fd46 --- /dev/null +++ b/packages/plugins/plugin-auth/src/sign-in-method-floor.test.ts @@ -0,0 +1,427 @@ +// 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); + * 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, and one the + * boot did not declare counts only as the settings-added Google. + */ + +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 { + SettingsService, + SettingsValidationError, + builtinSettingsManifests, + registerSettingsRoutes, + type CryptoAdapter, +} 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, providerCredentialIndex, providerCredentialsFrom } 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', + '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 = {}) => { + const settings = new SettingsService({ env, crypto: new ConfidentialTestAdapter() }); + 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(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); + }); + + // ── 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; 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' }], + ); + expect(Object.fromEntries(index)).toEqual({ + 'social:github': true, + 'social:gitlab': false, + 'oidc:okta': true, + '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 ───────── + + 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..6b2fcf634a1 --- /dev/null +++ b/packages/plugins/plugin-auth/src/sign-in-method-floor.ts @@ -0,0 +1,390 @@ +// 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`) + * 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}); + * - 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 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 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 + * 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, 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); a + * provider of unknown origin does not count ({@link providerCredentialsFrom}). + */ + 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; +} + +/** + * {@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'; + +/** 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 save that may + * remove or add it. + */ +export async function otherSignInMethods( + deps: SignInMethodFloorDeps, + options: { withoutGoogle?: boolean } = {}, +): Promise { + const config = deps.publicConfig(); + const methods: OtherSignInMethod[] = []; + const providers = (config.socialProviders ?? []).filter( + (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'); + 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: + '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, 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: + '邮箱/密码登录关闭时,这项 Google 修改会导致没有任何登录方式,所有人(包括管理员)都将无法登录。' + + '请保持邮箱/密码登录开启,或在本次保存之前或同时配置另一种登录方式。', + }, + 'ja-JP': { + emailPassword: + 'メール/パスワードをオフにするとログイン方法がなくなり、管理者を含む全員がログインできなくなります。' + + 'この保存の前か同じ保存で、別のログイン方法を残すか設定してください(このページの Google ログイン' + + '(クライアント ID とシークレット)、他のソーシャルまたは OIDC プロバイダー、エンタープライズ SSO の ID ' + + 'プロバイダー、または電話番号ログイン)。', + google: + 'メール/パスワードログインがオフの状態でこの Google の変更を行うとログイン方法がなくなり、管理者を含む全員が' + + 'ログインできなくなります。メール/パスワードログインをオンのままにするか、この保存の前か同じ保存で別のログイン' + + '方法を設定してください。', + }, + 'es-ES': { + emailPassword: + '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, 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.', + }, +}; + +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; + /** 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. */ +export interface SignInFloorRefusal { + message: string; + 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; + 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 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', +}; + +const GOOGLE_KEYS = ['google_enabled', ...Object.keys(GOOGLE_CREDENTIAL_ENV)]; + +export const SIGN_IN_FLOOR_KEYS = ['email_password_enabled', ...GOOGLE_KEYS]; + +/** + * 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. + */ +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; +} + +/** + * 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(); + 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; + 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' } }; + }; +} + +/** The settings service surface this registers on (structural: no package edge). */ +interface WritePredicateHost { + registerWritePredicate( + namespace: string, + 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 (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( + settings: unknown, + deps: SignInMethodFloorDeps, + logger: { warn(message: string): void }, +): void { + const host = settings as Partial | undefined; + 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 ' + + 'is in force before turning email and password off.', + ); + return; + } + 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); + } catch { + // No `auth` namespace or no such key on this service: nothing saves it. + } + } +} diff --git a/packages/services/service-settings/src/settings-service.ts b/packages/services/service-settings/src/settings-service.ts index eea4d9db319..556051f9a85 100644 --- a/packages/services/service-settings/src/settings-service.ts +++ b/packages/services/service-settings/src/settings-service.ts @@ -2669,6 +2669,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 @@ -2685,6 +2688,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 registered = byKey.get(key); @@ -2703,6 +2710,9 @@ export class SettingsService { organizationId, ctx, locale: display.locale, + // The whole write as built once above, never narrowed: `transport` + // is narrowed to what this predicate declared, `batch` is the write. + batch, ...(display.transport ? { transport: narrowTransport(display.transport, registered.requestHeaders) } : {}), }); if (!refusal) continue; diff --git a/packages/services/service-settings/src/settings-service.types.ts b/packages/services/service-settings/src/settings-service.types.ts index 442147e9d82..b1234bdcd4c 100644 --- a/packages/services/service-settings/src/settings-service.types.ts +++ b/packages/services/service-settings/src/settings-service.types.ts @@ -386,6 +386,15 @@ export interface SettingsWritePredicateInput { * in-process write. */ transport?: SettingsRequestTransport; + /** + * 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>; } /** [#30203] How a {@link SettingsWritePredicate} is registered. */ 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); diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index ecebfaf2009..723c292f912 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",