@udibo/oauth2 0.39.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.
@@ -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 most recently created active record for `(email, purpose)`, or `null`.
71
- * Must still return an expired record — the service reports expiry itself.
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 newer code survives. A no-op for an unknown id.
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
- /** The newest active record for `(email, purpose)`, expired or not; else `null`. */
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
- /** Atomically deactivate one active record; return whether it was claimed. */
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>;
@@ -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
- /** The newest active record for `(email, purpose)`, expired or not; else `null`. */
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
- /** Atomically deactivate one active record; return whether it was claimed. */
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
- return Promise.resolve(this.#active.delete(id));
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) {
@@ -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 { OtpStore } from "../../identity/otp.ts";
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"`.
@@ -46,6 +61,10 @@ export interface OtpStoreContractOptions {
46
61
  *
47
62
  * The concurrency case fires overlapping `recordAttempt` calls and requires
48
63
  * each to see a distinct count; a store that reads, awaits, then writes hands
49
- * 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`.
50
69
  */
51
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,6 +25,7 @@
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";
@@ -51,9 +54,15 @@ function record(overrides = {}) {
51
54
  *
52
55
  * The concurrency case fires overlapping `recordAttempt` calls and requires
53
56
  * each to see a distinct count; a store that reads, awaits, then writes hands
54
- * 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`.
55
62
  */
56
63
  export function runOtpStoreContractTests(options) {
64
+ const oneActiveCode = options.oneActiveCode ?? false;
65
+ const manyActiveCodes = !oneActiveCode;
57
66
  describe(options.describeName ?? "OtpStore contract", () => {
58
67
  let store;
59
68
  beforeEach(async () => {
@@ -84,6 +93,31 @@ export function runOtpStoreContractTests(options) {
84
93
  await store.create(newer);
85
94
  expect((await store.findActive(EMAIL, PURPOSE))?.id, "a re-send supersedes the code before it").toBe(newer.id);
86
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
+ }
87
121
  it("returns an expired record rather than hiding it", async () => {
88
122
  await store.create(record({ expiresAt: Date.now() - 1000 }));
89
123
  assert((await store.findActive(EMAIL, PURPOSE)) !== null, "expiry is the service's call — it reports `expired` distinctly " +
@@ -110,14 +144,56 @@ export function runOtpStoreContractTests(options) {
110
144
  await store.consume(created.id);
111
145
  expect(await store.findActive(EMAIL, PURPOSE)).toBe(null);
112
146
  });
113
- it("leaves an older active code findable", async () => {
147
+ it("retires every older active code for the pair", async () => {
114
148
  const now = Date.now();
149
+ const oldest = record({ createdAt: now - 2000 });
115
150
  const older = record({ createdAt: now - 1000 });
116
- const newer = record({ createdAt: now });
151
+ const newest = record({ createdAt: now });
152
+ await store.create(oldest);
117
153
  await store.create(older);
118
- await store.create(newer);
119
- await store.consume(newer.id);
120
- expect((await store.findActive(EMAIL, PURPOSE))?.id).toBe(older.id);
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);
121
197
  });
122
198
  it("is a no-op for an unknown id", async () => {
123
199
  const created = record();
@@ -127,15 +203,30 @@ export function runOtpStoreContractTests(options) {
127
203
  });
128
204
  });
129
205
  describe("invalidateById", () => {
130
- it("deactivates only the named record", async () => {
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 () => {
131
222
  const now = Date.now();
132
- const undelivered = record({ createdAt: now });
133
- const previous = record({ createdAt: now - 1000 });
134
- await store.create(previous);
135
- await store.create(undelivered);
136
- await store.invalidateById(undelivered.id);
137
- expect((await store.findActive(EMAIL, PURPOSE))?.id, "discarding an undeliverable code must not take a concurrent " +
138
- "request's code with it").toBe(previous.id);
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);
139
230
  });
140
231
  it("is a no-op for an unknown id", async () => {
141
232
  const created = record();
@@ -193,5 +284,46 @@ export function runOtpStoreContractTests(options) {
193
284
  "rest through").toStrictEqual([1, 2, 3, 4, 5]);
194
285
  });
195
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
+ });
196
328
  });
197
329
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@udibo/oauth2",
3
- "version": "0.39.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",