@udibo/oauth2 0.35.0 → 0.36.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.
@@ -162,6 +162,13 @@ export interface TokenServiceInterface<Client extends ClientInterface, User, Sco
162
162
  * Returns true if any tokens were revoked, which the grant reports as a code
163
163
  * replay (RFC 6819 §4.4.1.1).
164
164
  *
165
+ * After the client check below passes, revoke every live member of the
166
+ * rotation families of those tokens too, as {@linkcode revokeFamily} does,
167
+ * keeping them revoked against a rotation in flight. A refresh token rotated
168
+ * from the code's token carries no `code`, so revoking only the tokens saved
169
+ * with the code leaves the stolen code's session refreshing once it has
170
+ * rotated.
171
+ *
165
172
  * `client` is the client that presented the code. Revoke only when it is the
166
173
  * client the code's tokens were issued to; for any other client revoke
167
174
  * nothing and resolve `false`. A different confidential client could never
@@ -235,6 +235,19 @@ export function runTokenRotationContractTests(options) {
235
235
  "successor that rotation commits afterwards stays live in a " +
236
236
  "family the server revoked").toStrictEqual([]);
237
237
  });
238
+ it("leaves no live member when the code that issued the family is replayed while a rotation is in flight", async () => {
239
+ const code = "replayed-while-rotating";
240
+ const parent = { ...(await member(family)), code };
241
+ await tokenService.save(parent);
242
+ const next = await member(family);
243
+ const [rotated, revoked] = await whileRotating(parent, next, () => tokenService.revokeCode(code, client));
244
+ assert(revoked.status === "fulfilled");
245
+ assert(rotated.status === "fulfilled");
246
+ expect([undefined, next.refreshToken]).toContain(rotated.value?.refreshToken);
247
+ expect(await live([parent, next]), "revokeCode must revoke the code's families as revokeFamily does, " +
248
+ "waiting for a rotation holding one, or the successor that " +
249
+ "rotation commits afterwards stays live after the code's replay").toStrictEqual([]);
250
+ });
238
251
  it("revokes the successor of a rotation that committed before the family was revoked", async () => {
239
252
  const parent = await savedMember(family);
240
253
  const next = await member(family);
@@ -52,6 +52,41 @@ export function runTokenServiceContractTests(options) {
52
52
  await options.addUser(userService, user, "pw");
53
53
  await options.addClient(clientService, client);
54
54
  });
55
+ async function familyMember(familyId, refreshTokenExpiresAt = new Date(Date.now() + 600_000)) {
56
+ const accessToken = await tokenService.generateAccessToken(client, user);
57
+ const refreshToken = (await tokenService.generateRefreshToken(client, user));
58
+ return {
59
+ accessToken,
60
+ accessTokenExpiresAt: new Date(Date.now() + 60_000),
61
+ refreshToken,
62
+ refreshTokenExpiresAt,
63
+ familyId,
64
+ familyCreatedAt: new Date(Date.now() - 60_000),
65
+ client,
66
+ user,
67
+ };
68
+ }
69
+ async function saveFamilyMember(familyId, refreshTokenExpiresAt) {
70
+ const token = await familyMember(familyId, refreshTokenExpiresAt);
71
+ await tokenService.save(token);
72
+ return token;
73
+ }
74
+ async function isLive(token) {
75
+ return ((await tokenService.getRefreshToken(token.refreshToken)) !==
76
+ undefined ||
77
+ (await tokenService.getToken(token.accessToken)) !== undefined);
78
+ }
79
+ async function rotateTo(current, next) {
80
+ if (tokenService.rotate) {
81
+ assert(await tokenService.rotate(current, next));
82
+ return;
83
+ }
84
+ const claimed = tokenService.revokeRotated
85
+ ? await tokenService.revokeRotated(current)
86
+ : await tokenService.revoke(current);
87
+ assert(claimed);
88
+ await tokenService.save(next);
89
+ }
55
90
  describe("generateAccessToken / generateRefreshToken", () => {
56
91
  it("produces unique access tokens", async () => {
57
92
  const a = await tokenService.generateAccessToken(client, user);
@@ -237,6 +272,22 @@ export function runTokenServiceContractTests(options) {
237
272
  expect(await tokenService.revokeCode("replayed-code", client)).toBe(true);
238
273
  expect(await tokenService.getToken(accessToken)).toBe(undefined);
239
274
  });
275
+ it("revokes nothing in a rotated family for a client other than the one the code's tokens were issued to", async () => {
276
+ const other = makeClient(2);
277
+ await options.addClient(clientService, other);
278
+ const family = makeFamilyId();
279
+ const issued = {
280
+ ...(await familyMember(family)),
281
+ code: "rotated-replayed-code",
282
+ };
283
+ await tokenService.save(issued);
284
+ const rotated = await familyMember(family);
285
+ await rotateTo(issued, rotated);
286
+ expect(await tokenService.revokeCode("rotated-replayed-code", other)).toBe(false);
287
+ expect(await isLive(rotated), "the client check comes before any family is revoked — a " +
288
+ "client that could never have redeemed the code must not end " +
289
+ "the session rotated from it").toBe(true);
290
+ });
240
291
  }
241
292
  else {
242
293
  it.skip("revokeCode ignores the presenting client — any client that presents a spent code revokes its tokens (revokeCodeChecksClient: false)", () => { });
@@ -250,25 +301,6 @@ export function runTokenServiceContractTests(options) {
250
301
  family = makeFamilyId();
251
302
  otherFamily = makeFamilyId();
252
303
  });
253
- async function familyMember(familyId, refreshTokenExpiresAt = new Date(Date.now() + 600_000)) {
254
- const accessToken = await tokenService.generateAccessToken(client, user);
255
- const refreshToken = (await tokenService.generateRefreshToken(client, user));
256
- return {
257
- accessToken,
258
- accessTokenExpiresAt: new Date(Date.now() + 60_000),
259
- refreshToken,
260
- refreshTokenExpiresAt,
261
- familyId,
262
- familyCreatedAt: new Date(Date.now() - 60_000),
263
- client,
264
- user,
265
- };
266
- }
267
- async function saveFamilyMember(familyId, refreshTokenExpiresAt) {
268
- const token = await familyMember(familyId, refreshTokenExpiresAt);
269
- await tokenService.save(token);
270
- return token;
271
- }
272
304
  it("implements both halves of reuse detection", () => {
273
305
  assert(typeof tokenService.getRevokedRefreshToken === "function" &&
274
306
  typeof tokenService.revokeFamily === "function", "getRevokedRefreshToken and revokeFamily are optional, but " +
@@ -404,6 +436,49 @@ export function runTokenServiceContractTests(options) {
404
436
  assert((await tokenService.getRefreshToken(unrelated.refreshToken)) !==
405
437
  undefined);
406
438
  });
439
+ it("revokes the rotated successors of a replayed code's tokens, leaving other families live", async () => {
440
+ const code = "rotated-code";
441
+ const issued = { ...(await familyMember(family)), code };
442
+ await tokenService.save(issued);
443
+ const rotated = await familyMember(family);
444
+ await rotateTo(issued, rotated);
445
+ const latest = await familyMember(family);
446
+ await rotateTo(rotated, latest);
447
+ const unrelated = await saveFamilyMember(otherFamily);
448
+ expect(await isLive(latest)).toBe(true);
449
+ expect(await tokenService.revokeCode(code, client), "the replay ended a live token, so the grant must report it").toBe(true);
450
+ expect(await tokenService.getRefreshToken(latest.refreshToken), "a rotated successor carries no code, so revoking only the " +
451
+ "rows issued with the code leaves the stolen code's session " +
452
+ "refreshing — revoke every live member of their families").toBe(undefined);
453
+ expect(await tokenService.getToken(latest.accessToken), "the successor's access token ends with its family").toBe(undefined);
454
+ if (tokenService.hasLiveRefreshToken) {
455
+ expect(await tokenService.hasLiveRefreshToken(family)).toBe(false);
456
+ }
457
+ assert((await tokenService.getRefreshToken(unrelated.refreshToken)) !==
458
+ undefined, "a replayed code must not end families it did not issue");
459
+ });
460
+ it("keeps a replayed code's family revoked: a successor saved after revokeCode is not live", async ({ skip, }) => {
461
+ skip(typeof tokenService.rotate === "function", "the service implements rotate, so the refresh grant never saves " +
462
+ "a successor separately; runTokenRotationContractTests checks " +
463
+ "revokeCode against rotate");
464
+ const code = "claimed-code";
465
+ const issued = { ...(await familyMember(family)), code };
466
+ await tokenService.save(issued);
467
+ const next = await familyMember(family);
468
+ const claimed = tokenService.revokeRotated
469
+ ? await tokenService.revokeRotated(issued)
470
+ : await tokenService.revoke(issued);
471
+ expect(claimed).toBe(true);
472
+ await tokenService.revokeCode(code, client);
473
+ await tokenService.save(next);
474
+ expect(await isLive(next), "a successor saved after its code was replayed must be stored " +
475
+ "already revoked: the refresh grant claims a token and saves " +
476
+ "its successor in two calls, and a replay that lands between " +
477
+ "them otherwise leaves the successor live").toBe(false);
478
+ if (tokenService.hasLiveRefreshToken) {
479
+ expect(await tokenService.hasLiveRefreshToken(family)).toBe(false);
480
+ }
481
+ });
407
482
  it("leaves rotatedAt unset on a token revoked other than by rotation", async () => {
408
483
  const token = await saveFamilyMember(family);
409
484
  await tokenService.revoke(token.refreshToken, "refresh_token");
@@ -294,7 +294,9 @@ export declare class MemoryTokenService<C extends ClientInterface, U extends Mem
294
294
  revokeFamily(familyId: string): Promise<boolean>;
295
295
  /**
296
296
  * Removes every token issued from the given authorization code, used when a
297
- * code is replayed. Resolves `true` when at least one token was removed.
297
+ * code is replayed, and revokes the rotation families of those tokens as
298
+ * {@linkcode revokeFamily} does, so their rotated successors end with them.
299
+ * Resolves `true` when tokens were issued from the code.
298
300
  * Removes nothing and resolves `false` when `client` is given and is not the
299
301
  * client the code's tokens were issued to.
300
302
  */
@@ -367,10 +367,16 @@ export class MemoryTokenService {
367
367
  if (stored.codeHash) {
368
368
  let bucket = this.#tokensByCodeHash.get(stored.codeHash);
369
369
  if (!bucket) {
370
- bucket = { clientId: stored.clientId, accessTokenHashes: new Set() };
370
+ bucket = {
371
+ clientId: stored.clientId,
372
+ accessTokenHashes: new Set(),
373
+ familyIds: new Set(),
374
+ };
371
375
  this.#tokensByCodeHash.set(stored.codeHash, bucket);
372
376
  }
373
377
  bucket.accessTokenHashes.add(stored.accessTokenHash);
378
+ if (stored.familyId)
379
+ bucket.familyIds.add(stored.familyId);
374
380
  }
375
381
  if (stored.refreshTokenHash) {
376
382
  if (stored.familyId && this.#revokedFamilies.has(stored.familyId)) {
@@ -483,6 +489,9 @@ export class MemoryTokenService {
483
489
  * live token was removed.
484
490
  */
485
491
  revokeFamily(familyId) {
492
+ return Promise.resolve(this.#revokeFamily(familyId));
493
+ }
494
+ #revokeFamily(familyId) {
486
495
  this.#revokedFamilies.add(familyId);
487
496
  let revoked = false;
488
497
  for (const [hash, stored] of this.#tokensByRefreshHash) {
@@ -499,11 +508,13 @@ export class MemoryTokenService {
499
508
  revoked = true;
500
509
  }
501
510
  }
502
- return Promise.resolve(revoked);
511
+ return revoked;
503
512
  }
504
513
  /**
505
514
  * Removes every token issued from the given authorization code, used when a
506
- * code is replayed. Resolves `true` when at least one token was removed.
515
+ * code is replayed, and revokes the rotation families of those tokens as
516
+ * {@linkcode revokeFamily} does, so their rotated successors end with them.
517
+ * Resolves `true` when tokens were issued from the code.
507
518
  * Removes nothing and resolves `false` when `client` is given and is not the
508
519
  * client the code's tokens were issued to.
509
520
  */
@@ -515,6 +526,8 @@ export class MemoryTokenService {
515
526
  if (client && client.id.toString() !== bucket.clientId.toString()) {
516
527
  return false;
517
528
  }
529
+ for (const familyId of bucket.familyIds)
530
+ this.#revokeFamily(familyId);
518
531
  for (const accessTokenHash of bucket.accessTokenHashes) {
519
532
  const stored = this.#tokensByAccessHash.get(accessTokenHash);
520
533
  if (stored) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@udibo/oauth2",
3
- "version": "0.35.0",
3
+ "version": "0.36.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",