@udibo/oauth2 0.38.0 → 0.40.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/mfa/service.d.ts +3 -1
- package/dist/identity/otp.d.ts +25 -6
- package/dist/identity/otp.js +25 -4
- 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 +25 -3
- package/dist/testing/contract/otp-store.js +155 -21
- 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
|
@@ -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
|
/**
|
package/dist/identity/otp.d.ts
CHANGED
|
@@ -67,8 +67,10 @@ export interface OtpStore {
|
|
|
67
67
|
/** Persist a new active record. */
|
|
68
68
|
create(record: OtpRecord): Promise<void>;
|
|
69
69
|
/**
|
|
70
|
-
* The
|
|
71
|
-
*
|
|
70
|
+
* The active record with the greatest `createdAt` for `(email, purpose)`, or
|
|
71
|
+
* `null` — ordered by `createdAt`, never by insertion, since `consume`
|
|
72
|
+
* retires records by the same field. Must still return an expired record —
|
|
73
|
+
* the service reports expiry itself.
|
|
72
74
|
*/
|
|
73
75
|
findActive(email: string, purpose: string): Promise<OtpRecord | null>;
|
|
74
76
|
/**
|
|
@@ -84,6 +86,13 @@ export interface OtpStore {
|
|
|
84
86
|
* Atomically consume an active code. Return `true` only to the caller that
|
|
85
87
|
* removed it from the active set; concurrent or repeated claims return
|
|
86
88
|
* `false`. Use a conditional delete/update with RETURNING in persistent stores.
|
|
89
|
+
*
|
|
90
|
+
* A successful claim also deactivates every other active record for the same
|
|
91
|
+
* `(email, purpose)` created no later than the claimed one, in the same
|
|
92
|
+
* atomic operation, so a sign-in with the newest code leaves no older code
|
|
93
|
+
* redeemable. Records created after it, other pairs, and — when the claim
|
|
94
|
+
* fails — every record stay active. A store that keeps one active code per
|
|
95
|
+
* pair already satisfies this.
|
|
87
96
|
*/
|
|
88
97
|
consume(id: string): Promise<boolean>;
|
|
89
98
|
/**
|
|
@@ -91,7 +100,8 @@ export interface OtpStore {
|
|
|
91
100
|
* the send carrying it failed. Separate from {@link OtpStore.consume} so a
|
|
92
101
|
* store that records *why* a code left the active set can tell a redeemed
|
|
93
102
|
* code from a discarded one, and from {@link OtpStore.invalidate} so a
|
|
94
|
-
* concurrent request's
|
|
103
|
+
* concurrent request's code survives, older or newer. Unlike `consume`, it
|
|
104
|
+
* deactivates no other record. A no-op for an unknown id.
|
|
95
105
|
*/
|
|
96
106
|
invalidateById(id: string): Promise<void>;
|
|
97
107
|
/** Deactivate every active record for `(email, purpose)`. */
|
|
@@ -237,7 +247,9 @@ export declare class EmailOtpService {
|
|
|
237
247
|
* value — **before** the compare, so concurrent verifies of one live code
|
|
238
248
|
* can't each test a guess against a stale pre-increment count: at most
|
|
239
249
|
* `maxAttempts` guesses ever reach the comparison. Spending the budget
|
|
240
|
-
* invalidates the code (`locked`); success consumes it (single-use)
|
|
250
|
+
* invalidates the code (`locked`); success consumes it (single-use) and,
|
|
251
|
+
* through {@link OtpStore.consume}, retires any older code for the pair that
|
|
252
|
+
* overlapping requests left active.
|
|
241
253
|
*/
|
|
242
254
|
verify(options: VerifyOtpOptions): Promise<VerifyOtpResult>;
|
|
243
255
|
}
|
|
@@ -249,11 +261,18 @@ export declare class MemoryOtpStore implements OtpStore {
|
|
|
249
261
|
#private;
|
|
250
262
|
/** Persist a record and mark it active. */
|
|
251
263
|
create(record: OtpRecord): Promise<void>;
|
|
252
|
-
/**
|
|
264
|
+
/**
|
|
265
|
+
* The active record with the greatest `createdAt` for `(email, purpose)`,
|
|
266
|
+
* expired or not; else `null`.
|
|
267
|
+
*/
|
|
253
268
|
findActive(email: string, purpose: string): Promise<OtpRecord | null>;
|
|
254
269
|
/** Bump a record's attempt count; returns the new count, or 0 if unknown. */
|
|
255
270
|
recordAttempt(id: string): Promise<number>;
|
|
256
|
-
/**
|
|
271
|
+
/**
|
|
272
|
+
* Atomically deactivate one active record and, when it was claimed, the
|
|
273
|
+
* older active records for its `(email, purpose)`; return whether it was
|
|
274
|
+
* claimed.
|
|
275
|
+
*/
|
|
257
276
|
consume(id: string): Promise<boolean>;
|
|
258
277
|
/** Deactivate one undelivered record; a no-op if the id is unknown. */
|
|
259
278
|
invalidateById(id: string): Promise<void>;
|
package/dist/identity/otp.js
CHANGED
|
@@ -160,7 +160,9 @@ export class EmailOtpService {
|
|
|
160
160
|
* value — **before** the compare, so concurrent verifies of one live code
|
|
161
161
|
* can't each test a guess against a stale pre-increment count: at most
|
|
162
162
|
* `maxAttempts` guesses ever reach the comparison. Spending the budget
|
|
163
|
-
* invalidates the code (`locked`); success consumes it (single-use)
|
|
163
|
+
* invalidates the code (`locked`); success consumes it (single-use) and,
|
|
164
|
+
* through {@link OtpStore.consume}, retires any older code for the pair that
|
|
165
|
+
* overlapping requests left active.
|
|
164
166
|
*/
|
|
165
167
|
async verify(options) {
|
|
166
168
|
const { purpose } = options;
|
|
@@ -204,7 +206,10 @@ export class MemoryOtpStore {
|
|
|
204
206
|
this.#active.add(record.id);
|
|
205
207
|
return Promise.resolve();
|
|
206
208
|
}
|
|
207
|
-
/**
|
|
209
|
+
/**
|
|
210
|
+
* The active record with the greatest `createdAt` for `(email, purpose)`,
|
|
211
|
+
* expired or not; else `null`.
|
|
212
|
+
*/
|
|
208
213
|
findActive(email, purpose) {
|
|
209
214
|
let latest = null;
|
|
210
215
|
for (const id of this.#active) {
|
|
@@ -226,9 +231,25 @@ export class MemoryOtpStore {
|
|
|
226
231
|
record.attempts++;
|
|
227
232
|
return Promise.resolve(record.attempts);
|
|
228
233
|
}
|
|
229
|
-
/**
|
|
234
|
+
/**
|
|
235
|
+
* Atomically deactivate one active record and, when it was claimed, the
|
|
236
|
+
* older active records for its `(email, purpose)`; return whether it was
|
|
237
|
+
* claimed.
|
|
238
|
+
*/
|
|
230
239
|
consume(id) {
|
|
231
|
-
|
|
240
|
+
const claimed = this.#records.get(id);
|
|
241
|
+
if (!claimed || !this.#active.delete(id))
|
|
242
|
+
return Promise.resolve(false);
|
|
243
|
+
for (const activeId of [...this.#active]) {
|
|
244
|
+
const record = this.#records.get(activeId);
|
|
245
|
+
if (record &&
|
|
246
|
+
record.email === claimed.email &&
|
|
247
|
+
record.purpose === claimed.purpose &&
|
|
248
|
+
record.createdAt <= claimed.createdAt) {
|
|
249
|
+
this.#active.delete(activeId);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return Promise.resolve(true);
|
|
232
253
|
}
|
|
233
254
|
/** Deactivate one undelivered record; a no-op if the id is unknown. */
|
|
234
255
|
invalidateById(id) {
|
|
@@ -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
|
});
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
* A six-digit code is guessable online, so the attempt budget is the only thing
|
|
6
6
|
* standing between an attacker and a one-in-a-million draw. `recordAttempt` is
|
|
7
7
|
* what enforces it, and it only enforces it when concurrent guesses cannot
|
|
8
|
-
* share a count — which is what this suite checks.
|
|
8
|
+
* share a count — which is what this suite checks. It also checks that a
|
|
9
|
+
* successful `consume` retires the pair's older codes, so a sign-in with the
|
|
10
|
+
* newest code leaves no earlier one redeemable.
|
|
9
11
|
*
|
|
10
12
|
* @example Verify a database-backed store
|
|
11
13
|
* ```ts
|
|
@@ -22,7 +24,7 @@
|
|
|
22
24
|
*
|
|
23
25
|
* @module
|
|
24
26
|
*/
|
|
25
|
-
import type
|
|
27
|
+
import { type OtpStore } from "../../identity/otp.ts";
|
|
26
28
|
/** Options for {@link runOtpStoreContractTests}. */
|
|
27
29
|
export interface OtpStoreContractOptions {
|
|
28
30
|
/**
|
|
@@ -31,6 +33,19 @@ export interface OtpStoreContractOptions {
|
|
|
31
33
|
* meaningless.
|
|
32
34
|
*/
|
|
33
35
|
makeStore(): Promise<OtpStore> | OtpStore;
|
|
36
|
+
/**
|
|
37
|
+
* Set to `true` for a store that keeps at most one active code per
|
|
38
|
+
* `(email, purpose)`, where `create` replaces the pair's code — for example
|
|
39
|
+
* an upsert on a unique `(email, purpose)` index. Such a store conforms: the
|
|
40
|
+
* suite then requires `create` to replace the code, so the replaced code's
|
|
41
|
+
* id no longer consumes, and drops the cases that need two codes active at
|
|
42
|
+
* once. The trade-off is availability: when overlapping requests each send a
|
|
43
|
+
* code and the later send fails, discarding the later code leaves no code
|
|
44
|
+
* the user can enter, where a store keeping several codes leaves the one
|
|
45
|
+
* already delivered. Defaults to `false`; a store that keeps several codes
|
|
46
|
+
* and sets it fails the replacement case.
|
|
47
|
+
*/
|
|
48
|
+
oneActiveCode?: boolean;
|
|
34
49
|
/**
|
|
35
50
|
* Overrides the name passed to the outer `describe` block. Defaults to
|
|
36
51
|
* `"OtpStore contract"`.
|
|
@@ -41,8 +56,15 @@ export interface OtpStoreContractOptions {
|
|
|
41
56
|
* Runs the {@link OtpStore} contract suite. Call it from your own test file —
|
|
42
57
|
* it registers `describe` / `it` blocks the test runner picks up.
|
|
43
58
|
*
|
|
59
|
+
* Record ids, known and unknown alike, are UUIDs — the ids `EmailOtpService`
|
|
60
|
+
* issues — so a store with a `uuid` id column accepts every one.
|
|
61
|
+
*
|
|
44
62
|
* The concurrency case fires overlapping `recordAttempt` calls and requires
|
|
45
63
|
* each to see a distinct count; a store that reads, awaits, then writes hands
|
|
46
|
-
* the same count to every guess and fails it.
|
|
64
|
+
* the same count to every guess and fails it. The `consume` cases require a
|
|
65
|
+
* successful claim to retire every older active code for the pair, and to
|
|
66
|
+
* leave newer codes, other pairs and — when the claim fails — everything else
|
|
67
|
+
* active. Register a store that keeps one code per pair with
|
|
68
|
+
* `oneActiveCode: true`.
|
|
47
69
|
*/
|
|
48
70
|
export declare function runOtpStoreContractTests(options: OtpStoreContractOptions): void;
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
* A six-digit code is guessable online, so the attempt budget is the only thing
|
|
6
6
|
* standing between an attacker and a one-in-a-million draw. `recordAttempt` is
|
|
7
7
|
* what enforces it, and it only enforces it when concurrent guesses cannot
|
|
8
|
-
* share a count — which is what this suite checks.
|
|
8
|
+
* share a count — which is what this suite checks. It also checks that a
|
|
9
|
+
* successful `consume` retires the pair's older codes, so a sign-in with the
|
|
10
|
+
* newest code leaves no earlier one redeemable.
|
|
9
11
|
*
|
|
10
12
|
* @example Verify a database-backed store
|
|
11
13
|
* ```ts
|
|
@@ -23,16 +25,16 @@
|
|
|
23
25
|
* @module
|
|
24
26
|
*/
|
|
25
27
|
import { assert, beforeEach, describe, expect, it } from "vitest";
|
|
28
|
+
import { EmailOtpService, } from "../../identity/otp.js";
|
|
26
29
|
import { CONCURRENT_CALLERS, race } from "./_race.js";
|
|
27
30
|
const EMAIL = "user@example.com";
|
|
28
31
|
const OTHER_EMAIL = "other@example.com";
|
|
29
32
|
const PURPOSE = "signin";
|
|
30
33
|
const OTHER_PURPOSE = "step-up";
|
|
31
|
-
let sequence = 0;
|
|
32
34
|
function record(overrides = {}) {
|
|
33
35
|
const now = Date.now();
|
|
34
36
|
return {
|
|
35
|
-
id:
|
|
37
|
+
id: crypto.randomUUID(),
|
|
36
38
|
email: EMAIL,
|
|
37
39
|
purpose: PURPOSE,
|
|
38
40
|
codeHash: "hash",
|
|
@@ -47,11 +49,20 @@ function record(overrides = {}) {
|
|
|
47
49
|
* Runs the {@link OtpStore} contract suite. Call it from your own test file —
|
|
48
50
|
* it registers `describe` / `it` blocks the test runner picks up.
|
|
49
51
|
*
|
|
52
|
+
* Record ids, known and unknown alike, are UUIDs — the ids `EmailOtpService`
|
|
53
|
+
* issues — so a store with a `uuid` id column accepts every one.
|
|
54
|
+
*
|
|
50
55
|
* The concurrency case fires overlapping `recordAttempt` calls and requires
|
|
51
56
|
* each to see a distinct count; a store that reads, awaits, then writes hands
|
|
52
|
-
* the same count to every guess and fails it.
|
|
57
|
+
* the same count to every guess and fails it. The `consume` cases require a
|
|
58
|
+
* successful claim to retire every older active code for the pair, and to
|
|
59
|
+
* leave newer codes, other pairs and — when the claim fails — everything else
|
|
60
|
+
* active. Register a store that keeps one code per pair with
|
|
61
|
+
* `oneActiveCode: true`.
|
|
53
62
|
*/
|
|
54
63
|
export function runOtpStoreContractTests(options) {
|
|
64
|
+
const oneActiveCode = options.oneActiveCode ?? false;
|
|
65
|
+
const manyActiveCodes = !oneActiveCode;
|
|
55
66
|
describe(options.describeName ?? "OtpStore contract", () => {
|
|
56
67
|
let store;
|
|
57
68
|
beforeEach(async () => {
|
|
@@ -82,6 +93,31 @@ export function runOtpStoreContractTests(options) {
|
|
|
82
93
|
await store.create(newer);
|
|
83
94
|
expect((await store.findActive(EMAIL, PURPOSE))?.id, "a re-send supersedes the code before it").toBe(newer.id);
|
|
84
95
|
});
|
|
96
|
+
if (oneActiveCode) {
|
|
97
|
+
it("replaces the pair's active code (oneActiveCode)", async () => {
|
|
98
|
+
const now = Date.now();
|
|
99
|
+
const older = record({ createdAt: now - 1000, codeHash: "old" });
|
|
100
|
+
const newer = record({ createdAt: now, codeHash: "new" });
|
|
101
|
+
await store.create(older);
|
|
102
|
+
await store.create(newer);
|
|
103
|
+
expect(await store.consume(older.id), "`oneActiveCode: true` declares that a new code replaces the " +
|
|
104
|
+
"pair's code; this store kept the older one active").toBe(false);
|
|
105
|
+
expect((await store.findActive(EMAIL, PURPOSE))?.id).toBe(newer.id);
|
|
106
|
+
});
|
|
107
|
+
it.skip("a store keeping one code per pair returns the code created last, whatever its createdAt — there is no other code to order it against (oneActiveCode: true)", () => { });
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
it("returns the greatest createdAt, not the last inserted", async () => {
|
|
111
|
+
const now = Date.now();
|
|
112
|
+
const higher = record({ createdAt: now });
|
|
113
|
+
const lower = record({ createdAt: now - 1000 });
|
|
114
|
+
await store.create(higher);
|
|
115
|
+
await store.create(lower);
|
|
116
|
+
expect((await store.findActive(EMAIL, PURPOSE))?.id, "verify consumes the record findActive returns and consume " +
|
|
117
|
+
"retires records created no later than it; ordering by " +
|
|
118
|
+
"insertion under clock drift leaves the other code redeemable").toBe(higher.id);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
85
121
|
it("returns an expired record rather than hiding it", async () => {
|
|
86
122
|
await store.create(record({ expiresAt: Date.now() - 1000 }));
|
|
87
123
|
assert((await store.findActive(EMAIL, PURPOSE)) !== null, "expiry is the service's call — it reports `expired` distinctly " +
|
|
@@ -100,7 +136,7 @@ export function runOtpStoreContractTests(options) {
|
|
|
100
136
|
const results = await race(() => store.consume(created.id));
|
|
101
137
|
expect(results.filter((claimed) => claimed).length, "a code is single-use — consumers racing one code must not each " +
|
|
102
138
|
"be told they claimed it").toBe(1);
|
|
103
|
-
expect(await store.consume(
|
|
139
|
+
expect(await store.consume(crypto.randomUUID())).toBe(false);
|
|
104
140
|
});
|
|
105
141
|
it("takes the record out of the active set", async () => {
|
|
106
142
|
const created = record();
|
|
@@ -108,37 +144,94 @@ export function runOtpStoreContractTests(options) {
|
|
|
108
144
|
await store.consume(created.id);
|
|
109
145
|
expect(await store.findActive(EMAIL, PURPOSE)).toBe(null);
|
|
110
146
|
});
|
|
111
|
-
it("
|
|
147
|
+
it("retires every older active code for the pair", async () => {
|
|
112
148
|
const now = Date.now();
|
|
149
|
+
const oldest = record({ createdAt: now - 2000 });
|
|
113
150
|
const older = record({ createdAt: now - 1000 });
|
|
114
|
-
const
|
|
151
|
+
const newest = record({ createdAt: now });
|
|
152
|
+
await store.create(oldest);
|
|
115
153
|
await store.create(older);
|
|
116
|
-
await store.create(
|
|
117
|
-
await store.consume(
|
|
118
|
-
expect(
|
|
154
|
+
await store.create(newest);
|
|
155
|
+
expect(await store.consume(newest.id)).toBe(true);
|
|
156
|
+
expect(await store.findActive(EMAIL, PURPOSE), "a sign-in with the newest code must end the older codes' window; " +
|
|
157
|
+
"otherwise each stays redeemable until it expires").toBe(null);
|
|
158
|
+
expect(await store.consume(older.id)).toBe(false);
|
|
159
|
+
expect(await store.consume(oldest.id)).toBe(false);
|
|
160
|
+
});
|
|
161
|
+
if (manyActiveCodes) {
|
|
162
|
+
it("leaves a code newer than the claimed one active", async () => {
|
|
163
|
+
const now = Date.now();
|
|
164
|
+
const claimed = record({ createdAt: now - 1000 });
|
|
165
|
+
const newer = record({ createdAt: now });
|
|
166
|
+
await store.create(claimed);
|
|
167
|
+
await store.create(newer);
|
|
168
|
+
expect(await store.consume(claimed.id)).toBe(true);
|
|
169
|
+
expect((await store.findActive(EMAIL, PURPOSE))?.id, "a code a concurrent request minted after the claimed one " +
|
|
170
|
+
"is not older than it and must survive").toBe(newer.id);
|
|
171
|
+
});
|
|
172
|
+
it("retires nothing when the claim fails", async () => {
|
|
173
|
+
const now = Date.now();
|
|
174
|
+
const older = record({ createdAt: now - 1000 });
|
|
175
|
+
const discarded = record({ createdAt: now });
|
|
176
|
+
await store.create(older);
|
|
177
|
+
await store.create(discarded);
|
|
178
|
+
await store.invalidateById(discarded.id);
|
|
179
|
+
expect(await store.consume(discarded.id)).toBe(false);
|
|
180
|
+
expect((await store.findActive(EMAIL, PURPOSE))?.id, "only a successful claim retires older codes; a stale or " +
|
|
181
|
+
"repeated consume must leave the delivered code usable").toBe(older.id);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
it.skip("a store keeping one code per pair has no newer or older code beside the claimed one — a re-send replaces the code a sign-in is verifying, so that sign-in reports invalid and the user enters the new code (oneActiveCode: true)", () => { });
|
|
186
|
+
}
|
|
187
|
+
it("leaves other emails and other purposes active", async () => {
|
|
188
|
+
const claimed = record();
|
|
189
|
+
const otherEmail = record({ email: OTHER_EMAIL });
|
|
190
|
+
const otherPurpose = record({ purpose: OTHER_PURPOSE });
|
|
191
|
+
await store.create(claimed);
|
|
192
|
+
await store.create(otherEmail);
|
|
193
|
+
await store.create(otherPurpose);
|
|
194
|
+
expect(await store.consume(claimed.id)).toBe(true);
|
|
195
|
+
expect((await store.findActive(OTHER_EMAIL, PURPOSE))?.id).toBe(otherEmail.id);
|
|
196
|
+
expect((await store.findActive(EMAIL, OTHER_PURPOSE))?.id).toBe(otherPurpose.id);
|
|
119
197
|
});
|
|
120
198
|
it("is a no-op for an unknown id", async () => {
|
|
121
199
|
const created = record();
|
|
122
200
|
await store.create(created);
|
|
123
|
-
await store.consume(
|
|
201
|
+
await store.consume(crypto.randomUUID());
|
|
124
202
|
expect((await store.findActive(EMAIL, PURPOSE))?.id).toBe(created.id);
|
|
125
203
|
});
|
|
126
204
|
});
|
|
127
205
|
describe("invalidateById", () => {
|
|
128
|
-
|
|
206
|
+
if (manyActiveCodes) {
|
|
207
|
+
it("deactivates only the named record", async () => {
|
|
208
|
+
const now = Date.now();
|
|
209
|
+
const undelivered = record({ createdAt: now });
|
|
210
|
+
const previous = record({ createdAt: now - 1000 });
|
|
211
|
+
await store.create(previous);
|
|
212
|
+
await store.create(undelivered);
|
|
213
|
+
await store.invalidateById(undelivered.id);
|
|
214
|
+
expect((await store.findActive(EMAIL, PURPOSE))?.id, "discarding an undeliverable code must not take a concurrent " +
|
|
215
|
+
"request's code with it").toBe(previous.id);
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
it.skip("a store keeping one code per pair has no older code to keep — when overlapping requests each send a code and the later send fails, discarding it leaves no code the user can enter (oneActiveCode: true)", () => { });
|
|
220
|
+
}
|
|
221
|
+
it("leaves a newer code active", async () => {
|
|
129
222
|
const now = Date.now();
|
|
130
|
-
const
|
|
131
|
-
const
|
|
132
|
-
await store.create(
|
|
133
|
-
await store.create(
|
|
134
|
-
await store.invalidateById(
|
|
135
|
-
expect((await store.findActive(EMAIL, PURPOSE))?.id, "
|
|
136
|
-
"
|
|
223
|
+
const older = record({ createdAt: now - 1000 });
|
|
224
|
+
const newer = record({ createdAt: now });
|
|
225
|
+
await store.create(older);
|
|
226
|
+
await store.create(newer);
|
|
227
|
+
await store.invalidateById(older.id);
|
|
228
|
+
expect((await store.findActive(EMAIL, PURPOSE))?.id, "a slow failed send discards its own code, never the code a " +
|
|
229
|
+
"retry has since delivered").toBe(newer.id);
|
|
137
230
|
});
|
|
138
231
|
it("is a no-op for an unknown id", async () => {
|
|
139
232
|
const created = record();
|
|
140
233
|
await store.create(created);
|
|
141
|
-
await store.invalidateById(
|
|
234
|
+
await store.invalidateById(crypto.randomUUID());
|
|
142
235
|
expect((await store.findActive(EMAIL, PURPOSE))?.id).toBe(created.id);
|
|
143
236
|
});
|
|
144
237
|
});
|
|
@@ -171,7 +264,7 @@ export function runOtpStoreContractTests(options) {
|
|
|
171
264
|
expect(await store.recordAttempt(created.id)).toBe(3);
|
|
172
265
|
});
|
|
173
266
|
it("returns 0 for an unknown id", async () => {
|
|
174
|
-
expect(await store.recordAttempt(
|
|
267
|
+
expect(await store.recordAttempt(crypto.randomUUID())).toBe(0);
|
|
175
268
|
});
|
|
176
269
|
it("counts each record separately", async () => {
|
|
177
270
|
const mine = record();
|
|
@@ -191,5 +284,46 @@ export function runOtpStoreContractTests(options) {
|
|
|
191
284
|
"rest through").toStrictEqual([1, 2, 3, 4, 5]);
|
|
192
285
|
});
|
|
193
286
|
});
|
|
287
|
+
describe("EmailOtpService over the store", () => {
|
|
288
|
+
it("leaves no older code redeemable after a successful verify", async () => {
|
|
289
|
+
const overlapping = new EmailOtpService({
|
|
290
|
+
store: {
|
|
291
|
+
create: (created) => store.create(created),
|
|
292
|
+
findActive: (email, purpose) => store.findActive(email, purpose),
|
|
293
|
+
recordAttempt: (id) => store.recordAttempt(id),
|
|
294
|
+
consume: (id) => store.consume(id),
|
|
295
|
+
invalidateById: (id) => store.invalidateById(id),
|
|
296
|
+
invalidate: () => Promise.resolve(),
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
const codes = new Map();
|
|
300
|
+
for (let i = 0; i < 2; i++) {
|
|
301
|
+
await overlapping.request({
|
|
302
|
+
email: EMAIL,
|
|
303
|
+
purpose: PURPOSE,
|
|
304
|
+
onDeliver: (code, _expiresAt, codeId) => {
|
|
305
|
+
codes.set(codeId, code);
|
|
306
|
+
},
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
const newest = await store.findActive(EMAIL, PURPOSE);
|
|
310
|
+
assert(newest !== null, "a requested code must be findable");
|
|
311
|
+
const newestCode = codes.get(newest.id);
|
|
312
|
+
const olderCode = [...codes].find(([id]) => id !== newest.id)?.[1];
|
|
313
|
+
assert(newestCode !== undefined && olderCode !== undefined);
|
|
314
|
+
const service = new EmailOtpService({ store });
|
|
315
|
+
expect(await service.verify({
|
|
316
|
+
email: EMAIL,
|
|
317
|
+
purpose: PURPOSE,
|
|
318
|
+
code: newestCode,
|
|
319
|
+
})).toStrictEqual({ status: "success" });
|
|
320
|
+
expect(await service.verify({
|
|
321
|
+
email: EMAIL,
|
|
322
|
+
purpose: PURPOSE,
|
|
323
|
+
code: olderCode,
|
|
324
|
+
}), "two overlapping requests left two codes; signing in with the " +
|
|
325
|
+
"newest must not leave the older one redeemable").toStrictEqual({ status: "invalid" });
|
|
326
|
+
});
|
|
327
|
+
});
|
|
194
328
|
});
|
|
195
329
|
}
|
|
@@ -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.40.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",
|