@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
|
|
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
|
*/
|
package/dist/testing/services.js
CHANGED
|
@@ -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 = {
|
|
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
|
|
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
|
|
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.
|
|
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",
|