@udibo/oauth2 0.24.0 → 0.26.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.
@@ -0,0 +1,383 @@
1
+ /**
2
+ * Contract test suite for {@link TokenServiceInterface.rotate} and its
3
+ * linearizability with {@link TokenServiceInterface.revokeFamily}.
4
+ *
5
+ * `runTokenServiceContractTests` checks what one caller sees. This suite
6
+ * checks what concurrent callers see: two rotations of one refresh token, a
7
+ * family revocation racing a rotation, a replay racing a refresh through the
8
+ * refresh grant, a rotation in an unrelated family, and a rotation whose
9
+ * insert fails. Each case asserts the store's end state, the live tokens of
10
+ * the family, not only the value each call resolved.
11
+ *
12
+ * The store under test awaits {@link TokenRotationBarriers.afterClaim} inside
13
+ * `rotate`, between its claim and its insert, so the suite can hold a rotation
14
+ * there and race the other call against it; a store that never reaches the
15
+ * barrier fails every held case. Only a store whose `rotate` never awaits
16
+ * between its claim and its insert, such as `MemoryTokenService`, opts out with
17
+ * `rotateAwaitsBarrier: false`; the suite then runs those schedules as
18
+ * overlapping calls.
19
+ *
20
+ * @module
21
+ */
22
+ import { assert, beforeEach, describe, expect, it } from "vitest";
23
+ import { InvalidGrantError } from "../../errors.js";
24
+ import { RefreshTokenGrant } from "../../server/grants/refresh-token.js";
25
+ import { race } from "./_race.js";
26
+ const defaultMakeUser = (seq) => ({ id: `u${seq}`, username: `user${seq}` });
27
+ const defaultMakeClient = (seq) => ({
28
+ id: `client-${seq}`,
29
+ grants: ["authorization_code", "refresh_token"],
30
+ });
31
+ async function settledWithin(promise, ms) {
32
+ let timer;
33
+ const timeout = new Promise((resolve) => {
34
+ timer = setTimeout(() => resolve(false), ms);
35
+ });
36
+ try {
37
+ return await Promise.race([
38
+ promise.then(() => true, () => true),
39
+ timeout,
40
+ ]);
41
+ }
42
+ finally {
43
+ clearTimeout(timer);
44
+ }
45
+ }
46
+ /**
47
+ * Runs the rotation contract suite: concurrent schedules of
48
+ * {@linkcode TokenServiceInterface.rotate} against itself, against
49
+ * {@linkcode TokenServiceInterface.revokeFamily}, and through the refresh
50
+ * grant. The service must implement `rotate`, `revokeFamily` and
51
+ * `getRevokedRefreshToken`.
52
+ */
53
+ export function runTokenRotationContractTests(options) {
54
+ const makeUser = options.makeUser ?? defaultMakeUser;
55
+ const makeClient = options.makeClient ?? defaultMakeClient;
56
+ const pausable = options.rotateAwaitsBarrier ?? true;
57
+ const blockedAfterMs = options.blockedAfterMs ?? 100;
58
+ describe(options.describeName ?? "TokenServiceInterface.rotate contract", () => {
59
+ let clientService;
60
+ let tokenService;
61
+ let user;
62
+ let client;
63
+ let armed;
64
+ let family;
65
+ let otherFamily;
66
+ const barriers = {
67
+ async afterClaim() {
68
+ const hold = armed;
69
+ if (!hold)
70
+ return;
71
+ armed = undefined;
72
+ hold.reached.resolve();
73
+ await hold.release.promise;
74
+ },
75
+ };
76
+ beforeEach(async () => {
77
+ armed = undefined;
78
+ family = crypto.randomUUID();
79
+ otherFamily = crypto.randomUUID();
80
+ const services = await options.makeServices(barriers);
81
+ clientService = services.clientService;
82
+ tokenService = services.tokenService;
83
+ user = makeUser(1);
84
+ client = makeClient(1);
85
+ await options.addUser(services.userService, user, "pw");
86
+ await options.addClient(clientService, client);
87
+ });
88
+ function rotate(current, next) {
89
+ assert(typeof tokenService.rotate === "function", "runTokenRotationContractTests needs a token service that implements rotate");
90
+ return tokenService.rotate(current, next);
91
+ }
92
+ async function member(familyId) {
93
+ return {
94
+ accessToken: await tokenService.generateAccessToken(client, user),
95
+ accessTokenExpiresAt: new Date(Date.now() + 60_000),
96
+ refreshToken: (await tokenService.generateRefreshToken(client, user)),
97
+ refreshTokenExpiresAt: new Date(Date.now() + 600_000),
98
+ familyId,
99
+ familyCreatedAt: new Date(Date.now() - 60_000),
100
+ client,
101
+ user,
102
+ };
103
+ }
104
+ async function savedMember(familyId) {
105
+ const token = await member(familyId);
106
+ await tokenService.save(token);
107
+ return token;
108
+ }
109
+ async function live(tokens) {
110
+ const found = [];
111
+ for (const token of tokens) {
112
+ if ((await tokenService.getRefreshToken(token.refreshToken)) ||
113
+ (await tokenService.getToken(token.accessToken))) {
114
+ found.push(token);
115
+ }
116
+ }
117
+ return found;
118
+ }
119
+ async function liveOf(refreshTokens) {
120
+ const found = [];
121
+ for (const refreshToken of refreshTokens) {
122
+ if (await tokenService.getRefreshToken(refreshToken)) {
123
+ found.push(refreshToken);
124
+ }
125
+ }
126
+ return found;
127
+ }
128
+ function arm() {
129
+ const hold = {
130
+ reached: Promise.withResolvers(),
131
+ release: Promise.withResolvers(),
132
+ };
133
+ armed = hold;
134
+ return hold;
135
+ }
136
+ async function untilHeld(hold, rotation, unheld = "rotate settled without awaiting barriers.afterClaim; wire " +
137
+ "the barrier into rotate between its claim and its insert, or, " +
138
+ "only if rotate never awaits between them, set " +
139
+ "rotateAwaitsBarrier: false") {
140
+ const outcome = await Promise.race([
141
+ hold.reached.promise.then(() => "held"),
142
+ rotation.then(() => "settled", () => "settled"),
143
+ ]);
144
+ assert(outcome === "held", unheld);
145
+ }
146
+ /**
147
+ * Runs `during` while `rotate(current, next)` is held after its claim,
148
+ * or, for a store that cannot be held, while the rotation is in flight.
149
+ */
150
+ async function whileRotating(current, next, during) {
151
+ if (!pausable) {
152
+ const rotation = rotate(current, next);
153
+ const other = during();
154
+ return await Promise.all([
155
+ Promise.allSettled([rotation]).then(([r]) => r),
156
+ Promise.allSettled([other]).then(([o]) => o),
157
+ ]);
158
+ }
159
+ const hold = arm();
160
+ const rotation = rotate(current, next);
161
+ await untilHeld(hold, rotation);
162
+ const other = during();
163
+ await settledWithin(other, blockedAfterMs);
164
+ hold.release.resolve();
165
+ return await Promise.all([
166
+ Promise.allSettled([rotation]).then(([r]) => r),
167
+ Promise.allSettled([other]).then(([o]) => o),
168
+ ]);
169
+ }
170
+ /**
171
+ * Wraps the token service to record every refresh token the grant
172
+ * generates, and to run `beforeRotate` before each `rotate` reaches the
173
+ * store.
174
+ */
175
+ function observe(beforeRotate) {
176
+ const issued = [];
177
+ const observed = new Proxy(tokenService, {
178
+ get(target, prop) {
179
+ if (prop === "generateRefreshToken") {
180
+ return async (...args) => {
181
+ const value = await target.generateRefreshToken(...args);
182
+ if (value)
183
+ issued.push(value);
184
+ return value;
185
+ };
186
+ }
187
+ if (prop === "rotate" && beforeRotate) {
188
+ return async (current, next) => {
189
+ await beforeRotate();
190
+ return await rotate(current, next);
191
+ };
192
+ }
193
+ const value = Reflect.get(target, prop, target);
194
+ return typeof value === "function" ? value.bind(target) : value;
195
+ },
196
+ });
197
+ return { observed, issued };
198
+ }
199
+ function refreshGrant(service, reuseEvents) {
200
+ return new RefreshTokenGrant({
201
+ resolve: () => ({ clientService, tokenService: service }),
202
+ onTokenReuse: (event) => {
203
+ reuseEvents.push(event);
204
+ },
205
+ });
206
+ }
207
+ function refresh(grant, refreshToken) {
208
+ const body = new FormData();
209
+ body.set("grant_type", "refresh_token");
210
+ body.set("refresh_token", refreshToken);
211
+ return grant.token(new Request("https://as.test/token", { method: "POST" }), client, body);
212
+ }
213
+ it("implements rotate, revokeFamily and getRevokedRefreshToken", () => {
214
+ assert(typeof tokenService.rotate === "function" &&
215
+ typeof tokenService.revokeFamily === "function" &&
216
+ typeof tokenService.getRevokedRefreshToken === "function", "the rotation contract covers rotate together with reuse detection");
217
+ });
218
+ it("refuses to rotate a member of a family revoked before it began", async () => {
219
+ const parent = await savedMember(family);
220
+ const next = await member(family);
221
+ await tokenService.revokeFamily(family);
222
+ expect(await rotate(parent, next)).toBe(undefined);
223
+ expect(await live([parent, next]), "a rotation that starts after its family was revoked must not " +
224
+ "give the family a live member again").toStrictEqual([]);
225
+ });
226
+ it("leaves no live member when the family is revoked while a rotation is in flight", async () => {
227
+ const parent = await savedMember(family);
228
+ const next = await member(family);
229
+ const [rotated, revoked] = await whileRotating(parent, next, () => tokenService.revokeFamily(family));
230
+ assert(revoked.status === "fulfilled");
231
+ assert(rotated.status === "fulfilled");
232
+ expect([undefined, next.refreshToken]).toContain(rotated.value?.refreshToken);
233
+ expect(await live([parent, next]), "revokeFamily must wait for a rotation holding the family, or the " +
234
+ "successor that rotation commits afterwards stays live in a " +
235
+ "family the server revoked").toStrictEqual([]);
236
+ });
237
+ it("revokes the successor of a rotation that committed before the family was revoked", async () => {
238
+ const parent = await savedMember(family);
239
+ const next = await member(family);
240
+ expect((await rotate(parent, next))?.refreshToken).toBe(next.refreshToken);
241
+ expect(await tokenService.revokeFamily(family)).toBe(true);
242
+ expect(await live([parent, next])).toStrictEqual([]);
243
+ });
244
+ it("gives concurrent rotations of one refresh token exactly one successor", async () => {
245
+ const parent = await savedMember(family);
246
+ const successors = await race(() => member(family));
247
+ const results = await race((caller) => rotate(parent, successors[caller]));
248
+ expect(results.filter((result) => result !== undefined).length, "rotate must claim its parent once, however many callers race").toBe(1);
249
+ expect(await live([parent, ...successors])).toHaveLength(1);
250
+ });
251
+ it("refuses a second rotation that starts while the first is held after its claim", async ({ skip, }) => {
252
+ skip(!pausable, "rotate cannot be held (rotateAwaitsBarrier: false)");
253
+ const parent = await savedMember(family);
254
+ const first = await member(family);
255
+ const second = await member(family);
256
+ const [held, racing] = await whileRotating(parent, first, () => rotate(parent, second));
257
+ assert(held.status === "fulfilled" && racing.status === "fulfilled");
258
+ expect(held.value?.refreshToken).toBe(first.refreshToken);
259
+ expect(racing.value).toBe(undefined);
260
+ expect(await live([parent, first, second])).toStrictEqual([first]);
261
+ });
262
+ it("answers a refresh that loses the claim to a concurrent one with invalid_grant, keeping the winner's successor", async () => {
263
+ const parent = await savedMember(family);
264
+ const held = Promise.withResolvers();
265
+ const release = Promise.withResolvers();
266
+ let rotations = 0;
267
+ const { observed, issued } = observe(async () => {
268
+ if (rotations++ === 0) {
269
+ held.resolve();
270
+ await release.promise;
271
+ }
272
+ });
273
+ const reuseEvents = [];
274
+ const grant = refreshGrant(observed, reuseEvents);
275
+ const first = refresh(grant, parent.refreshToken);
276
+ await untilHeld({ reached: held }, first, "the refresh grant settled without calling rotate");
277
+ const second = await refresh(grant, parent.refreshToken);
278
+ release.resolve();
279
+ const firstOutcome = await Promise.allSettled([first]);
280
+ assert(firstOutcome[0].status === "rejected");
281
+ expect(firstOutcome[0].reason).toBeInstanceOf(InvalidGrantError);
282
+ expect(reuseEvents, "losing the claim is a race, not a replay of a rotated-out token").toStrictEqual([]);
283
+ const liveRefreshTokens = await liveOf([
284
+ parent.refreshToken,
285
+ ...issued,
286
+ ]);
287
+ expect(liveRefreshTokens).toStrictEqual([second.refreshToken]);
288
+ });
289
+ it("answers a replay that arrives while a rotation is held after its claim with invalid_grant, without revoking the family", async ({ skip, }) => {
290
+ skip(!pausable, "rotate cannot be held (rotateAwaitsBarrier: false)");
291
+ const parent = await savedMember(family);
292
+ const { observed, issued } = observe();
293
+ const reuseEvents = [];
294
+ const grant = refreshGrant(observed, reuseEvents);
295
+ const hold = arm();
296
+ const first = refresh(grant, parent.refreshToken);
297
+ await untilHeld(hold, first);
298
+ const replay = refresh(grant, parent.refreshToken);
299
+ await settledWithin(replay, blockedAfterMs);
300
+ hold.release.resolve();
301
+ const [firstOutcome, replayOutcome] = await Promise.allSettled([
302
+ first,
303
+ replay,
304
+ ]);
305
+ assert(firstOutcome.status === "fulfilled");
306
+ assert(replayOutcome.status === "rejected");
307
+ expect(replayOutcome.reason).toBeInstanceOf(InvalidGrantError);
308
+ expect(reuseEvents, "a replay inside the claim-to-insert gap found no live successor " +
309
+ "and was taken for theft").toStrictEqual([]);
310
+ const liveRefreshTokens = await liveOf([
311
+ parent.refreshToken,
312
+ ...issued,
313
+ ]);
314
+ expect(liveRefreshTokens).toStrictEqual([
315
+ firstOutcome.value.refreshToken,
316
+ ]);
317
+ });
318
+ it("does not hold a rotation in another family behind a held one", async () => {
319
+ const parent = await savedMember(family);
320
+ const next = await member(family);
321
+ const otherParent = await savedMember(otherFamily);
322
+ const otherNext = await member(otherFamily);
323
+ if (!pausable) {
324
+ const [rotated, other] = await Promise.all([
325
+ rotate(parent, next),
326
+ rotate(otherParent, otherNext),
327
+ ]);
328
+ expect(rotated?.refreshToken).toBe(next.refreshToken);
329
+ expect(other?.refreshToken).toBe(otherNext.refreshToken);
330
+ return;
331
+ }
332
+ const hold = arm();
333
+ const rotation = rotate(parent, next);
334
+ await untilHeld(hold, rotation);
335
+ const other = rotate(otherParent, otherNext);
336
+ const settled = await settledWithin(other, 5_000);
337
+ hold.release.resolve();
338
+ expect(settled, "the family lock must be keyed by family, so one login's " +
339
+ "rotation never waits on another's").toBe(true);
340
+ expect((await other)?.refreshToken).toBe(otherNext.refreshToken);
341
+ expect((await rotation)?.refreshToken).toBe(next.refreshToken);
342
+ });
343
+ it("leaves the parent live and persists nothing when the successor cannot be persisted", async () => {
344
+ const parent = await savedMember(family);
345
+ const unpersistable = await member(family);
346
+ Object.defineProperty(unpersistable, "refreshToken", {
347
+ enumerable: true,
348
+ get() {
349
+ throw new Error("forced persistence failure");
350
+ },
351
+ });
352
+ const outcome = await Promise.allSettled([
353
+ rotate(parent, unpersistable),
354
+ ]);
355
+ if (outcome[0].status === "fulfilled") {
356
+ expect(outcome[0].value).toBe(undefined);
357
+ }
358
+ expect(await tokenService.getRefreshToken(parent.refreshToken), "a failed rotation must not spend its parent").not.toBe(undefined);
359
+ expect(await tokenService.getToken(unpersistable.accessToken)).toBe(undefined);
360
+ const retry = await member(family);
361
+ expect((await rotate(parent, retry))?.refreshToken).toBe(retry.refreshToken);
362
+ });
363
+ it("rolls the claim back when the insert fails after it", async ({ skip, }) => {
364
+ skip(!pausable, "rotate cannot be held (rotateAwaitsBarrier: false)");
365
+ const parent = await savedMember(family);
366
+ const next = await member(family);
367
+ const hold = arm();
368
+ const rotation = rotate(parent, next);
369
+ await untilHeld(hold, rotation);
370
+ hold.release.reject(new Error("forced insert failure"));
371
+ const outcome = await Promise.allSettled([rotation]);
372
+ if (outcome[0].status === "fulfilled") {
373
+ expect(outcome[0].value).toBe(undefined);
374
+ }
375
+ expect(await tokenService.getRefreshToken(parent.refreshToken), "the claim and the insert are one step: a failed insert must " +
376
+ "leave the parent live").not.toBe(undefined);
377
+ expect(await live([next])).toStrictEqual([]);
378
+ expect(await tokenService.getRevokedRefreshToken(next.refreshToken)).toBe(undefined);
379
+ const retry = await member(family);
380
+ expect((await rotate(parent, retry))?.refreshToken, "a failed rotation must release the family").toBe(retry.refreshToken);
381
+ });
382
+ });
383
+ }
@@ -12,6 +12,9 @@
12
12
  * with `user: undefined` and persists a token whose `user` is unset — a store
