@udibo/oauth2 0.37.0 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -118,18 +118,39 @@ export type IdentityEvent = {
118
118
  } | {
119
119
  type: "password_reset.completed";
120
120
  userId: string;
121
+ /**
122
+ * Sessions the reset revoked: the sum of what
123
+ * `IdentityUserStore.resetCredential` and the `sessions` service
124
+ * reported. Absent when neither ran.
125
+ */
126
+ sessionsRevoked?: number;
121
127
  } | {
122
128
  type: "password_reset.failed";
123
129
  /**
124
130
  * `"invalid_token"` — the reset link was unknown, expired, or already used.
125
- * `"session_revocation_failed"` — the new password was set but the
126
- * configured {@link RevocableSessionService} threw before the user's other
127
- * sessions could be revoked, so the reset is reported failed (and rethrown)
128
- * rather than trusted. The reset token is already spent, so clear the
129
- * still-live sessions by calling `revokeAllByUser` again from this event,
130
- * or have the user complete a fresh reset link.
131
+ * Nothing changed.
132
+ *
133
+ * The other reasons fire after the link was spent, and the error is
134
+ * rethrown:
135
+ *
136
+ * - `"credential_write_failed"` — hashing or storing the new password
137
+ * threw. The old password still works and no session was revoked; the
138
+ * user requests a fresh link.
139
+ * - `"session_revocation_failed"` — the new password was set but the
140
+ * configured `RevocableSessionService` threw before the user's other
141
+ * sessions could be revoked. Call `revokeAllByUser(userId)` again.
142
+ * - `"lockout_reset_failed"` — the password changed and the sessions were
143
+ * revoked, but clearing the failed-attempt lockout threw. Call
144
+ * `reset(userId)` on the lockout, or let the lock lapse.
145
+ *
146
+ * A store that implements `IdentityUserStore.resetCredential` writes the
147
+ * password and revokes the sessions it holds together. A
148
+ * `"session_revocation_failed"` after that means the stored sessions
149
+ * were revoked and only the `sessions` service's own state was not.
131
150
  */
132
- reason: "invalid_token" | "session_revocation_failed";
151
+ reason: "invalid_token" | "credential_write_failed" | "session_revocation_failed" | "lockout_reset_failed";
152
+ /** The account the spent link belonged to; absent for `"invalid_token"`. */
153
+ userId?: string;
133
154
  } | {
134
155
  type: "credential_mint.failed";
135
156
  /**
@@ -69,7 +69,9 @@ export interface MfaStore {
69
69
  getRecoveryHashes(userId: string): Promise<string[]>;
70
70
  /**
71
71
  * Replace the user's recovery-code hashes with exactly this set — used for
72
- * initial issue, regeneration, and disable (`[]`).
72
+ * initial issue, regeneration, and disable (`[]`). `MfaService` issues codes
73
+ * only once the TOTP credential is active, so a store may keep recovery
74
+ * hashes on the credential's record and drop a write for a user without one.
73
75
  */
74
76
  setRecoveryHashes(userId: string, hashes: string[]): Promise<void>;
75
77
  /**
@@ -189,7 +191,9 @@ export type MfaVerification = {
189
191
  method: "recovery";
190
192
  /**
191
193
  * How many unused recovery codes remain — surface it so the UI can
192
- * prompt regeneration when low.
194
+ * prompt regeneration when low. Counted from the set read just before
195
+ * this code was burned, so a code burned concurrently is not yet
196
+ * subtracted.
193
197
  */
194
198
  remainingRecoveryCodes: number;
195
199
  };
