diff --git a/.changeset/29909-revoke-sessions-on-password-reset-retired.md b/.changeset/29909-revoke-sessions-on-password-reset-retired.md new file mode 100644 index 00000000000..54236248a92 --- /dev/null +++ b/.changeset/29909-revoke-sessions-on-password-reset-retired.md @@ -0,0 +1,19 @@ +--- +'@objectstack/spec': minor +'@objectstack/plugin-auth': minor +--- + +feat(spec)!: retire `emailAndPassword.revokeSessionsOnPasswordReset` — a completed password reset always ends every session the account holds, and there is no setting (#29909) + +Clause-②: no (narrowing) — `EmailAndPasswordConfig.revokeSessionsOnPasswordReset` becomes a `retiredKey()` tombstone, so the schema parse and `tsc` refuse it at any value, and plugin-auth's entry guard refuses it at any value too, `true` included, which it accepted before. Nothing is widened. +Metadata-protocol: narrowed + + + +**BREAKING** for a configuration that still writes `emailAndPassword.revokeSessionsOnPasswordReset`, at any value. + +- **`@objectstack/spec`.** `EmailAndPasswordConfig.revokeSessionsOnPasswordReset` is retired (ADR-0049 enforce-or-remove). A completed self-service password reset already ended every session the account holds, on every reset door, and an explicit `false` was already refused at boot, so the key could hold one value only. It is now a tombstone: a typed configuration that writes it fails `tsc`, and `AuthConfigSchema` / `EmailAndPasswordConfigSchema` refuse it with the prescription. The authorable-surface row reads `[RETIRED]`, `RETIRED_KEYS_BY_MAJOR[18]` lists `system/EmailAndPasswordConfig:revokeSessionsOnPasswordReset`, and the upgrade guide carries the semantic entry `email-and-password-revoke-sessions-on-reset-retired`. The prescription is exported as `REVOKE_SESSIONS_ON_PASSWORD_RESET_RETIRED` (`@objectstack/spec/system`). No ADR-0087 conversion: the key lives in the configuration a host hands the auth plugin, never in a stack or a stored row, so `os migrate meta` has nothing to rewrite. +- **`@objectstack/plugin-auth`.** The auth manager takes its configuration unparsed, so it refuses the retired key itself, where the configuration enters: at construction, which stops the boot, and on a configuration patch, where the standing configuration keeps ruling. It now refuses `true` as well as `false`, with the tombstone's own prescription. `true` asked for what the platform does anyway; the key is to be deleted either way. The auth instance still sets better-auth's own option of that name itself, so a completed reset ends the account's sessions exactly as before. +- **Unchanged.** What a password reset does: every self-service reset door ends every session the account holds, and each ended session is stamped `revoke_reason: password_reset`. An absent key is accepted, as before. + +**The remedy.** Delete `revokeSessionsOnPasswordReset` from the `emailAndPassword` block of the auth configuration, whatever it holds; nothing replaces it. diff --git a/content/docs/permissions/authentication.mdx b/content/docs/permissions/authentication.mdx index 15b6ec68525..343c6f0c17f 100644 --- a/content/docs/permissions/authentication.mdx +++ b/content/docs/permissions/authentication.mdx @@ -309,8 +309,10 @@ const response = await fetch('http://localhost:3000/api/v1/auth/reset-password', A completed reset ends every existing session of the account — the emailed link and the phone OTP reset alike, the same as an administrator's forced reset — so -the user signs in again with the new password. It is not a setting: -`emailAndPassword.revokeSessionsOnPasswordReset: false` is refused at boot. +the user signs in again with the new password. It is not a setting: the former +`emailAndPassword.revokeSessionsOnPasswordReset` key is retired, and a +configuration that still carries it, at any value, is refused at boot — delete +the key. #### Setting a first local password diff --git a/content/docs/references/system/auth-config.mdx b/content/docs/references/system/auth-config.mdx index 849cab2475e..34cfb662983 100644 --- a/content/docs/references/system/auth-config.mdx +++ b/content/docs/references/system/auth-config.mdx @@ -155,7 +155,7 @@ OIDC / Generic OAuth2 provider configuration for enterprise SSO | **maxPasswordLength** | `number` | optional | Maximum password length (default 128) | | **resetPasswordTokenExpiresIn** | `number` | optional | Reset-password token TTL in seconds (default 3600) (unit per better-auth `emailAndPassword.resetPasswordTokenExpiresIn`) | | **autoSignIn** | `boolean` | optional | Auto sign-in after sign-up (default true) | -| **revokeSessionsOnPasswordReset** | `boolean` | optional | Revoke all other sessions on password reset | +| **revokeSessionsOnPasswordReset** | `never` | optional | [REMOVED] `emailAndPassword.revokeSessionsOnPasswordReset` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — it could hold only one value: a completed password reset always ends every session the account holds, on every reset door, and no setting turns that off. Delete the key; nothing replaces it. | ### Nested Shape: `AuthConfig.emailVerification` @@ -252,7 +252,7 @@ Email and password authentication options forwarded to better-auth | **maxPasswordLength** | `number` | optional | Maximum password length (default 128) | | **resetPasswordTokenExpiresIn** | `number` | optional | Reset-password token TTL in seconds (default 3600) (unit per better-auth `emailAndPassword.resetPasswordTokenExpiresIn`) | | **autoSignIn** | `boolean` | optional | Auto sign-in after sign-up (default true) | -| **revokeSessionsOnPasswordReset** | `boolean` | optional | Revoke all other sessions on password reset | +| **revokeSessionsOnPasswordReset** | `never` | optional | [REMOVED] `emailAndPassword.revokeSessionsOnPasswordReset` was removed in @objectstack/spec 18 (ADR-0049 enforce-or-remove) — it could hold only one value: a completed password reset always ends every session the account holds, on every reset door, and no setting turns that off. Delete the key; nothing replaces it. | --- diff --git a/docs/HARDENING.md b/docs/HARDENING.md index a3972c61228..5a883c306fd 100644 --- a/docs/HARDENING.md +++ b/docs/HARDENING.md @@ -260,7 +260,7 @@ ObjectStack uses [better-auth](https://www.better-auth.com/) via | Session TTL | 7 days | `session.expiresIn` (seconds) | | Access token TTL | inherited from session | configure in better-auth | | Refresh | better-auth `/auth/get-session` rolls TTL | `session.updateAge` (seconds) | -| Revocation | Sign-out deletes the `session` row. A revoke, and a completed password reset (which ends every session of the account), keep the row stamped `revoked_at` / `revoke_reason` (`password_reset` for the reset) and refuse it | — always on (`revokeSessionsOnPasswordReset: false` is refused at boot) | +| Revocation | Sign-out deletes the `session` row. A revoke, and a completed password reset (which ends every session of the account), keep the row stamped `revoked_at` / `revoke_reason` (`password_reset` for the reset) and refuse it | — unconditional; no setting (the retired `emailAndPassword.revokeSessionsOnPasswordReset` key is refused at any value) | | Idle / absolute / concurrent caps | Off (opt-in) | `session_idle_timeout_minutes` / `session_absolute_max_hours` / `max_concurrent_sessions_per_user` (Setup → Authentication) | | Email verify TTL | 1 hour | `emailVerification.expiresIn` | @@ -277,7 +277,7 @@ curl -H "Authorization: Bearer $TOK" $API/data/account # → 401 Unauthorized # 3. Confirm a completed password reset ends the user's other sessions -# (always on: a session signed in before the reset answers 401 after it) +# (unconditional: a session signed in before the reset answers 401 after it) ``` ## CORS diff --git a/packages/plugins/plugin-auth/src/auth-manager-auth-instance.ts b/packages/plugins/plugin-auth/src/auth-manager-auth-instance.ts index 217e918f33f..e1d0c458ab3 100644 --- a/packages/plugins/plugin-auth/src/auth-manager-auth-instance.ts +++ b/packages/plugins/plugin-auth/src/auth-manager-auth-instance.ts @@ -417,8 +417,10 @@ export async function createAuthInstance(this: AuthInstanceHost): Promise { maxPasswordLength: 64, requireEmailVerification: true, autoSignIn: false, - revokeSessionsOnPasswordReset: true, }, }); await manager.getAuthInstance(); @@ -2069,8 +2068,10 @@ describe('AuthManager', () => { maxPasswordLength: 64, requireEmailVerification: true, autoSignIn: false, - revokeSessionsOnPasswordReset: true, }); + // better-auth's own option, set by the auth instance whatever the host + // passes (the spec key of that name is retired; the manager refuses it). + expect(capturedConfig.emailAndPassword.revokeSessionsOnPasswordReset).toBe(true); expect(typeof capturedConfig.emailAndPassword.sendResetPassword).toBe('function'); }); }); diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index ec7662b7a29..7c46b7cb160 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -1606,9 +1606,10 @@ export class AuthManager { assertScimAdminCoherence(config.plugins); // A completed password reset ends the account's other sessions, always; - // an explicit `revokeSessionsOnPasswordReset: false` is refused here, at - // boot, rather than silently overridden (`password-reset-ends-sessions.ts`). - // `applyConfigPatch` re-runs it on the merged result. + // the retired `emailAndPassword.revokeSessionsOnPasswordReset` is refused + // here at any value, at boot, rather than silently ignored + // (`password-reset-ends-sessions.ts`). `applyConfigPatch` re-runs it on the + // merged result. assertPasswordResetEndsSessions(config.emailAndPassword); // The mobile sign-in hand-off's registered app schemes — same @@ -2083,8 +2084,8 @@ export class AuthManager { assertScimAdminCoherence(next.plugins); } // Same entry validation the constructor runs, on the MERGED block: a patch - // cannot ask a completed password reset to keep the account's other - // sessions. Refused loudly and the standing config keeps ruling. + // cannot write the retired `revokeSessionsOnPasswordReset`, at any value. + // Refused loudly and the standing config keeps ruling. if ('emailAndPassword' in patch) { assertPasswordResetEndsSessions(next.emailAndPassword); } diff --git a/packages/plugins/plugin-auth/src/password-reset-ends-sessions.test.ts b/packages/plugins/plugin-auth/src/password-reset-ends-sessions.test.ts index 71130f61f13..85822ab4936 100644 --- a/packages/plugins/plugin-auth/src/password-reset-ends-sessions.test.ts +++ b/packages/plugins/plugin-auth/src/password-reset-ends-sessions.test.ts @@ -19,9 +19,10 @@ * - a reset that does not complete (a token that does not match) ends nothing; * - with no configuration at all, the auth instance asks better-auth to end * sessions on every reset door; - * - an explicit `revokeSessionsOnPasswordReset: false` is refused at - * construction and on a configuration patch, where the standing - * configuration keeps ruling; `true` is accepted; + * - the retired `revokeSessionsOnPasswordReset` is refused at any value, with + * the spec tombstone's prescription, at construction and on a + * configuration patch, where the standing configuration keeps ruling; an + * absent key passes; * - a session a password reset ends leaves a revocation trail: on each reset * door every session the account held is still a `sys_session` row * afterwards, stamped `revoked_at` and `revoke_reason: password_reset`, and @@ -35,6 +36,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import { AuthManager } from './auth-manager.js'; import { runAdminSetUserPassword, type AdminUserEndpointDeps } from './admin-user-endpoints.js'; import { authIdentityObjects } from './manifest.js'; +import { REVOKE_SESSIONS_ON_PASSWORD_RESET_RETIRED } from '@objectstack/spec/system'; import { assertPasswordResetEndsSessions } from './password-reset-ends-sessions.js'; import { memoizeScrypt } from './__tests__/scrypt-memo.testkit'; import { sqliteEnginePool } from './__tests__/sqlite-engine-pool.testkit'; @@ -51,6 +53,8 @@ const EMAIL = 'reset-ends-sessions@example.com'; const FIRST = 'F1rst!Password-For-Reset'; const SECOND = 'S3cond!Password-After-Reset'; const SYS = { context: { isSystem: true } } as never; +/** The retired configuration key, spelled once. */ +const RETIRED = 'revokeSessionsOnPasswordReset'; const engines = sqliteEnginePool(async () => { const engine = new ObjectQL(); @@ -325,31 +329,49 @@ describe('a session a password reset ends leaves a revocation trail', () => { }, 60_000); }); -describe('revokeSessionsOnPasswordReset: false is refused, never silently overridden', () => { - it('is refused at construction', () => { - expect( - () => - new AuthManager({ - secret: SECRET, - baseUrl: BASE, - emailAndPassword: { enabled: true, revokeSessionsOnPasswordReset: false }, - } as never), - ).toThrow(/revokeSessionsOnPasswordReset: false is not supported/); - }); +describe('the retired revokeSessionsOnPasswordReset is refused at any value, never silently ignored', () => { + // The manager takes its configuration unparsed, so the spec tombstone never + // sees a host's object: this guard answers it with the tombstone's own text. + const PRESCRIPTION = + /^\[auth\] `emailAndPassword\.revokeSessionsOnPasswordReset` was removed in @objectstack\/spec 18 \(ADR-0049 enforce-or-remove\) — .*Delete the key; nothing replaces it\.$/s; - it('is refused on a configuration patch, and the standing configuration keeps ruling', () => { - const manager = new AuthManager({ secret: SECRET, baseUrl: BASE, emailAndPassword: { enabled: true, minPasswordLength: 10 } } as never); - expect(() => - manager.applyConfigPatch({ emailAndPassword: { revokeSessionsOnPasswordReset: false, minPasswordLength: 20 } } as never), - ).toThrow(/revokeSessionsOnPasswordReset: false is not supported/); - const standing = (manager as unknown as { config: { emailAndPassword?: Record } }).config.emailAndPassword; - expect(standing?.minPasswordLength).toBe(10); - expect(standing && 'revokeSessionsOnPasswordReset' in standing).toBe(false); + it('the refusal is the spec tombstone\'s prescription, word for word', () => { + expect(() => assertPasswordResetEndsSessions({ revokeSessionsOnPasswordReset: true })).toThrow( + `[auth] ${REVOKE_SESSIONS_ON_PASSWORD_RESET_RETIRED}`, + ); }); - it('accepts true and an absent key', () => { - expect(() => assertPasswordResetEndsSessions({ revokeSessionsOnPasswordReset: true })).not.toThrow(); + for (const value of [false, true, 'false', 0, null]) { + it(`is refused at construction: ${JSON.stringify(value)}`, () => { + expect( + () => + new AuthManager({ + secret: SECRET, + baseUrl: BASE, + emailAndPassword: { enabled: true, revokeSessionsOnPasswordReset: value }, + } as never), + ).toThrow(PRESCRIPTION); + }); + } + + for (const value of [false, true]) { + it(`is refused on a configuration patch, and the standing configuration keeps ruling: ${value}`, () => { + const manager = new AuthManager({ secret: SECRET, baseUrl: BASE, emailAndPassword: { enabled: true, minPasswordLength: 10 } } as never); + expect(() => + manager.applyConfigPatch({ emailAndPassword: { revokeSessionsOnPasswordReset: value, minPasswordLength: 20 } } as never), + ).toThrow(PRESCRIPTION); + const standing = (manager as unknown as { config: { emailAndPassword?: Record } }).config.emailAndPassword; + expect(standing?.minPasswordLength).toBe(10); + expect(standing && RETIRED in standing).toBe(false); + }); + } + + it('CONTROL: an absent key, an undefined one and no block at all pass, and the manager is built', () => { expect(() => assertPasswordResetEndsSessions({})).not.toThrow(); + expect(() => assertPasswordResetEndsSessions({ [RETIRED]: undefined })).not.toThrow(); expect(() => assertPasswordResetEndsSessions(undefined)).not.toThrow(); + expect( + () => new AuthManager({ secret: SECRET, baseUrl: BASE, emailAndPassword: { enabled: true, minPasswordLength: 10 } } as never), + ).not.toThrow(); }); }); diff --git a/packages/plugins/plugin-auth/src/password-reset-ends-sessions.ts b/packages/plugins/plugin-auth/src/password-reset-ends-sessions.ts index 30a91c22e6a..51ab13f3707 100644 --- a/packages/plugins/plugin-auth/src/password-reset-ends-sessions.ts +++ b/packages/plugins/plugin-auth/src/password-reset-ends-sessions.ts @@ -38,36 +38,43 @@ * `/sign-out` alone. And the account's API keys are untouched; the forced * reset's key revocation is its own switch. * - * ## The declared key + * ## The retired key * - * `emailAndPassword.revokeSessionsOnPasswordReset` is still declared by the + * `emailAndPassword.revokeSessionsOnPasswordReset` is RETIRED from the * published configuration schema (`@objectstack/spec`, - * `EmailAndPasswordConfigSchema`). `true` is what the platform does anyway and - * is accepted. An explicit `false` asks for something the platform no longer - * does, so {@link assertPasswordResetEndsSessions} REFUSES it where the - * configuration enters the manager — at construction, which stops the boot, and - * on a configuration patch, where the standing configuration keeps ruling — - * rather than overriding it silently: a declaration that reads as in force and - * is not is the ADR-0049 hazard, and refusing is how this plugin treats every - * other declaration it will not honour (`assertAudienceConfig`, - * `assertScimAdminCoherence`). + * `EmailAndPasswordConfigSchema`): it could hold only one value, so it is a + * `retiredKey()` tombstone, and a typed or parsed configuration that writes it + * fails `tsc` or the parse with the prescription. The manager never parses its + * configuration through that schema, so {@link assertPasswordResetEndsSessions} + * holds the same line where the configuration enters the manager — at + * construction, which stops the boot, and on a configuration patch, where the + * standing configuration keeps ruling — and REFUSES the key at any value, with + * the tombstone's own prescription. `true` asked for what the platform does + * anyway and `false` for what it no longer does; either way the key is to be + * deleted, and a declaration that reads as a setting when there is none is the + * ADR-0049 hazard. Refusing is how this plugin treats every other declaration + * it will not honour (`assertAudienceConfig`, `assertScimAdminCoherence`). + * better-auth's own option of the same name is set by the auth instance, never + * read from the host configuration. */ -import type { EmailAndPasswordConfig } from '@objectstack/spec/system'; +import { REVOKE_SESSIONS_ON_PASSWORD_RESET_RETIRED } from '@objectstack/spec/system'; + +/** The retired configuration key this guard refuses. */ +const RETIRED_KEY = 'revokeSessionsOnPasswordReset'; /** - * Refuse an `emailAndPassword` block that asks a completed password reset to - * keep the account's other sessions. Absent and `true` pass; see the header. + * Refuse an `emailAndPassword` block that still writes the retired + * `revokeSessionsOnPasswordReset`, whatever it holds. An absent key (or one + * holding `undefined`, which the schema's tombstone accepts too) passes; see + * the header. */ -export function assertPasswordResetEndsSessions( - emailAndPassword: Pick, 'revokeSessionsOnPasswordReset'> | undefined, -): void { - if (emailAndPassword?.revokeSessionsOnPasswordReset === false) { - throw new Error( - '[auth] emailAndPassword.revokeSessionsOnPasswordReset: false is not supported: a completed ' + - "password reset always ends the account's other sessions, on every reset door, the same as " + - "an administrator's forced reset. Remove the key (true is accepted and changes nothing). " + - 'Refused rather than ignored, so the configuration never reads as keeping sessions it does not keep.', - ); +export function assertPasswordResetEndsSessions(emailAndPassword: unknown): void { + if ( + emailAndPassword !== null + && typeof emailAndPassword === 'object' + && (emailAndPassword as Record)[RETIRED_KEY] !== undefined + ) { + throw new Error(`[auth] ${REVOKE_SESSIONS_ON_PASSWORD_RESET_RETIRED}`); } } diff --git a/packages/spec/api-surface/system.json b/packages/spec/api-surface/system.json index 046517b6507..8ceb4c7c046 100644 --- a/packages/spec/api-surface/system.json +++ b/packages/spec/api-surface/system.json @@ -556,6 +556,7 @@ "QueueConfigSchema (const)", "QuotaEnforcementResult (type)", "QuotaEnforcementResultSchema (const)", + "REVOKE_SESSIONS_ON_PASSWORD_RESET_RETIRED (const)", "RPO (type)", "RPOParsed (type)", "RPOSchema (const)", diff --git a/packages/spec/authorable-surface/system.json b/packages/spec/authorable-surface/system.json index ea2a9f877f8..28c83782524 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -375,7 +375,7 @@ "system/EmailAndPasswordConfig:minPasswordLength", "system/EmailAndPasswordConfig:requireEmailVerification", "system/EmailAndPasswordConfig:resetPasswordTokenExpiresIn", - "system/EmailAndPasswordConfig:revokeSessionsOnPasswordReset", + "system/EmailAndPasswordConfig:revokeSessionsOnPasswordReset [RETIRED]", "system/EmailServiceConfig:apiKey", "system/EmailServiceConfig:appName", "system/EmailServiceConfig:defaultFrom", diff --git a/packages/spec/export-origins/system.json b/packages/spec/export-origins/system.json index 896dbe8fe59..23a842a3156 100644 --- a/packages/spec/export-origins/system.json +++ b/packages/spec/export-origins/system.json @@ -534,6 +534,7 @@ "QueueConfigSchema": "src/system/worker.zod.ts#QueueConfigSchema (const)", "QuotaEnforcementResult": "src/system/tenant.zod.ts#QuotaEnforcementResult (type)", "QuotaEnforcementResultSchema": "src/system/tenant.zod.ts#QuotaEnforcementResultSchema (const)", + "REVOKE_SESSIONS_ON_PASSWORD_RESET_RETIRED": "src/system/auth-config.zod.ts#REVOKE_SESSIONS_ON_PASSWORD_RESET_RETIRED (const)", "RPO": "src/system/disaster-recovery.zod.ts#RPO (type)", "RPOParsed": "src/system/disaster-recovery.zod.ts#RPOParsed (type)", "RPOSchema": "src/system/disaster-recovery.zod.ts#RPOSchema (const)", diff --git a/packages/spec/src/migrations/entries/retired-keys/18.system__EmailAndPasswordConfig__revokeSessionsOnPasswordReset.ts b/packages/spec/src/migrations/entries/retired-keys/18.system__EmailAndPasswordConfig__revokeSessionsOnPasswordReset.ts new file mode 100644 index 00000000000..3676472fd67 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/18.system__EmailAndPasswordConfig__revokeSessionsOnPasswordReset.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #29909 — ADR-0049 enforce-or-remove, by the maintainer's ruling on that card: +// a completed self-service password reset always ends every session the +// account holds, and there is no setting. plugin-auth made the behaviour +// unconditional first (it sets better-auth's own option itself and refused an +// explicit `false`), which left `EmailAndPasswordConfig.revokeSessionsOnPasswordReset` +// able to hold one value only. Tombstoned with `retiredKey()`. No D2 +// conversion: host plugin configuration, not a stack collection member, not a +// stored row. The D3 record is `email-and-password-revoke-sessions-on-reset-retired`. +export const entry = 'system/EmailAndPasswordConfig:revokeSessionsOnPasswordReset'; diff --git a/packages/spec/src/migrations/entries/semantic/18.email-and-password-revoke-sessions-on-reset-retired.ts b/packages/spec/src/migrations/entries/semantic/18.email-and-password-revoke-sessions-on-reset-retired.ts new file mode 100644 index 00000000000..f4194bd587c --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.email-and-password-revoke-sessions-on-reset-retired.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #29909 — ADR-0049 enforce-or-remove — the D3 entry for the retired +// `system/EmailAndPasswordConfig:revokeSessionsOnPasswordReset`. No D2 +// conversion carries it: the key lives in the configuration a host hands the +// auth plugin, never in a stack collection or a stored row, so there is no +// source `os migrate meta` could rewrite and the deletion is the author's. +export const entry: SemanticMigration = { + id: 'email-and-password-revoke-sessions-on-reset-retired', + surface: 'auth emailAndPassword.revokeSessionsOnPasswordReset — whether a completed password reset ends the account\'s sessions', + replacement: + 'Nothing: delete the key. A completed password reset always ends every session the account ' + + 'holds, on every reset door (the emailed link and the phone OTP reset alike), the same as an ' + + 'administrator\'s forced reset, and no setting turns that off.', + reason: + 'The key could hold only one value. A configuration that wrote `true` asked for what the ' + + 'platform does anyway, so deleting it changes no behaviour; one that wrote `false` was already ' + + 'refused at boot by the auth plugin before this retirement, so it cannot be running. Both ' + + 'spellings are now refused: by `tsc` and the schema parse where the configuration is typed or ' + + 'parsed, and by the auth plugin where a host hands it an unparsed object. What no tool can ' + + 'check is whether something the deployment maintains reads the key — a settings form, a ' + + 'generated configuration file or a script that writes it — and must stop writing it.', + acceptanceCriteria: + 'No configuration the deployment hands the auth plugin carries ' + + '`emailAndPassword.revokeSessionsOnPasswordReset`, and the deployment boots. A user signed in ' + + 'on a second device before a password reset is signed out there (the session answers 401) ' + + 'once the reset completes.', +}; diff --git a/packages/spec/src/migrations/registry.ts.template b/packages/spec/src/migrations/registry.ts.template index 9d56c03545e..0c44b1c1c71 100644 --- a/packages/spec/src/migrations/registry.ts.template +++ b/packages/spec/src/migrations/registry.ts.template @@ -1840,6 +1840,18 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'the heading element each one always rendered, so the outline is unchanged and the heading ' + 'takes that level\'s style. The `body` default for an absent `variant` is unchanged.', }, + { + id: 'email-and-password-revoke-sessions-on-reset-retired', + order: 118, + text: + 'It also retires `emailAndPassword.revokeSessionsOnPasswordReset` from the auth ' + + 'configuration (ADR-0049 enforce-or-remove). A completed self-service password reset now ' + + 'always ends every session the account holds, on every reset door, and the auth plugin ' + + 'already refused an explicit `false`, so the key could hold one value only. There is no D2 ' + + 'conversion: the key lives in the configuration a host hands the auth plugin, never in a ' + + 'stack collection or a stored row. Its D3 record is the semantic entry ' + + '`email-and-password-revoke-sessions-on-reset-retired`.', + }, { id: 'email-template-category-is-system-retired', order: 94, diff --git a/packages/spec/src/system/auth-config.test.ts b/packages/spec/src/system/auth-config.test.ts index 50ac335d689..c6702d6032d 100644 --- a/packages/spec/src/system/auth-config.test.ts +++ b/packages/spec/src/system/auth-config.test.ts @@ -296,13 +296,14 @@ describe('EmailAndPasswordConfigSchema', () => { minPasswordLength: 10, maxPasswordLength: 64, autoSignIn: false, - revokeSessionsOnPasswordReset: true, resetPasswordTokenExpiresIn: 7200, }); expect(config?.enabled).toBe(true); expect(config?.minPasswordLength).toBe(10); - expect(config?.revokeSessionsOnPasswordReset).toBe(true); + // `revokeSessionsOnPasswordReset` is retired: its refusal is pinned in + // `auth-revoke-sessions-on-password-reset-retirement.test.ts`. + expect(config).not.toHaveProperty('revokeSessionsOnPasswordReset'); }); it('should default enabled to true', () => { diff --git a/packages/spec/src/system/auth-config.zod.ts b/packages/spec/src/system/auth-config.zod.ts index 42df324f621..6336afb7779 100644 --- a/packages/spec/src/system/auth-config.zod.ts +++ b/packages/spec/src/system/auth-config.zod.ts @@ -10,6 +10,7 @@ import { z } from 'zod'; */ import { lazySchema } from '../shared/lazy-schema'; +import { retiredKey } from '../shared/retired-key'; export const AuthProviderConfigSchema = lazySchema(() => z.object({ id: z.string().describe('Provider ID (github, google)'), clientId: z.string().describe('OAuth Client ID'), @@ -297,6 +298,18 @@ export type OidcProviderConfig = z.input; export type OidcProvidersConfig = z.input; +/** + * The prescription `EmailAndPasswordConfig.revokeSessionsOnPasswordReset`'s + * tombstone carries. plugin-auth's entry guard (`password-reset-ends-sessions.ts`) + * answers a host configuration that never passed this schema with the same + * text, so both doors say the same thing. + */ +export const REVOKE_SESSIONS_ON_PASSWORD_RESET_RETIRED = + '`emailAndPassword.revokeSessionsOnPasswordReset` was removed in @objectstack/spec 18 ' + + '(ADR-0049 enforce-or-remove) — it could hold only one value: a completed password reset ' + + 'always ends every session the account holds, on every reset door, and no setting turns ' + + 'that off. Delete the key; nothing replaces it.'; + export const EmailAndPasswordConfigSchema = lazySchema(() => z.object({ enabled: z.boolean().default(true).describe('Enable email/password auth'), disableSignUp: z.boolean().optional().describe('Disable new user registration via email/password'), @@ -306,18 +319,27 @@ export const EmailAndPasswordConfigSchema = lazySchema(() => z.object({ minPasswordLength: z.number().optional().describe('Minimum password length (default 8)'), maxPasswordLength: z.number().optional().describe('Maximum password length (default 128)'), // `externalVocabulary` mirror (#14478 ruling B): this object's own describe - // says its options are "forwarded to better-auth", and every sibling here is - // a better-auth option name verbatim (`disableSignUp`, - // `requireEmailVerification`, `minPasswordLength`, `autoSignIn`, - // `revokeSessionsOnPasswordReset`). A key that is forwarded by name cannot be - // renamed without breaking the forwarding. + // says its options are "forwarded to better-auth", and every live sibling + // here is a better-auth option name verbatim, forwarded under that name by + // plugin-auth's auth instance (`disableSignUp`, `requireEmailVerification`, + // `minPasswordLength`, `maxPasswordLength`, `autoSignIn`). A key that is + // forwarded by name cannot be renamed without breaking the forwarding. resetPasswordTokenExpiresIn: z.number().optional().describe( 'Reset-password token TTL in seconds (default 3600)' ).meta({ externalVocabulary: 'better-auth `emailAndPassword.resetPasswordTokenExpiresIn`' }), autoSignIn: z.boolean().optional().describe('Auto sign-in after sign-up (default true)'), - revokeSessionsOnPasswordReset: z.boolean().optional().describe( - 'Revoke all other sessions on password reset' - ), + // RETIRED (#29909, ADR-0049 enforce-or-remove): a completed self-service + // password reset always ends every session the account holds, on every reset + // door, and no setting turns that off (maintainer ruling on #29909). After + // plugin-auth made the behaviour unconditional the key could hold one value + // only, so it is a tombstone: authoring it is a `tsc` error and a parse + // error carrying the prescription. plugin-auth still sets better-auth's own + // option of this name itself (`auth-manager-auth-instance.ts`), and refuses + // this key at any value where a host configuration enters the manager + // unparsed (`password-reset-ends-sessions.ts`). No ADR-0087 D2 conversion: + // this is host plugin configuration, not a stack collection member or a + // stored row. D3 record: `email-and-password-revoke-sessions-on-reset-retired`. + revokeSessionsOnPasswordReset: retiredKey(REVOKE_SESSIONS_ON_PASSWORD_RESET_RETIRED), }).optional().describe('Email and password authentication options forwarded to better-auth')); /** diff --git a/packages/spec/src/system/auth-revoke-sessions-on-password-reset-retirement.test.ts b/packages/spec/src/system/auth-revoke-sessions-on-password-reset-retirement.test.ts new file mode 100644 index 00000000000..10d4b78f255 --- /dev/null +++ b/packages/spec/src/system/auth-revoke-sessions-on-password-reset-retirement.test.ts @@ -0,0 +1,244 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `EmailAndPasswordConfig.revokeSessionsOnPasswordReset` RETIRED (#29909) — + * ADR-0049 enforce-or-remove, by the maintainer's ruling on that card: a + * completed self-service password reset always ends every session the account + * holds, and there is no setting. plugin-auth made the behaviour unconditional + * first (it sets better-auth's own option itself and refused an explicit + * `false`), which left the key able to hold one value only. + * + * Bookkeeping shapes, pinned below: + * 1. A `retiredKey()` tombstone on `EmailAndPasswordConfigSchema`, so the + * refusal carries its prescription and a typed configuration fails `tsc`. + * The prescription is exported (`REVOKE_SESSIONS_ON_PASSWORD_RESET_RETIRED`) + * because plugin-auth, which takes its configuration unparsed, answers a + * host's object with the same text. + * 2. `RETIRED_KEYS_BY_MAJOR[18]` carries the key and the D3 record is + * `email-and-password-revoke-sessions-on-reset-retired`. No D2 conversion: + * host plugin configuration, not a stack collection member or a stored row. + * 3. A tree-scoped absence scan over the authored spelling. + * + * On the assertion set: a schema refusal raises a `ZodError` whose issues carry + * `code` and `path` but no ADR-0112 `status` (this schema has no authoring + * door of its own), so these pins assert refusal, the issue `code`, the `path` + * and the prescription text. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +import { maskComments } from '../../../../scripts/js-comment-mask.mjs'; + +import { ALL_CONVERSIONS } from '../conversions/registry'; +import { MIGRATIONS_BY_MAJOR, RETIRED_KEYS_BY_MAJOR } from '../migrations/registry'; +import { + AuthConfigSchema, + EmailAndPasswordConfigSchema, + REVOKE_SESSIONS_ON_PASSWORD_RESET_RETIRED, + type EmailAndPasswordConfig, +} from './auth-config.zod'; + +const KEY = 'revokeSessionsOnPasswordReset'; +const D3_ID = 'email-and-password-revoke-sessions-on-reset-retired'; + +const PRESCRIPTION = + /^`emailAndPassword\.revokeSessionsOnPasswordReset` was removed in @objectstack\/spec 18 \(ADR-0049 enforce-or-remove\) — it could hold only one value: a completed password reset always ends every session the account holds, on every reset door, and no setting turns that off\. Delete the key; nothing replaces it\.$/; + +/** A well-formed block — the keys a host commonly writes, not the retired one. */ +const BLOCK = { enabled: true, minPasswordLength: 12, autoSignIn: false } as const; + +describe('revokeSessionsOnPasswordReset retirement — the tombstone', () => { + it('the exported prescription is the house sentence, and carries no tracker number or migrate-tool promise', () => { + expect(REVOKE_SESSIONS_ON_PASSWORD_RESET_RETIRED).toMatch(PRESCRIPTION); + expect(REVOKE_SESSIONS_ON_PASSWORD_RESET_RETIRED).not.toMatch(/#\d{3,}/); + // No conversion covers host configuration, so the text must not promise one. + expect(REVOKE_SESSIONS_ON_PASSWORD_RESET_RETIRED).not.toMatch(/os migrate meta/); + }); + + for (const value of [true, false, 'false', 0, null, {}]) { + it(`the schema refuses ${JSON.stringify(value)} at the key, with the prescription`, () => { + const r = EmailAndPasswordConfigSchema.safeParse({ ...BLOCK, [KEY]: value }); + expect(r.success).toBe(false); + if (r.success) return; + expect(r.error.issues).toHaveLength(1); + const issue = r.error.issues[0]!; + expect(issue.code).toBe('invalid_type'); + expect(issue.path).toEqual([KEY]); + expect(issue.message).toBe(REVOKE_SESSIONS_ON_PASSWORD_RESET_RETIRED); + }); + } + + it('the whole auth configuration refuses it at the nested path', () => { + const r = AuthConfigSchema.safeParse({ emailAndPassword: { ...BLOCK, [KEY]: true } }); + expect(r.success).toBe(false); + if (r.success) return; + const issue = r.error.issues.find((i) => i.path.join('.') === `emailAndPassword.${KEY}`); + expect(issue, JSON.stringify(r.error.issues)).toBeDefined(); + expect(issue!.message).toMatch(PRESCRIPTION); + // CONTROL: the same configuration without the key is accepted. + expect(AuthConfigSchema.safeParse({ emailAndPassword: { ...BLOCK } }).success).toBe(true); + }); + + it('CONTROL: the block without the key passes, live keys intact, and grows no key', () => { + const r = EmailAndPasswordConfigSchema.safeParse(BLOCK); + expect(r.success).toBe(true); + if (!r.success) return; + expect(r.data).not.toHaveProperty(KEY); + expect(r.data).toEqual(BLOCK); + // An explicit `undefined` is absence, as on every tombstone. + expect(EmailAndPasswordConfigSchema.safeParse({ ...BLOCK, [KEY]: undefined }).success).toBe(true); + }); + + it('fails tsc at the authoring site: the input type of the key is the retired mark', () => { + const withTrue: EmailAndPasswordConfig = { + ...BLOCK, + // @ts-expect-error — `revokeSessionsOnPasswordReset` is a retiredKey() tombstone: no value is assignable. + revokeSessionsOnPasswordReset: true, + }; + // The parse channel agrees with the type channel on the same literal. + expect(() => EmailAndPasswordConfigSchema.parse(withTrue)).toThrow(/`emailAndPassword\.revokeSessionsOnPasswordReset` was removed in @objectstack\/spec 18/); + }); + + it('the walked shape keeps the key — the authorable-surface row stays reachable as [RETIRED]', () => { + const shape = (EmailAndPasswordConfigSchema as unknown as { unwrap?: () => { shape?: Record } }) + .unwrap?.()?.shape; + expect(shape, 'EmailAndPasswordConfigSchema must unwrap to a read-through shape').toBeDefined(); + expect(Object.keys(shape!)).toContain(KEY); + expect(Object.keys(shape!), 'CONTROL: a live neighbour').toContain('minPasswordLength'); + }); +}); + +describe('revokeSessionsOnPasswordReset retirement — ADR-0087 registration', () => { + it('declares the key under major 18', () => { + expect(RETIRED_KEYS_BY_MAJOR[18]).toContain(`system/EmailAndPasswordConfig:${KEY}`); + }); + + it('carries one D3 entry, which judges no conversion, and no D2 conversion names the key', () => { + const entry = MIGRATIONS_BY_MAJOR[18].semantic.find((m) => m.id === D3_ID); + expect(entry, `D3 entry ${D3_ID} is not registered in step 18`).toBeDefined(); + expect(entry!.surface).toMatch(/emailAndPassword\.revokeSessionsOnPasswordReset/); + expect(entry!.replacement).toMatch(/delete the key/i); + expect(entry!.reason.length).toBeGreaterThan(0); + expect(entry!.acceptanceCriteria.length).toBeGreaterThan(0); + expect(entry!.conversionIds).toBeUndefined(); + // No D2 by design: nothing in a stack or a stored row carries the key. + expect(ALL_CONVERSIONS.length).toBeGreaterThan(50); // anti-vacuity: the table is populated + expect(ALL_CONVERSIONS.filter((c) => JSON.stringify(c).includes(KEY))).toEqual([]); + }); +}); + +// ─── Tree-scoped absence, with a DECLARED radius ───────────────────────────── +// +// `tsc` is the primary sweeper — the tombstone types the key as the retired +// mark on `EmailAndPasswordConfig`, so every TYPED authoring site fails to +// compile. The residue is what `tsc` never judges: JSON, YAML, MD/MDX, untyped +// `.js`, and TS literals typed `any` / `unknown` / a record — the shape a host +// configuration handed to the auth plugin usually has. This walk covers that +// residue across the roots `scripts/cross-package-test-inputs.mjs` already +// declares for `@objectstack/spec` (the #15513 pin's rows, mirrored in +// `turbo.json`): `packages/` per extension, `examples/` for the non-code +// extensions plus each app's `src/` tree, and `skills/`, `content/`, `scripts/`. +// +// The key name is unique to this setting (better-auth's option of the same +// name aside — see the exclusions), so the matcher is the key in KEY POSITION +// (`revokeSessionsOnPasswordReset:` or its quoted JSON form), with comment +// contents masked in code (`scripts/js-comment-mask.mjs`); the tombstone declaration itself +// (`: retiredKey(`) is never an offence. Excluded, each for its reason: +// - this file, which writes the key to prove the refusal; +// - plugin-auth's `password-reset-ends-sessions.test.ts`, which writes it as +// a host's input to prove the plugin's refusal; +// - plugin-auth's `auth-manager-auth-instance.ts`, which writes +// better-auth's OWN option of that name into better-auth's configuration — +// the mechanism of the rule, never read from a host; +// - `**/CHANGELOG.md`, release-owned history of what shipped; +// - `packages/spec/json-schema/`, the git-ignored build projection of the +// schema, where the key appears as the tombstone itself (`not: {}` under +// its `[REMOVED]` description). +// The bound, stated: a key assembled under a computed name, and `docs/**`, +// `.claude/**`, `.github/**` and the repo-root files are outside this walk. +describe('tree-scoped absence: nothing inside the declared radius still writes emailAndPassword.revokeSessionsOnPasswordReset', () => { + const SPEC_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + const REPO_ROOT = path.resolve(SPEC_ROOT, '../..'); + const THIS_FILE = path.relative(REPO_ROOT, fileURLToPath(import.meta.url)).split(path.sep).join('/'); + + const WALK_ROOTS = ['packages', 'examples', 'skills', 'content', 'scripts']; + const SCANNED_EXT = new Set(['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs', '.json', '.md', '.mdx', '.yaml', '.yml']); + const CODE_EXT = new Set(['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs']); + const EXAMPLES_EXT = new Set(['.json', '.md', '.mdx', '.yaml', '.yml']); + const EXAMPLE_APP_SRC_TS = /^examples\/[^/]+\/src\/.+\.ts$/; + const SKIPPED_DIRS = new Set(['node_modules', 'dist', '.git', '.turbo', '.cache', '.objectstack', 'coverage', '.next', '.source']); + + const EXCLUDED = new Set([ + THIS_FILE, + 'packages/plugins/plugin-auth/src/password-reset-ends-sessions.test.ts', + 'packages/plugins/plugin-auth/src/auth-manager-auth-instance.ts', + ]); + + const JSON_SCHEMA_PROJECTION = 'packages/spec/json-schema/'; + + const IN_KEY_POSITION = /(^|[\s{,(])["']?revokeSessionsOnPasswordReset["']?\s*:(?!\s*retiredKey\()/g; + + const walk = (dir: string, out: string[]) => { + for (const ent of fs.readdirSync(dir, { withFileTypes: true })) { + if (ent.isDirectory()) { + if (!SKIPPED_DIRS.has(ent.name)) walk(path.join(dir, ent.name), out); + } else if (ent.isFile()) { + out.push(path.join(dir, ent.name)); + } + } + }; + + const files = (): string[] => { + const all: string[] = []; + for (const root of WALK_ROOTS) { + const abs = path.join(REPO_ROOT, root); + if (fs.existsSync(abs)) walk(abs, all); + } + return all + .map((abs) => path.relative(REPO_ROOT, abs).split(path.sep).join('/')) + .filter((rel) => { + const ext = path.extname(rel); + if (!SCANNED_EXT.has(ext)) return false; + if (rel.startsWith('examples/')) return EXAMPLES_EXT.has(ext) || EXAMPLE_APP_SRC_TS.test(rel); + return true; + }); + }; + + it('no file writes the retired key', () => { + const scanned = files(); + // Anti-vacuity: the walk reached the trees it claims, including the file + // that declares the tombstone and the plugin that refuses the key. + expect(scanned.length).toBeGreaterThan(1000); + expect(scanned).toContain('packages/spec/src/system/auth-config.zod.ts'); + expect(scanned).toContain('packages/plugins/plugin-auth/src/password-reset-ends-sessions.ts'); + + const offences: string[] = []; + for (const rel of scanned) { + if (EXCLUDED.has(rel) || rel.endsWith('/CHANGELOG.md') || rel.startsWith(JSON_SCHEMA_PROJECTION)) continue; + const raw = fs.readFileSync(path.join(REPO_ROOT, rel), 'utf8'); + if (!raw.includes('revokeSessionsOnPasswordReset')) continue; + const text = CODE_EXT.has(path.extname(rel)) ? maskComments(raw) : raw; + for (const m of text.matchAll(IN_KEY_POSITION)) { + const line = text.slice(0, m.index).split('\n').length; + offences.push(`${rel}:${line}`); + } + } + expect(offences, 'delete the key at each site: a completed password reset always ends every session').toEqual([]); + }); + + it('LIT CONTROL: the matcher finds the authored spellings and passes the tombstone and prose', () => { + const hits = (s: string) => [...s.matchAll(IN_KEY_POSITION)].length; + expect(hits('emailAndPassword: { revokeSessionsOnPasswordReset: true }')).toBe(1); + expect(hits('{ "revokeSessionsOnPasswordReset": false }')).toBe(1); + expect(hits('emailAndPassword:\n revokeSessionsOnPasswordReset: false')).toBe(1); + expect(hits(' revokeSessionsOnPasswordReset: retiredKey(X),')).toBe(0); + expect(hits('the `emailAndPassword.revokeSessionsOnPasswordReset` key')).toBe(0); + expect(hits(maskComments('// revokeSessionsOnPasswordReset: true'))).toBe(0); + // Each excluded file still exists, so an exclusion cannot outlive its reason. + for (const rel of EXCLUDED) expect(fs.existsSync(path.join(REPO_ROOT, rel)), rel).toBe(true); + }); +}); diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index 29c12e2a404..fbf980b353e 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -61,6 +61,7 @@ "src/security/permission-set-managed-by-retirement.test.ts", "src/security/rls-tags-retirement.test.ts", "src/shared/retired-key-migrate-sentence.test.ts", + "src/system/auth-revoke-sessions-on-password-reset-retirement.test.ts", "src/system/compliance-families-retirement.test.ts", "src/system/constants/platform-object-names.test.ts", "src/system/email-template-category-is-system-retirement.test.ts",