@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.
- package/dist/identity/events.d.ts +28 -7
- package/dist/identity/mfa/service.d.ts +9 -3
- package/dist/identity/mfa/service.js +8 -5
- package/dist/identity/service.d.ts +43 -11
- package/dist/identity/service.js +82 -40
- package/dist/testing/contract/identity-user-store.d.ts +18 -0
- package/dist/testing/contract/identity-user-store.js +54 -0
- package/dist/testing/contract/lockout-store.d.ts +11 -0
- package/dist/testing/contract/lockout-store.js +2 -2
- package/dist/testing/contract/mfa-store.d.ts +11 -0
- package/dist/testing/contract/mfa-store.js +14 -2
- package/dist/testing/contract/otp-store.d.ts +3 -0
- package/dist/testing/contract/otp-store.js +8 -6
- package/dist/testing/contract/token-flow-store.d.ts +15 -0
- package/dist/testing/contract/token-flow-store.js +68 -54
- package/package.json +1 -1
|
@@ -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
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
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.
|
|
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.
|
|
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
|
|
259
|
-
|
|
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
|
-
|
|
262
|
-
|
|
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
|
-
/**
|
|
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.
|
|
477
|
-
*
|
|
478
|
-
* (`session_revocation_failed`
|
|
479
|
-
* fires,
|
|
480
|
-
*
|
|
481
|
-
* the
|
|
482
|
-
*
|
|
483
|
-
*
|
|
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;
|
package/dist/identity/service.js
CHANGED
|
@@ -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.
|
|
267
|
-
*
|
|
268
|
-
* (`session_revocation_failed`
|
|
269
|
-
* fires,
|
|
270
|
-
*
|
|
271
|
-
* the
|
|
272
|
-
*
|
|
273
|
-
*
|
|
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
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
310
|
-
await this.#
|
|
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
|
|
332
|
+
userId,
|
|
333
|
+
...(sessionsRevoked === undefined ? {} : { sessionsRevoked }),
|
|
321
334
|
});
|
|
322
|
-
return { userId
|
|
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
|
-
|
|
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
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
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:
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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: "
|
|
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("
|
|
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: "
|
|
80
|
-
const fetched = await store.get("
|
|
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: "
|
|
86
|
-
await store.save(record({ tokenHash: "
|
|
87
|
-
expect((await store.get("
|
|
88
|
-
expect((await store.get("
|
|
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: "
|
|
101
|
+
await store.save(record({ tokenHash: hash("1") }));
|
|
94
102
|
const consumedAt = Date.now();
|
|
95
|
-
expect(await store.markConsumed("
|
|
96
|
-
expect((await store.get("
|
|
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: "
|
|
107
|
+
await store.save(record({ tokenHash: hash("1") }));
|
|
100
108
|
const first = Date.now();
|
|
101
|
-
await store.markConsumed("
|
|
102
|
-
expect(await store.markConsumed("
|
|
103
|
-
expect((await store.get("
|
|
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(
|
|
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: "
|
|
117
|
+
await store.save(record({ tokenHash: hash("1") }));
|
|
110
118
|
const now = Date.now();
|
|
111
|
-
const results = await race((caller) => store.markConsumed("
|
|
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: "
|
|
117
|
-
await store.save(record({ tokenHash: "
|
|
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("
|
|
121
|
-
store.markConsumed("
|
|
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("
|
|
125
|
-
(await store.get("
|
|
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: "
|
|
138
|
-
await store.save(record({ tokenHash: "
|
|
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("
|
|
141
|
-
expect(await store.get("
|
|
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: "
|
|
145
|
-
await store.save(record({
|
|
146
|
-
|
|
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("
|
|
149
|
-
assert((await store.get("
|
|
150
|
-
assert((await store.get("
|
|
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: "
|
|
167
|
+
await store.save(record({ tokenHash: hash("1") }));
|
|
154
168
|
await store.deleteBySubject(PURPOSE, OTHER_SUBJECT);
|
|
155
|
-
assert((await store.get("
|
|
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: "
|
|
159
|
-
await store.save(record({ tokenHash: "
|
|
160
|
-
expect(await store.markConsumed("
|
|
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("
|
|
163
|
-
const used = await store.get("
|
|
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.
|
|
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",
|