@@ -285,7 +289,9 @@ export declare class MfaService {
285
289
  * accepted TOTP code advances the stored last step through
286
290
  * {@link MfaStore.advanceLastStep}, so the same code is rejected on replay;
287
291
  * an accepted recovery code is burned through
288
- * {@link MfaStore.consumeRecoveryHash}, so it is single-use. Both hold under
292
+ * {@link MfaStore.consumeRecoveryHash}, so it is single-use. The remaining
293
+ * codes are read before the burn, so a store failure never spends a code
294
+ * without signing the user in. Both hold under
289
295
  * concurrent submission only if the store implements those methods
290
296
  * atomically. Without an
291
297
  * active credential every code is rejected — recovery codes are a fallback
@@ -153,7 +153,9 @@ export class MfaService {
153
153
  * accepted TOTP code advances the stored last step through
154
154
  * {@link MfaStore.advanceLastStep}, so the same code is rejected on replay;
155
155
  * an accepted recovery code is burned through
156
- * {@link MfaStore.consumeRecoveryHash}, so it is single-use. Both hold under
156
+ * {@link MfaStore.consumeRecoveryHash}, so it is single-use. The remaining
157
+ * codes are read before the burn, so a store failure never spends a code
158
+ * without signing the user in. Both hold under
157
159
  * concurrent submission only if the store implements those methods
158
160
  * atomically. Without an
159
161
  * active credential every code is rejected — recovery codes are a fallback
@@ -255,11 +257,12 @@ export class MfaService {
255
257
  if (normalizeRecoveryCode(code).length !== RECOVERY_CODE_LENGTH) {
256
258
  return undefined;
257
259
  }
258
- const consumed = await this.#store.consumeRecoveryHash(userId, await hashRecoveryCode(code));
259
- if (!consumed)
260
+ const hash = await hashRecoveryCode(code);
261
+ const stored = await this.#store.getRecoveryHashes(userId);
262
+ if (!(await this.#store.consumeRecoveryHash(userId, hash))) {
260
263
  return undefined;
261
- const remainingRecoveryCodes = (await this.#store.getRecoveryHashes(userId))
262
- .length;
264
+ }
265
+ const remainingRecoveryCodes = stored.filter((candidate) => candidate !== hash).length;
263
266
  await this.#resetThrottle(userId);
264
267
  await this.#emit({
265
268
  type: "mfa.verify.succeeded",
@@ -60,6 +60,22 @@ export interface IdentityUserStore<User extends IdentityUser> {
60
60
  getCredential(userId: string): Promise<PasswordCredential | undefined>;
61
61
  /** Replace the user's password credential (reset / change). */
62
62
  setCredential(userId: string, credential: PasswordCredential): Promise<void>;
63
+ /**
64
+ * Complete a password reset in one transaction: replace the credential,
65
+ * drop any imported hash, and revoke **every** session of the user. Return
66
+ * the number of sessions revoked. On a throw, none of the three may have
67
+ * happened.
68
+ *
69
+ * Optional. Implement it when the credential and the sessions share a
70
+ * database, so a session-store outage cannot leave a reset with the new
71
+ * password set and the old sessions live. When present,
72
+ * {@link IdentityService.resetPassword} calls it instead of
73
+ * {@link setCredential} and {@link clearLegacyCredential}. The reset link is
74
+ * still spent first. A configured `sessions` service is still called after
75
+ * this commits, for session state outside the transaction, such as a cache.
76
+ * Its `revokeAllByUser` then finds nothing left in the shared database.
77
+ */
78
+ resetCredential?(userId: string, credential: PasswordCredential): Promise<number>;
63
79
  /**
64
80
  * Atomically replace the credential only if it still equals `expected`.
65
81
  * `undefined` expects no native credential, for an imported-password upgrade.
@@ -188,7 +204,13 @@ export interface IdentityServiceOptions<User extends IdentityUser> {
188
204
  * Successful sign-ins are never delayed.
189
205
  */
190
206
  failedSignInFloorMs?: number;
191
- /** Session revocation, called on a successful password reset. Optional. */
207
+ /**
208
+ * Session revocation, called on a successful password reset. Optional. When
209
+ * the user store implements {@link IdentityUserStore.resetCredential}, the
210
+ * reset still calls `revokeAllByUser` after that transaction commits, so
211
+ * session state the transaction cannot reach is revoked too. Omit it when
212
+ * the store's transaction already covers every place a session lives.
213
+ */
192
214
  sessions?: RevocableSessionService;
193
215
  /** Delivery transports for the emailed links and sign-in codes. Optional. */
194
216
  delivery?: DeliveryHooks;
@@ -468,19 +490,27 @@ export declare class IdentityService<User extends IdentityUser> {
468
490
  * redeemable until they expire) and, when an `otp` service is configured, the
469
491
  * pending sign-in code. Neither can fail the reset: a store that throws is
470
492
  * logged and the reset still completes, since the password has already
471
- * changed by then.
493
+ * changed by then. Each is voided independently, so one store failing does
494
+ * not keep the other credential alive.
472
495
  *
473
496
  * The token is consumed **first**, before the password is written, so two
474
497
  * concurrent submissions of one link resolve to exactly one `{ userId }` and
475
498
  * only that call's password is set — the loser gets `null` and writes
476
- * nothing. Sessions are revoked **after** the password is changed. If that
477
- * revocation throws, the reset is reported failed — a `password_reset.failed`
478
- * (`session_revocation_failed`) event fires, no `password_reset.completed`
479
- * fires, and the error rethrows — rather than silently returning success
480
- * with the user's old sessions still live. The token is already spent and
481
- * the new password already set by then, so the operator clears the still-live
482
- * sessions by calling `revokeAllByUser` again (subscribe to the event), or
483
- * the user completes a fresh reset link.
499
+ * nothing. Any failure after that point is reported, never swallowed: a
500
+ * `password_reset.failed` event carrying the `userId` and a reason
501
+ * (`credential_write_failed`, `session_revocation_failed` or
502
+ * `lockout_reset_failed`) fires, no `password_reset.completed` fires, and the
503
+ * error rethrows. Each reason names what already changed and how to finish
504
+ * the reset; see the event's documentation.
505
+ *
506
+ * Without {@link IdentityUserStore.resetCredential}, sessions are revoked
507
+ * **after** the password is changed, in a separate call, so a session-store
508
+ * outage leaves the new password set and the old sessions live. A store
509
+ * that implements it makes the password write and the revocation one
510
+ * transaction; a configured `sessions` service then runs after the commit
511
+ * and can still report `session_revocation_failed` for the state outside
512
+ * it. `password_reset.completed` carries `sessionsRevoked`, the sum of the
513
+ * counts the store and the `sessions` service reported.
484
514
  *
485
515
  * @throws {IdentityError} `weak_password` when `password` fails the password
486
516
  * policy — including the 8–256 character defaults that apply when
@@ -602,7 +632,9 @@ export declare class IdentityService<User extends IdentityUser> {
602
632
  * the `signin_code.failed` event carries `locked`. Success consumes the code
603
633
  * (single-use), resets the flow's throttle window, and carries the `userId`
604
634
  * to sign in — session creation and your MFA gate stay your app's job, as
605
- * does treating a since-deleted/disabled user as a failed sign-in.
635
+ * does treating a since-deleted/disabled user as a failed sign-in. A
636
+ * throttle reset that throws is logged and the result is still `success`,
637
+ * since the code is already spent.
606
638
  */
607
639
  verifySignInCode(input: {
608
640
  email: string;
@@ -258,19 +258,27 @@ export class IdentityService {
258
258
  * redeemable until they expire) and, when an `otp` service is configured, the
259
259
  * pending sign-in code. Neither can fail the reset: a store that throws is
260
260
  * logged and the reset still completes, since the password has already
261
- * changed by then.
261
+ * changed by then. Each is voided independently, so one store failing does
262
+ * not keep the other credential alive.
262
263
  *
263
264
  * The token is consumed **first**, before the password is written, so two
264
265
  * concurrent submissions of one link resolve to exactly one `{ userId }` and
265
266
  * only that call's password is set — the loser gets `null` and writes
266
- * nothing. Sessions are revoked **after** the password is changed. If that
267
- * revocation throws, the reset is reported failed — a `password_reset.failed`
268
- * (`session_revocation_failed`) event fires, no `password_reset.completed`
269
- * fires, and the error rethrows — rather than silently returning success
270
- * with the user's old sessions still live. The token is already spent and
271
- * the new password already set by then, so the operator clears the still-live
272
- * sessions by calling `revokeAllByUser` again (subscribe to the event), or
273
- * the user completes a fresh reset link.
267
+ * nothing. Any failure after that point is reported, never swallowed: a
268
+ * `password_reset.failed` event carrying the `userId` and a reason
269
+ * (`credential_write_failed`, `session_revocation_failed` or
270
+ * `lockout_reset_failed`) fires, no `password_reset.completed` fires, and the
271
+ * error rethrows. Each reason names what already changed and how to finish
272
+ * the reset; see the event's documentation.
273
+ *
274
+ * Without {@link IdentityUserStore.resetCredential}, sessions are revoked
275
+ * **after** the password is changed, in a separate call, so a session-store
276
+ * outage leaves the new password set and the old sessions live. A store
277
+ * that implements it makes the password write and the revocation one
278
+ * transaction; a configured `sessions` service then runs after the commit
279
+ * and can still report `session_revocation_failed` for the state outside
280
+ * it. `password_reset.completed` carries `sessionsRevoked`, the sum of the
281
+ * counts the store and the `sessions` service reported.
274
282
  *
275
283
  * @throws {IdentityError} `weak_password` when `password` fails the password
276
284
  * policy — including the 8–256 character defaults that apply when
@@ -293,33 +301,55 @@ export class IdentityService {
293
301
  });
294
302
  if (consumed.status !== "success")
295
303
  return null;
296
- const { resolved } = consumed;
297
- await this.#users.setCredential(resolved.subject, await this.#passwords.hash(input.password));
298
- try {
299
- await this.#users.clearLegacyCredential?.(resolved.subject);
300
- }
301
- catch (error) {
302
- console.error("[@udibo/oauth2] resetPassword legacy-credential clear failed:", error instanceof Error ? error.message : error);
303
- }
304
- await this.#voidPasswordlessCredentials(tokens, resolved.subject, emailFromData(resolved.data));
305
- if (this.#sessions) {
306
- try {
307
- await this.#sessions.revokeAllByUser(resolved.subject);
304
+ const userId = consumed.resolved.subject;
305
+ const resetCredential = this.#users.resetCredential;
306
+ let sessionsRevoked;
307
+ await this.#failResetOnThrow(userId, "credential_write_failed", async () => {
308
+ const credential = await this.#passwords.hash(input.password);
309
+ if (resetCredential) {
310
+ sessionsRevoked = await resetCredential.call(this.#users, userId, credential);
308
311
  }
309
- catch (error) {
310
- await this.#emit({
311
- type: "password_reset.failed",
312
- reason: "session_revocation_failed",
313
- });
314
- throw error;
312
+ else {
313
+ await this.#users.setCredential(userId, credential);
315
314
  }
315
+ });
316
+ if (!resetCredential)
317
+ await this.#clearLegacyAfterReset(userId);
318
+ await this.#voidPasswordlessCredentials(tokens, userId, emailFromData(consumed.resolved.data));
319
+ const sessions = this.#sessions;
320
+ if (sessions) {
321
+ await this.#failResetOnThrow(userId, "session_revocation_failed", async () => {
322
+ sessionsRevoked =
323
+ (sessionsRevoked ?? 0) + (await sessions.revokeAllByUser(userId));
324
+ });
325
+ }
326
+ const lockout = this.#lockout;
327
+ if (lockout) {
328
+ await this.#failResetOnThrow(userId, "lockout_reset_failed", () => lockout.reset(userId));
316
329
  }
317
- await this.#lockout?.reset(resolved.subject);
318
330
  await this.#emit({
319
331
  type: "password_reset.completed",
320
- userId: resolved.subject,
332
+ userId,
333
+ ...(sessionsRevoked === undefined ? {} : { sessionsRevoked }),
321
334
  });
322
- return { userId: resolved.subject };
335
+ return { userId };
336
+ }
337
+ async #failResetOnThrow(userId, reason, step) {
338
+ try {
339
+ await step();
340
+ }
341
+ catch (error) {
342
+ await this.#emit({ type: "password_reset.failed", reason, userId });
343
+ throw error;
344
+ }
345
+ }
346
+ async #clearLegacyAfterReset(userId) {
347
+ try {
348
+ await this.#users.clearLegacyCredential?.(userId);
349
+ }
350
+ catch (error) {
351
+ console.error("[@udibo/oauth2] resetPassword legacy-credential clear failed:", error instanceof Error ? error.message : error);
352
+ }
323
353
  }
324
354
  /**
325
355
  * Mint + deliver an email-verification link for a user. Typically called
@@ -565,7 +595,9 @@ export class IdentityService {
565
595
  * the `signin_code.failed` event carries `locked`. Success consumes the code
566
596
  * (single-use), resets the flow's throttle window, and carries the `userId`
567
597
  * to sign in — session creation and your MFA gate stay your app's job, as
568
- * does treating a since-deleted/disabled user as a failed sign-in.
598
+ * does treating a since-deleted/disabled user as a failed sign-in. A
599
+ * throttle reset that throws is logged and the result is still `success`,
600
+ * since the code is already spent.
569
601
  */
570
602
  async verifySignInCode(input) {
571
603
  const otp = this.#requireOtp("verifySignInCode");
@@ -593,7 +625,12 @@ export class IdentityService {
593
625
  });
594
626
  return { status: "invalid" };
595
627
  }