13
13
  * that requires a user cannot hold a machine token.
14
14
  *
15
+ * When the service implements `rotate`, its single-caller contract is checked
16
+ * here too; `runTokenRotationContractTests` checks it under concurrent callers.
17
+ *
15
18
  * @module
16
19
  */
17
20
  import type { ClientInterface } from "../../models/client.ts";
@@ -12,6 +12,9 @@
12
12
  * with `user: undefined` and persists a token whose `user` is unset — a store
13
13
  * that requires a user cannot hold a machine token.
14
14
  *
15
+ * When the service implements `rotate`, its single-caller contract is checked
16
+ * here too; `runTokenRotationContractTests` checks it under concurrent callers.
17
+ *
15
18
  * @module
16
19
  */
17
20
  import { assert, beforeEach, describe, expect, it } from "vitest";
@@ -212,10 +215,10 @@ export function runTokenServiceContractTests(options) {
212
215
  describe("refresh-token rotation family", () => {
213
216
  const FAMILY = "family-1";
214
217
  const OTHER_FAMILY = "family-2";
215
- async function saveFamilyMember(familyId, refreshTokenExpiresAt = new Date(Date.now() + 600_000)) {
218
+ async function familyMember(familyId, refreshTokenExpiresAt = new Date(Date.now() + 600_000)) {
216
219
  const accessToken = await tokenService.generateAccessToken(client, user);
217
220
  const refreshToken = (await tokenService.generateRefreshToken(client, user));
218
- const token = {
221
+ return {
219
222
  accessToken,
220
223
  accessTokenExpiresAt: new Date(Date.now() + 60_000),
221
224
  refreshToken,
@@ -225,6 +228,9 @@ export function runTokenServiceContractTests(options) {
225
228
  client,
226
229
  user,
227
230
  };
231
+ }
232
+ async function saveFamilyMember(familyId, refreshTokenExpiresAt) {
233
+ const token = await familyMember(familyId, refreshTokenExpiresAt);
228
234
  await tokenService.save(token);
229
235
  return token;
230
236
  }
@@ -285,6 +291,42 @@ export function runTokenServiceContractTests(options) {
285
291
  revoked.rotatedAt.getTime() <= Date.now() + 1000, "without rotatedAt the refresh grant's replayGraceSeconds never " +
286
292
  "applies, so every straggler revokes its family");
287
293
  });
294
+ it("rotates in one call: the parent is spent and stamped, the successor live", async ({ skip, }) => {
295
+ skip(typeof tokenService.rotate !== "function", "the service does not implement rotate");
296
+ const parent = await saveFamilyMember(FAMILY);
297
+ const next = await familyMember(FAMILY);
298
+ const before = Date.now();
299
+ const rotated = await tokenService.rotate(parent, next);
300
+ expect(rotated?.refreshToken).toBe(next.refreshToken);
301
+ expect((await tokenService.getRefreshToken(next.refreshToken))?.familyId).toBe(FAMILY);
302
+ expect(await tokenService.getToken(next.accessToken)).not.toBe(undefined);
303
+ expect(await tokenService.getRefreshToken(parent.refreshToken)).toBe(undefined);
304
+ expect(await tokenService.getToken(parent.accessToken), "rotation ends the parent's access token, as revokeRotated does").toBe(undefined);
305
+ const tombstone = await tokenService.getRevokedRefreshToken(parent.refreshToken);
306
+ expect(tombstone?.familyId).toBe(FAMILY);
307
+ assert(tombstone?.rotatedAt instanceof Date &&
308
+ tombstone.rotatedAt.getTime() >= before - 1000 &&
309
+ tombstone.rotatedAt.getTime() <= Date.now() + 1000, "rotate must stamp rotatedAt as revokeRotated does, or the " +
310
+ "refresh grant's replayGraceSeconds and the revocation " +
311
+ "endpoint's family revocation stop applying");
312
+ });
313
+ it("refuses to rotate a refresh token that is no longer live, persisting nothing", async ({ skip, }) => {
314
+ skip(typeof tokenService.rotate !== "function", "the service does not implement rotate");
315
+ const parent = await saveFamilyMember(FAMILY);
316
+ const first = await familyMember(FAMILY);
317
+ const second = await familyMember(FAMILY);
318
+ const revokedParent = await saveFamilyMember(FAMILY);
319
+ const third = await familyMember(FAMILY);
320
+ await tokenService.revoke(revokedParent.refreshToken, "refresh_token");
321
+ expect(await tokenService.rotate(parent, first)).not.toBe(undefined);
322
+ expect(await tokenService.rotate(parent, second), "a refresh token is claimed once").toBe(undefined);
323
+ expect(await tokenService.rotate(revokedParent, third)).toBe(undefined);
324
+ for (const refused of [second, third]) {
325
+ expect(await tokenService.getRefreshToken(refused.refreshToken)).toBe(undefined);
326
+ expect(await tokenService.getToken(refused.accessToken)).toBe(undefined);
327
+ expect(await tokenService.getRevokedRefreshToken(refused.refreshToken)).toBe(undefined);
328
+ }
329
+ });
288
330
  it("finds a rotated-out token's family, so revoking it ends the live successor", async ({ skip, }) => {
289
331
  skip(typeof tokenService.revokeRotated !== "function", "the service does not implement revokeRotated");
290
332
  const rotatedOut = await saveFamilyMember(FAMILY);
@@ -247,6 +247,13 @@ export declare class MemoryTokenService<C extends ClientInterface, U extends Mem
247
247
  * token was already claimed.
248
248
  */
249
249
  revokeRotated(token: RefreshToken<C, U, S>): Promise<boolean>;
250
+ /**
251
+ * Claims `current` and saves `next` in one step, stamping the rotated-out
252
+ * tombstone with `rotatedAt` as {@linkcode revokeRotated} does. Resolves
253
+ * `undefined`, saving nothing, when `current` is no longer live or either
254
+ * token's family has been revoked.
255
+ */
256
+ rotate(current: RefreshToken<C, U, S>, next: RefreshToken<C, U, S>): Promise<RefreshToken<C, U, S> | undefined>;
250
257
  /** Whether the family has a live refresh token that has not expired. */
251
258
  hasLiveRefreshToken(familyId: string): Promise<boolean>;
252
259
  /**
@@ -317,38 +317,46 @@ export class MemoryTokenService {
317
317
  return this.#hydrate(stored, "", refreshToken);
318
318
  }
319
319
  async save(token) {
320
- const accessTokenHash = await sha256Hash(token.accessToken);
320
+ this.#store(await this.#prepare(token));
321
+ return token;
322
+ }
323
+ async #prepare(token) {
321
324
  const stored = {
322
- accessTokenHash,
325
+ accessTokenHash: await sha256Hash(token.accessToken),
323
326
  accessTokenExpiresAt: token.accessTokenExpiresAt,
324
327
  clientId: token.client.id,
325
328
  userId: token.user?.id,
326
329
  scope: token.scope,
327
330
  authenticationContext: snapshotAuthenticationContext(token.authenticationContext),
328
331
  };
329
- if (token.code) {
332
+ if (token.code)
330
333
  stored.codeHash = await sha256Hash(token.code);
331
- let bucket = this.#tokensByCodeHash.get(stored.codeHash);
332
- if (!bucket) {
333
- bucket = new Set();
334
- this.#tokensByCodeHash.set(stored.codeHash, bucket);
335
- }
336
- bucket.add(accessTokenHash);
337
- }
338
334
  if ("refreshToken" in token) {
339
335
  const rt = token;
340
336
  stored.refreshTokenHash = await sha256Hash(rt.refreshToken);
341
337
  stored.refreshTokenExpiresAt = rt.refreshTokenExpiresAt;
342
338
  stored.familyId = rt.familyId;
343
339
  stored.familyCreatedAt = rt.familyCreatedAt;
344
- if (rt.familyId && this.#revokedFamilies.has(rt.familyId)) {
340
+ }
341
+ return stored;
342
+ }
343
+ #store(stored) {
344
+ if (stored.codeHash) {
345
+ let bucket = this.#tokensByCodeHash.get(stored.codeHash);
346
+ if (!bucket) {
347
+ bucket = new Set();
348
+ this.#tokensByCodeHash.set(stored.codeHash, bucket);
349
+ }
350
+ bucket.add(stored.accessTokenHash);
351
+ }
352
+ if (stored.refreshTokenHash) {
353
+ if (stored.familyId && this.#revokedFamilies.has(stored.familyId)) {
345
354
  this.#tombstone(stored.refreshTokenHash, stored);
346
- return token;
355
+ return;
347
356
  }
348
357
  this.#tokensByRefreshHash.set(stored.refreshTokenHash, stored);
349
358
  }
350
- this.#tokensByAccessHash.set(accessTokenHash, stored);
351
- return token;
359
+ this.#tokensByAccessHash.set(stored.accessTokenHash, stored);
352
360
  }
353
361
  async revoke(token, hint) {
354
362
  if (typeof token !== "string") {
@@ -386,6 +394,27 @@ export class MemoryTokenService {
386
394
  stored.rotatedAt = new Date();
387
395
  return await this.revoke(token);
388
396
  }
397
+ /**
398
+ * Claims `current` and saves `next` in one step, stamping the rotated-out
399
+ * tombstone with `rotatedAt` as {@linkcode revokeRotated} does. Resolves
400
+ * `undefined`, saving nothing, when `current` is no longer live or either
401
+ * token's family has been revoked.
402
+ */
403
+ async rotate(current, next) {
404
+ const currentHash = await sha256Hash(current.refreshToken);
405
+ const successor = await this.#prepare(next);
406
+ const stored = this.#tokensByRefreshHash.get(currentHash);
407
+ if (!stored ||
408
+ [stored.familyId, successor.familyId].some((familyId) => familyId && this.#revokedFamilies.has(familyId))) {
409
+ return undefined;
410
+ }
411
+ stored.rotatedAt = new Date();
412
+ this.#tokensByAccessHash.delete(stored.accessTokenHash);
413
+ this.#tokensByRefreshHash.delete(currentHash);
414
+ this.#tombstone(currentHash, stored);
415
+ this.#store(successor);
416
+ return next;
417
+ }
389
418
  /** Whether the family has a live refresh token that has not expired. */
390
419
  hasLiveRefreshToken(familyId) {
391
420
  const now = Date.now();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@udibo/oauth2",
3
- "version": "0.24.0",
3
+ "version": "0.26.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",