|
21 | 21 | * sessions on every reset door; |
22 | 22 | * - an explicit `revokeSessionsOnPasswordReset: false` is refused at |
23 | 23 | * construction and on a configuration patch, where the standing |
24 | | - * configuration keeps ruling; `true` is accepted. |
| 24 | + * configuration keeps ruling; `true` is accepted; |
| 25 | + * - a session a password reset ends leaves a revocation trail: on each reset |
| 26 | + * door every session the account held is still a `sys_session` row |
| 27 | + * afterwards, stamped `revoked_at` and `revoke_reason: password_reset`, and |
| 28 | + * it still answers 401; the control, an administrator's forced reset over |
| 29 | + * the same engine, stamps `admin_password_reset` instead. |
25 | 30 | */ |
26 | 31 |
|
27 | 32 | import { describe, it, expect, afterAll, afterEach, vi } from 'vitest'; |
28 | 33 | import { ObjectQL } from '@objectstack/objectql'; |
29 | 34 | import { SqlDriver } from '@objectstack/driver-sql'; |
30 | 35 | import { AuthManager } from './auth-manager.js'; |
| 36 | +import { runAdminSetUserPassword, type AdminUserEndpointDeps } from './admin-user-endpoints.js'; |
31 | 37 | import { authIdentityObjects } from './manifest.js'; |
32 | 38 | import { assertPasswordResetEndsSessions } from './password-reset-ends-sessions.js'; |
33 | 39 | import { memoizeScrypt } from './__tests__/scrypt-memo.testkit'; |
@@ -209,6 +215,116 @@ describe("a completed phone OTP reset ends the account's other sessions", () => |
209 | 215 | }, 60_000); |
210 | 216 | }); |
211 | 217 |
|
| 218 | +describe('a session a password reset ends leaves a revocation trail', () => { |
| 219 | + type SessionRow = { id: string; expires_at?: unknown; revoked_at?: unknown; revoke_reason?: unknown }; |
| 220 | + |
| 221 | + /** The account's `sys_session` rows, read off the table the way an auditor reads them. */ |
| 222 | + async function sessionRowsOf(engine: ObjectQL): Promise<SessionRow[]> { |
| 223 | + const [user] = (await engine.find('sys_user', { where: { email: EMAIL }, limit: 1 }, SYS)) as Array<{ id: string }>; |
| 224 | + return (await engine.find('sys_session', { where: { user_id: user.id } }, SYS)) as SessionRow[]; |
| 225 | + } |
| 226 | + |
| 227 | + /** The sessions the account holds before the reset: at least one, none of them ended. */ |
| 228 | + async function liveSessionsBefore(a: Account): Promise<SessionRow[]> { |
| 229 | + const before = await sessionRowsOf(a.engine); |
| 230 | + expect(before.length).toBeGreaterThan(0); |
| 231 | + expect(before.map((row) => row.revoked_at ?? null)).toEqual(before.map(() => null)); |
| 232 | + return before; |
| 233 | + } |
| 234 | + |
| 235 | + /** |
| 236 | + * Every session in `before` is still a row, stamped with when and why it |
| 237 | + * ended, and the session signed in before the reset is refused. |
| 238 | + */ |
| 239 | + async function expectTrail(a: Account, before: SessionRow[], reason: string, startedAt: number): Promise<void> { |
| 240 | + const after = new Map((await sessionRowsOf(a.engine)).map((row) => [row.id, row])); |
| 241 | + for (const { id } of before) { |
| 242 | + const row = after.get(id); |
| 243 | + expect({ id, kept: row !== undefined }).toEqual({ id, kept: true }); |
| 244 | + expect(row!.revoke_reason).toBe(reason); |
| 245 | + const revokedAt = new Date(row!.revoked_at as string).getTime(); |
| 246 | + expect(Number.isFinite(revokedAt)).toBe(true); |
| 247 | + // Whole-second storage may round the stamp down, never ahead of now. |
| 248 | + expect(revokedAt).toBeGreaterThanOrEqual(startedAt - 1000); |
| 249 | + expect(revokedAt).toBeLessThanOrEqual(Date.now()); |
| 250 | + expect(new Date(row!.expires_at as string).getTime()).toBeLessThan(Date.now()); |
| 251 | + } |
| 252 | + expect(await answer(await getSession(a.manager, a.earlier))).toEqual({ status: 401, code: 'UNAUTHENTICATED' }); |
| 253 | + } |
| 254 | + |
| 255 | + it('the emailed-link reset (/reset-password) stamps every earlier session password_reset, and the session still answers 401', async () => { |
| 256 | + const a = await signedInAccount(); |
| 257 | + const token = await requestResetToken(a); |
| 258 | + const before = await liveSessionsBefore(a); |
| 259 | + const startedAt = Date.now(); |
| 260 | + |
| 261 | + const reset = await post(a.manager, '/reset-password', { token, newPassword: SECOND }); |
| 262 | + expect(reset.status, await reset.clone().text()).toBe(200); |
| 263 | + |
| 264 | + await expectTrail(a, before, 'password_reset', startedAt); |
| 265 | + }, 60_000); |
| 266 | + |
| 267 | + it('the phone OTP reset (/phone-number/reset-password) stamps every earlier session password_reset, and the session still answers 401', async () => { |
| 268 | + const PHONE = '+15550143'; |
| 269 | + const a = await signedInAccount({ plugins: { phoneNumber: true } }); |
| 270 | + const texts: Array<{ to: string; templateParams?: { code?: string } }> = []; |
| 271 | + a.manager.setSmsService({ |
| 272 | + async send(input: { to: string; templateParams?: { code?: string } }) { |
| 273 | + texts.push(input); |
| 274 | + return { id: `sms${texts.length}`, status: 'sent' }; |
| 275 | + }, |
| 276 | + isConfigured: () => true, |
| 277 | + } as never); |
| 278 | + const [user] = (await a.engine.find('sys_user', { where: { email: EMAIL }, limit: 1 }, SYS)) as Array<{ id: string }>; |
| 279 | + await a.engine.update('sys_user', { id: user.id, phone_number: PHONE, phone_number_verified: true }, SYS); |
| 280 | + const requested = await post(a.manager, '/phone-number/request-password-reset', { phoneNumber: PHONE }); |
| 281 | + expect(requested.status, await requested.clone().text()).toBe(200); |
| 282 | + await vi.waitFor(() => expect(texts.map((t) => t.to)).toEqual([PHONE])); |
| 283 | + const otp = String(texts[0].templateParams?.code ?? ''); |
| 284 | + expect(otp, 'the reset text carries its code').not.toBe(''); |
| 285 | + const before = await liveSessionsBefore(a); |
| 286 | + const startedAt = Date.now(); |
| 287 | + |
| 288 | + const reset = await post(a.manager, '/phone-number/reset-password', { phoneNumber: PHONE, otp, newPassword: SECOND }); |
| 289 | + expect(reset.status, await reset.clone().text()).toBe(200); |
| 290 | + |
| 291 | + await expectTrail(a, before, 'password_reset', startedAt); |
| 292 | + }, 60_000); |
| 293 | + |
| 294 | + it("the control: an administrator's forced reset still stamps admin_password_reset, so the value says who ended the session", async () => { |
| 295 | + const a = await signedInAccount(); |
| 296 | + const before = await liveSessionsBefore(a); |
| 297 | + const [user] = (await a.engine.find('sys_user', { where: { email: EMAIL }, limit: 1 }, SYS)) as Array<{ id: string }>; |
| 298 | + const m = a.manager; |
| 299 | + const quiet = { warn() {}, info() {}, error() {}, debug() {} }; |
| 300 | + // The deps `auth-plugin.ts` hands the forced-reset door, built from the same manager. |
| 301 | + const deps = { |
| 302 | + getAuthApi: () => m.getApi(), |
| 303 | + getAuthContext: () => m.getAuthContext(), |
| 304 | + getDataEngine: () => m.getDataEngine(), |
| 305 | + assertPasswordComplexity: (pw: string) => m.checkPasswordComplexity(pw), |
| 306 | + noteMustChangePasswordIssued: () => m.noteMustChangePasswordIssued(), |
| 307 | + phoneNumberEnabled: () => m.isPhoneNumberEnabled(), |
| 308 | + getMembershipPolicy: () => m.getMembershipPolicy(), |
| 309 | + logger: quiet, |
| 310 | + } as unknown as AdminUserEndpointDeps; |
| 311 | + const startedAt = Date.now(); |
| 312 | + |
| 313 | + const forced = await runAdminSetUserPassword( |
| 314 | + deps, |
| 315 | + new Request(`${AUTH}/admin/set-user-password`, { |
| 316 | + method: 'POST', |
| 317 | + headers: { 'Content-Type': 'application/json', origin: BASE }, |
| 318 | + body: JSON.stringify({ userId: user.id, newPassword: SECOND }), |
| 319 | + }), |
| 320 | + { id: 'admin-control', email: 'admin-control@example.com' }, |
| 321 | + ); |
| 322 | + expect(forced.status, JSON.stringify(forced.body)).toBe(200); |
| 323 | + |
| 324 | + await expectTrail(a, before, 'admin_password_reset', startedAt); |
| 325 | + }, 60_000); |
| 326 | +}); |
| 327 | + |
212 | 328 | describe('revokeSessionsOnPasswordReset: false is refused, never silently overridden', () => { |
213 | 329 | it('is refused at construction', () => { |
214 | 330 | expect( |
|
0 commit comments