596
- await this.#resetThrottle("signInCode", email);
628
+ try {
629
+ await this.#resetThrottle("signInCode", email);
630
+ }
631
+ catch (error) {
632
+ this.#logPersistFailure("sign-in code throttle reset", error);
633
+ }
597
634
  await this.#emit({ type: "signin_code.verified", userId: user.id });
598
635
  return { status: "success", userId: user.id };
599
636
  }
@@ -719,14 +756,10 @@ export class IdentityService {
719
756
  });
720
757
  }
721
758
  async #voidPasswordlessCredentials(tokens, subject, email) {
722
- try {
723
- await tokens.invalidate(TokenPurpose.SignIn, subject);
724
- if (email)
725
- await this.#otp?.invalidate(email, TokenPurpose.SignIn);
726
- }
727
- catch (error) {
728
- console.error("[@udibo/oauth2] resetPassword could not void the outstanding " +
729
- "passwordless credentials; they stay usable until they expire:", error instanceof Error ? error.message : error);
759
+ await logVoidFailure("sign-in links", () => tokens.invalidate(TokenPurpose.SignIn, subject));
760
+ const otp = this.#otp;
761
+ if (email && otp) {
762
+ await logVoidFailure("sign-in code", () => otp.invalidate(email, TokenPurpose.SignIn));
730
763
  }
731
764
  }
