Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/29909-revoke-sessions-on-password-reset-retired.md
Original file line number Diff line number Diff line change
@@ -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

<!-- adr-0087: registered email-and-password-revoke-sessions-on-reset-retired -->

**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.
6 changes: 4 additions & 2 deletions content/docs/permissions/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/system/auth-config.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down Expand Up @@ -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. |


---
Expand Down
4 changes: 2 additions & 2 deletions docs/HARDENING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -417,8 +417,10 @@ export async function createAuthInstance(this: AuthInstanceHost): Promise<Auth<a
...(this.config.emailAndPassword?.autoSignIn != null
? { autoSignIn: this.config.emailAndPassword.autoSignIn } : {}),
// A completed password reset ends the account's other sessions, on
// every reset door, always — not a deployment option. An explicit
// `false` never reaches here: the manager refuses it at entry.
// every reset door, always — not a deployment option. This is
// better-auth's own option, set here and never read from the host
// configuration: the spec key of the same name is retired, and the
// manager refuses it at entry at any value.
// `password-reset-ends-sessions.ts` holds the rule and the wiring.
revokeSessionsOnPasswordReset: true,
// #14319 — better-auth calls this as `sendResetPassword(data, ctx.request)`.
Expand Down
5 changes: 3 additions & 2 deletions packages/plugins/plugin-auth/src/auth-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2057,7 +2057,6 @@ describe('AuthManager', () => {
maxPasswordLength: 64,
requireEmailVerification: true,
autoSignIn: false,
revokeSessionsOnPasswordReset: true,
},
});
await manager.getAuthInstance();
Expand All @@ -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');
});
});
Expand Down
11 changes: 6 additions & 5 deletions packages/plugins/plugin-auth/src/auth-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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';
Expand All @@ -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();
Expand Down Expand Up @@ -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<string, unknown> } }).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<string, unknown> } }).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();
});
});
55 changes: 31 additions & 24 deletions packages/plugins/plugin-auth/src/password-reset-ends-sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<NonNullable<EmailAndPasswordConfig>, '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<string, unknown>)[RETIRED_KEY] !== undefined
) {
throw new Error(`[auth] ${REVOKE_SESSIONS_ON_PASSWORD_RESET_RETIRED}`);
}
}
1 change: 1 addition & 0 deletions packages/spec/api-surface/system.json
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,7 @@
"QueueConfigSchema (const)",
"QuotaEnforcementResult (type)",
"QuotaEnforcementResultSchema (const)",
"REVOKE_SESSIONS_ON_PASSWORD_RESET_RETIRED (const)",
"RPO (type)",
"RPOParsed (type)",
"RPOSchema (const)",
Expand Down
Loading
Loading