732
765
  async #trapMintFailure(flow, mint) {
@@ -854,6 +887,15 @@ const DELIVERY_PURPOSES = {
854
887
  sendEmailVerification: TokenPurpose.EmailVerification,
855
888
  sendSignInLink: TokenPurpose.SignIn,
856
889
  };
890
+ async function logVoidFailure(credential, invalidate) {
891
+ try {
892
+ await invalidate();
893
+ }
894
+ catch (error) {
895
+ console.error(`[@udibo/oauth2] resetPassword could not void the outstanding ` +
896
+ `${credential}, usable until expiry:`, error instanceof Error ? error.message : error);
897
+ }
898
+ }
857
899
  function emailFromData(data) {
858
900
  const email = data?.email;
859
901
  return typeof email === "string" ? email : undefined;
@@ -30,6 +30,18 @@ export interface IdentityUserStoreContractFixture<User extends IdentityUser> {
30
30
  isEmailVerified?(userId: string): Promise<boolean>;
31
31
  /** Seed an imported hash. Required for legacyCredentials. */
32
32
  setLegacyCredential?(userId: string, hash: string): Promise<void>;
33
+ /** Start a live session for the user and return its id. Required for resetCredential. */
34
+ startSession?(userId: string): Promise<string>;
35
+ /** Whether the application would still accept the session. Required for resetCredential. */
36
+ isSessionLive?(sessionId: string): Promise<boolean>;
37
+ /**
38
+ * Make the next resetCredential call throw at `step`, after the other
39
+ * writes have had their chance to run inside the transaction. Required for
40
+ * resetCredential: the suite calls it once for each step (`"legacy"` only
41
+ * with legacyCredentials) to prove that no write survives another failing,
42
+ * whichever order your transaction runs them in.
43
+ */
44
+ failNextReset?(step: "credential" | "legacy" | "sessions"): Promise<void> | void;
33
45
  /** Release resources and remove this test's records after all calls finish. */
34
46
  dispose?(): Promise<void> | void;
35
47
  }
@@ -45,6 +57,12 @@ export interface IdentityUserStoreContractOptions<User extends IdentityUser> {
45
57
  emailVerification?: boolean;
46
58
  /** Require imported credential lookup and clearing, plus its setup helper. */
47
59
  legacyCredentials?: boolean;
60
+ /**
61
+ * Require the transactional password-reset capability and its session and
62
+ * fault-injection helpers. With legacyCredentials, also checks that the
63
+ * reset drops the imported hash.
64
+ */
65
+ resetCredential?: boolean;
48
66
  }
49
67
  /**
50
68
  * Register the IdentityUserStore suite in a Vitest test file. Credential fixtures
@@ -137,6 +137,60 @@ export function runIdentityUserStoreContractTests(options) {
137
137
  expect(await store.getCredential(other.id)).toStrictEqual(ORIGINAL);
138
138
  });
139
139
  }
140
+ if (options.resetCredential) {
141
+ async function resetSetup() {
142
+ assert.exists(store.resetCredential, "resetCredential capability is required");
143
+ assert.exists(fixture.startSession, "startSession setup helper is required");
144
+ assert.exists(fixture.isSessionLive, "isSessionLive setup helper is required");
145
+ const user = await create();
146
+ const other = await create(2);
147
+ if (options.legacyCredentials) {
148
+ assert.exists(fixture.setLegacyCredential);
149
+ await fixture.setLegacyCredential(user.id, "imported-hash-one");
150
+ }
151
+ return {
152
+ user,
153
+ other,
154
+ sessions: [
155
+ await fixture.startSession(user.id),
156
+ await fixture.startSession(user.id),
157
+ ],
158
+ otherSession: await fixture.startSession(other.id),
159
+ };
160
+ }
161
+ async function liveness(ids) {
162
+ return await Promise.all(ids.map((id) => fixture.isSessionLive(id)));
163
+ }
164
+ it("resets the credential and revokes every session of the user, and only that user", async () => {
165
+ const { user, other, sessions, otherSession } = await resetSetup();
166
+ expect(await store.resetCredential(user.id, structuredClone(REPLACEMENT)), "resetCredential must report the sessions it revoked").toStrictEqual(2);
167
+ expect(await store.getCredential(user.id)).toStrictEqual(REPLACEMENT);
168
+ expect(await liveness(sessions), "resetCredential revokes every session of the user").toStrictEqual([false, false]);
169
+ if (options.legacyCredentials) {
170
+ expect(await store.getLegacyCredential(user.id), "resetCredential drops the imported hash").toBe(null);
171
+ }
172
+ expect(await store.getCredential(other.id)).toStrictEqual(ORIGINAL);
173
+ expect(await fixture.isSessionLive(otherSession), "resetCredential must not revoke another user's sessions").toBe(true);
174
+ expect(await store.resetCredential(user.id, structuredClone(REPLACEMENT))).toStrictEqual(0);
175
+ });
176
+ const steps = options.legacyCredentials
177
+ ? ["credential", "legacy", "sessions"]
178
+ : ["credential", "sessions"];
179
+ for (const step of steps) {
180
+ it(`changes nothing when the reset fails at the ${step} step`, async () => {
181
+ const { user, sessions } = await resetSetup();
182
+ assert.exists(fixture.failNextReset, "failNextReset fault-injection helper is required");
183
+ await fixture.failNextReset(step);
184
+ const outcome = await store.resetCredential(user.id, structuredClone(REPLACEMENT)).then(() => "resolved", () => "rejected");
185
+ expect(outcome, "resetCredential must reject when failNextReset injected a failure").toStrictEqual("rejected");
186
+ expect(await store.getCredential(user.id), "a failed reset must leave the credential unchanged").toStrictEqual(ORIGINAL);
187
+ expect(await liveness(sessions), "a failed reset must leave the sessions live").toStrictEqual([true, true]);
188
+ if (options.legacyCredentials) {
189
+ expect(await store.getLegacyCredential(user.id), "a failed reset must leave the imported hash in place").toStrictEqual("imported-hash-one");
190
+ }
191
+ });
192
+ }
193
+ }
140
194
  if (options.emailVerification) {
141
195
  it("verifies only the issued-for email and is idempotent", async () => {
142
196
  const user = await create();
@@ -31,6 +31,17 @@ export interface LockoutStoreContractOptions {
31
31
  * asserts.
32
32
  */
33
33
  makeStore(): Promise<LockoutStore> | LockoutStore;
34
+ /**
35
+ * The account the cases act on, in your application's id format. A store
36
+ * that keeps the count on the user's own row needs it to name a user that
37
+ * exists. Defaults to `"user-1"`.
38
+ */
39
+ userId?: string;
40
+ /**
41
+ * A second account, distinct from {@link LockoutStoreContractOptions.userId},
42
+ * whose count must stay apart from the first. Defaults to `"user-2"`.
43
+ */
44
+ otherUserId?: string;
34
45
  /**
35
46
  * Overrides the name passed to the outer `describe` block. Defaults to
36
47
  * `"LockoutStore contract"`.
@@ -23,8 +23,6 @@
23
23
  * @module
24
24
  */
25
25
  import { beforeEach, describe, expect, it } from "vitest";
26
- const USER = "user-1";
27
- const OTHER_USER = "user-2";
28
26
  /**
29
27
  * Runs the {@link LockoutStore} contract suite. Call it from your own test
30
28
  * file — it registers `describe` / `it` blocks the test runner picks up.
@@ -33,6 +31,8 @@ const OTHER_USER = "user-2";
33
31
  * clock-free and never need a timer.
34
32
  */
35
33
  export function runLockoutStoreContractTests(options) {
34
+ const USER = options.userId ?? "user-1";
35
+ const OTHER_USER = options.otherUserId ?? "user-2";
36
36
  describe(options.describeName ?? "LockoutStore contract", () => {
37
37
  let store;
38
38
  let now;
@@ -32,6 +32,17 @@ export interface MfaStoreContractOptions {
32
32
  * meaningless.
33
33
  */
34
34
  makeStore(): Promise<MfaStore> | MfaStore;
35
+ /**
36
+ * The user the cases act on, in your application's id format. A store whose
37
+ * rows reference a users table needs it to name a user that exists. Defaults
38
+ * to `"user-1"`.
39
+ */
40
+ userId?: string;
41
+ /**
42
+ * A second user, distinct from {@link MfaStoreContractOptions.userId}, whose
43
+ * records must stay apart from the first. Defaults to `"user-2"`.
44
+ */
45
+ otherUserId?: string;
35
46
  /**
36
47
  * Overrides the name passed to the outer `describe` block. Defaults to
37
48
  * `"MfaStore contract"`.
@@ -25,8 +25,6 @@
25
25
  */
26
26
  import { beforeEach, describe, expect, it } from "vitest";
27
27
  import { CONCURRENT_CALLERS, race } from "./_race.js";
28
- const USER = "user-1";
29
- const OTHER_USER = "user-2";
30
28
  const SECRET_A = "AAAAAAAAAAAAAAAA";
31
29
  const SECRET_B = "BBBBBBBBBBBBBBBB";
32
30
  function countTrue(results) {
@@ -43,6 +41,8 @@ function countTrue(results) {
43
41
  * probabilistic, so a lucky interleaving can still pass a single run.
44
42
  */
45
43
  export function runMfaStoreContractTests(options) {
44
+ const USER = options.userId ?? "user-1";
45
+ const OTHER_USER = options.otherUserId ?? "user-2";
46
46
  describe(options.describeName ?? "MfaStore contract", () => {
47
47
  let store;
48
48
  beforeEach(async () => {
@@ -165,7 +165,19 @@ export function runMfaStoreContractTests(options) {
165
165
  expect((await store.getTotp(USER))?.lastStep, "the highest accepted step must survive a concurrent lower one").toStrictEqual(105);
166
166
  });
167
167
  });
168
+ describe("recovery hashes for a user without a credential", () => {
169
+ it("reads none and redeems none", async () => {
170
+ expect(await store.getRecoveryHashes(OTHER_USER)).toStrictEqual([]);
171
+ expect(await store.consumeRecoveryHash(OTHER_USER, "h1")).toBe(false);
172
+ });
173
+ });
168
174
  describe("recovery hashes", () => {
175
+ beforeEach(async () => {
176
+ for (const user of [USER, OTHER_USER]) {
177
+ await store.setPendingTotp(user, SECRET_A);
178
+ await store.activateTotp(user, SECRET_A, 1);
179
+ }
180
+ });
169
181
  it("returns an empty list for a user with none", async () => {
170
182
  expect(await store.getRecoveryHashes(USER)).toStrictEqual([]);
171
183
  });
@@ -41,6 +41,9 @@ export interface OtpStoreContractOptions {
41
41
  * Runs the {@link OtpStore} contract suite. Call it from your own test file —
42
42
  * it registers `describe` / `it` blocks the test runner picks up.
43
43
  *
44
+ * Record ids, known and unknown alike, are UUIDs — the ids `EmailOtpService`
45
+ * issues — so a store with a `uuid` id column accepts every one.
46
+ *
44
47
  * The concurrency case fires overlapping `recordAttempt` calls and requires
45
48
  * each to see a distinct count; a store that reads, awaits, then writes hands
46
49
  * the same count to every guess and fails it.
@@ -28,11 +28,10 @@ const EMAIL = "user@example.com";
28
28
  const OTHER_EMAIL = "other@example.com";
29
29
  const PURPOSE = "signin";
30
30
  const OTHER_PURPOSE = "step-up";
31
- let sequence = 0;
32
31
  function record(overrides = {}) {
33
32
  const now = Date.now();
34
33
  return {
35
- id: `otp-${++sequence}`,
34
+ id: crypto.randomUUID(),
36
35
  email: EMAIL,
37
36
  purpose: PURPOSE,
38
37
  codeHash: "hash",
@@ -47,6 +46,9 @@ function record(overrides = {}) {
47
46
  * Runs the {@link OtpStore} contract suite. Call it from your own test file —
48
47
  * it registers `describe` / `it` blocks the test runner picks up.
49
48
  *
49
+ * Record ids, known and unknown alike, are UUIDs — the ids `EmailOtpService`
50
+ * issues — so a store with a `uuid` id column accepts every one.
51
+ *
50
52
  * The concurrency case fires overlapping `recordAttempt` calls and requires
51
53
  * each to see a distinct count; a store that reads, awaits, then writes hands
52
54
  * the same count to every guess and fails it.
@@ -100,7 +102,7 @@ export function runOtpStoreContractTests(options) {
100
102
  const results = await race(() => store.consume(created.id));
101
103
  expect(results.filter((claimed) => claimed).length, "a code is single-use — consumers racing one code must not each " +
102
104
  "be told they claimed it").toBe(1);
103
- expect(await store.consume("missing-code")).toBe(false);
105
+ expect(await store.consume(crypto.randomUUID())).toBe(false);
104
106
  });
105
107
  it("takes the record out of the active set", async () => {
106
108
  const created = record();
@@ -120,7 +122,7 @@ export function runOtpStoreContractTests(options) {
120
122
  it("is a no-op for an unknown id", async () => {
121
123
  const created = record();
122
124
  await store.create(created);
123
- await store.consume("no-such-id");
125
+ await store.consume(crypto.randomUUID());
124
126
  expect((await store.findActive(EMAIL, PURPOSE))?.id).toBe(created.id);
125
127
  });
126
128
  });
@@ -138,7 +140,7 @@ export function runOtpStoreContractTests(options) {
138
140
  it("is a no-op for an unknown id", async () => {
139
141
  const created = record();
140
142
  await store.create(created);
141
- await store.invalidateById("no-such-id");
143
+ await store.invalidateById(crypto.randomUUID());
142
144
  expect((await store.findActive(EMAIL, PURPOSE))?.id).toBe(created.id);
143
145
  });
144
146
  });
@@ -171,7 +173,7 @@ export function runOtpStoreContractTests(options) {
171
173
  expect(await store.recordAttempt(created.id)).toBe(3);
172
174
  });
173
175
  it("returns 0 for an unknown id", async () => {
174
- expect(await store.recordAttempt("no-such-id")).toBe(0);
176
+ expect(await store.recordAttempt(crypto.randomUUID())).toBe(0);
175
177
  });
176
178
  it("counts each record separately", async () => {
177
179
  const mine = record();
@@ -38,6 +38,18 @@ export interface TokenFlowStoreContractOptions {
38
38
  * that meant to implement it and does not fails loudly.
39
39
  */
40
40
  deleteBySubject?: boolean;
41
+ /**
42
+ * The subject the cases issue tokens for, in your application's id format. A
43
+ * store whose rows reference a users table needs it to name a user that
44
+ * exists. Defaults to `"user-1"`.
45
+ */
46
+ subject?: string;
47
+ /**
48
+ * A second subject, distinct from
49
+ * {@link TokenFlowStoreContractOptions.subject}, whose tokens must survive
50
+ * operations on the first. Defaults to `"user-2"`.
51
+ */
52
+ otherSubject?: string;
41
53
  /**
42
54
  * Overrides the name passed to the outer `describe` block. Defaults to
43
55
  * `"TokenFlowStore contract"`.
@@ -48,6 +60,9 @@ export interface TokenFlowStoreContractOptions {
48
60
  * Runs the {@link TokenFlowStore} contract suite. Call it from your own test
49
61
  * file — it registers `describe` / `it` blocks the test runner picks up.
50
62
  *
63
+ * Token hashes are unique to each test, so a store whose hash column is unique
64
+ * across tenants or test files never collides with an earlier run's rows.
65
+ *
51
66
  * The concurrency cases redeem one token from eight callers at once. A store
52
67
  * that reads, awaits, then writes hands the token to more than one of them and
53
68
  * fails in practice — a race is probabilistic, so a lucky interleaving can
@@ -25,25 +25,16 @@ import { assert, beforeEach, describe, expect, it } from "vitest";
25
25
  import { CONCURRENT_CALLERS, race } from "./_race.js";
26
26
  const PURPOSE = "password-reset";
27
27
  const OTHER_PURPOSE = "email-verification";
28
- const SUBJECT = "user-1";
29
- const OTHER_SUBJECT = "user-2";
30
28
  function countTrue(results) {
31
29
  return results.filter((result) => result).length;
32
30
  }
33
- function record(overrides) {
34
- const now = Date.now();
35
- return {
36
- purpose: PURPOSE,
37
- subject: SUBJECT,
38
- expiresAt: now + 60_000,
39
- createdAt: now,
40
- ...overrides,
41
- };
42
- }
43
31
  /**
44
32
  * Runs the {@link TokenFlowStore} contract suite. Call it from your own test
45
33
  * file — it registers `describe` / `it` blocks the test runner picks up.
46
34
  *
35
+ * Token hashes are unique to each test, so a store whose hash column is unique
36
+ * across tenants or test files never collides with an earlier run's rows.
37
+ *
47
38
  * The concurrency cases redeem one token from eight callers at once. A store
48
39
  * that reads, awaits, then writes hands the token to more than one of them and
49
40
  * fails in practice — a race is probabilistic, so a lucky interleaving can
@@ -51,22 +42,39 @@ function record(overrides) {
51
42
  */
52
43
  export function runTokenFlowStoreContractTests(options) {
53
44
  const expectDeleteBySubject = options.deleteBySubject ?? true;
45
+ const SUBJECT = options.subject ?? "user-1";
46
+ const OTHER_SUBJECT = options.otherSubject ?? "user-2";
54
47
  describe(options.describeName ?? "TokenFlowStore contract", () => {
55
48
  let store;
49
+ let testId;
50
+ function hash(name) {
51
+ return `hash-${name}-${testId}`;
52
+ }
53
+ function record(overrides) {
54
+ const now = Date.now();
55
+ return {
56
+ purpose: PURPOSE,
57
+ subject: SUBJECT,
58
+ expiresAt: now + 60_000,
59
+ createdAt: now,
60
+ ...overrides,
61
+ };
62
+ }
56
63
  beforeEach(async () => {
64
+ testId = crypto.randomUUID();
57
65
  store = await options.makeStore();
58
66
  });
59
67
  describe("save / get", () => {
60
68
  it("returns null for an unknown token hash", async () => {
61
- expect(await store.get("no-such-hash")).toBe(null);
69
+ expect(await store.get(hash("unknown"))).toBe(null);
62
70
  });
63
71
  it("round-trips every field of a saved record", async () => {
64
72
  const saved = record({
65
- tokenHash: "hash-1",
73
+ tokenHash: hash("1"),
66
74
  data: { email: "user@example.com" },
67
75
  });
68
76
  await store.save(saved);
69
- const fetched = await store.get("hash-1");
77
+ const fetched = await store.get(hash("1"));
70
78
  assert(fetched !== null, "a saved record must be readable back");
71
79
  expect(fetched.purpose).toStrictEqual(saved.purpose);
72
80
  expect(fetched.subject).toStrictEqual(saved.subject);
@@ -76,53 +84,53 @@ export function runTokenFlowStoreContractTests(options) {
76
84
  expect(fetched.consumedAt).toBe(undefined);
77
85
  });
78
86
  it("returns an expired record rather than hiding it", async () => {
79
- await store.save(record({ tokenHash: "hash-expired", expiresAt: Date.now() - 1000 }));
80
- const fetched = await store.get("hash-expired");
87
+ await store.save(record({ tokenHash: hash("expired"), expiresAt: Date.now() - 1000 }));
88
+ const fetched = await store.get(hash("expired"));
81
89
  assert(fetched !== null, "expiry is the service's call — it reports `expired` distinctly " +
82
90
  "from `invalid`, which it cannot do if the store swallows the row");
83
91
  });
84
92
  it("keeps records with different hashes independent", async () => {
85
- await store.save(record({ tokenHash: "hash-1" }));
86
- await store.save(record({ tokenHash: "hash-2" }));
87
- expect((await store.get("hash-1"))?.tokenHash).toBe("hash-1");
88
- expect((await store.get("hash-2"))?.tokenHash).toBe("hash-2");
93
+ await store.save(record({ tokenHash: hash("1") }));
94
+ await store.save(record({ tokenHash: hash("2") }));
95
+ expect((await store.get(hash("1")))?.tokenHash).toBe(hash("1"));
96
+ expect((await store.get(hash("2")))?.tokenHash).toBe(hash("2"));
89
97
  });
90
98
  });
91
99
  describe("markConsumed", () => {
92
100
  it("claims a live record and stamps it", async () => {
93
- await store.save(record({ tokenHash: "hash-1" }));
101
+ await store.save(record({ tokenHash: hash("1") }));
94
102
  const consumedAt = Date.now();
95
- expect(await store.markConsumed("hash-1", consumedAt)).toBe(true);
96
- expect((await store.get("hash-1"))?.consumedAt).toBe(consumedAt);
103
+ expect(await store.markConsumed(hash("1"), consumedAt)).toBe(true);
104
+ expect((await store.get(hash("1")))?.consumedAt).toBe(consumedAt);
97
105
  });
98
106
  it("refuses a record already consumed, keeping the first stamp", async () => {
99
- await store.save(record({ tokenHash: "hash-1" }));
107
+ await store.save(record({ tokenHash: hash("1") }));
100
108
  const first = Date.now();
101
- await store.markConsumed("hash-1", first);
102
- expect(await store.markConsumed("hash-1", first + 5_000)).toBe(false);
103
- expect((await store.get("hash-1"))?.consumedAt, "a losing claim must not overwrite the winner's record").toBe(first);
109
+ await store.markConsumed(hash("1"), first);
110
+ expect(await store.markConsumed(hash("1"), first + 5_000)).toBe(false);
111
+ expect((await store.get(hash("1")))?.consumedAt, "a losing claim must not overwrite the winner's record").toBe(first);
104
112
  });
105
113
  it("refuses an unknown token hash", async () => {
106
- expect(await store.markConsumed("no-such-hash", Date.now())).toBe(false);
114
+ expect(await store.markConsumed(hash("unknown"), Date.now())).toBe(false);
107
115
  });
108
116
  it(`claims a token for exactly one of ${CONCURRENT_CALLERS} concurrent redemptions`, async () => {
109
- await store.save(record({ tokenHash: "hash-1" }));
117
+ await store.save(record({ tokenHash: hash("1") }));
110
118
  const now = Date.now();
111
- const results = await race((caller) => store.markConsumed("hash-1", now + caller));
119
+ const results = await race((caller) => store.markConsumed(hash("1"), now + caller));
112
120
  expect(countTrue(results), "a link is single-use — requests racing one link must not each be " +
113
121
  "told they claimed it").toBe(1);
114
122
  });
115
123
  it("claims both tokens when two different ones are redeemed concurrently", async () => {
116
- await store.save(record({ tokenHash: "hash-1" }));
117
- await store.save(record({ tokenHash: "hash-2" }));
124
+ await store.save(record({ tokenHash: hash("1") }));
125
+ await store.save(record({ tokenHash: hash("2") }));
118
126
  const now = Date.now();
119
127
  const results = await Promise.all([
120
- store.markConsumed("hash-1", now),
121
- store.markConsumed("hash-2", now),
128
+ store.markConsumed(hash("1"), now),
129
+ store.markConsumed(hash("2"), now),
122
130
  ]);
123
131
  expect(countTrue(results)).toBe(2);
124
- assert((await store.get("hash-1"))?.consumedAt !== undefined &&
125
- (await store.get("hash-2"))?.consumedAt !== undefined, "neither stamp may be lost to the other's stale read");
132
+ assert((await store.get(hash("1")))?.consumedAt !== undefined &&
133
+ (await store.get(hash("2")))?.consumedAt !== undefined, "neither stamp may be lost to the other's stale read");
126
134
  });
127
135
  });
128
136
  if (expectDeleteBySubject) {
@@ -134,33 +142,39 @@ export function runTokenFlowStoreContractTests(options) {
134
142
  "`deleteBySubject: false` to record the gap.");
135
143
  });
136
144
  it("drops the pending tokens for one (purpose, subject)", async () => {
137
- await store.save(record({ tokenHash: "hash-1" }));
138
- await store.save(record({ tokenHash: "hash-2" }));
145
+ await store.save(record({ tokenHash: hash("1") }));
146
+ await store.save(record({ tokenHash: hash("2") }));
139
147
  await store.deleteBySubject(PURPOSE, SUBJECT);
140
- expect(await store.get("hash-1")).toBe(null);
141
- expect(await store.get("hash-2")).toBe(null);
148
+ expect(await store.get(hash("1"))).toBe(null);
149
+ expect(await store.get(hash("2"))).toBe(null);
142
150
  });
143
151
  it("leaves other subjects and other purposes alone", async () => {
144
- await store.save(record({ tokenHash: "hash-mine" }));
145
- await store.save(record({ tokenHash: "hash-other-subject", subject: OTHER_SUBJECT }));
146
- await store.save(record({ tokenHash: "hash-other-purpose", purpose: OTHER_PURPOSE }));
152
+ await store.save(record({ tokenHash: hash("mine") }));
153
+ await store.save(record({
154
+ tokenHash: hash("other-subject"),
155
+ subject: OTHER_SUBJECT,
156
+ }));
157
+ await store.save(record({
158
+ tokenHash: hash("other-purpose"),
159
+ purpose: OTHER_PURPOSE,
160
+ }));
147
161
  await store.deleteBySubject(PURPOSE, SUBJECT);
148
- expect(await store.get("hash-mine")).toBe(null);
149
- assert((await store.get("hash-other-subject")) !== null, "another subject's token must survive");
150
- assert((await store.get("hash-other-purpose")) !== null, "the same subject's token for another purpose must survive");
162
+ expect(await store.get(hash("mine"))).toBe(null);
163
+ assert((await store.get(hash("other-subject"))) !== null, "another subject's token must survive");
164
+ assert((await store.get(hash("other-purpose"))) !== null, "the same subject's token for another purpose must survive");
151
165
  });
152
166
  it("is a no-op when the pair has no tokens", async () => {
153
- await store.save(record({ tokenHash: "hash-1" }));
167
+ await store.save(record({ tokenHash: hash("1") }));
154
168
  await store.deleteBySubject(PURPOSE, OTHER_SUBJECT);
155
- assert((await store.get("hash-1")) !== null);
169
+ assert((await store.get(hash("1"))) !== null);
156
170
  });
157
171
  it("keeps an already-consumed record so it still reads as consumed", async () => {
158
- await store.save(record({ tokenHash: "hash-used" }));
159
- await store.save(record({ tokenHash: "hash-pending" }));
160
- expect(await store.markConsumed("hash-used", Date.now())).toBe(true);
172
+ await store.save(record({ tokenHash: hash("used") }));
173
+ await store.save(record({ tokenHash: hash("pending") }));
174
+ expect(await store.markConsumed(hash("used"), Date.now())).toBe(true);
161
175
  await store.deleteBySubject(PURPOSE, SUBJECT);
162
- expect(await store.get("hash-pending"), "the pending token must be dropped").toBe(null);
163
- const used = await store.get("hash-used");
176
+ expect(await store.get(hash("pending")), "the pending token must be dropped").toBe(null);
177
+ const used = await store.get(hash("used"));
164
178
  assert(used !== null, "a consumed record must survive so inspect() can answer " +
165
179
  "`consumed` rather than the generic `invalid`");
166
180
  assert(used.consumedAt !== undefined, "the surviving record must still carry its consumedAt stamp");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@udibo/oauth2",
3
- "version": "0.37.0",
3
+ "version": "0.39.0",
4
4
  "description": "A flexible OAuth2 toolkit: framework-agnostic authorization & resource servers, a typed client, Hono adapters (including a backend-for-frontend), and React bindings.",
5
5
  "keywords": [
6
6
  "authorization-server",