@udibo/oauth2 0.23.0 → 0.25.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/adapters/hono/bff/bff.d.ts +4 -2
- package/dist/adapters/hono/bff/bff.js +28 -9
- package/dist/adapters/hono/bff/mod.d.ts +1 -1
- package/dist/adapters/hono/bff/session-store.d.ts +85 -12
- package/dist/adapters/hono/bff/session-store.js +105 -11
- package/dist/adapters/hono/bff/testing.d.ts +6 -2
- package/dist/adapters/hono/bff/testing.js +96 -2
- package/dist/client/direct-client.d.ts +2 -2
- package/dist/client/discovery-cache.d.ts +3 -3
- package/dist/identity/captcha.d.ts +2 -2
- package/dist/models/authorization.d.ts +5 -5
- package/dist/models/authorization.js +2 -2
- package/dist/models/client.d.ts +1 -1
- package/dist/react/components/class-names.d.ts +2 -2
- package/dist/react/components/class-names.js +2 -2
- package/dist/server/authorization-server.d.ts +14 -11
- package/dist/server/authorization-server.js +9 -8
- package/dist/server/grants/refresh-token.d.ts +8 -4
- package/dist/server/grants/refresh-token.js +15 -4
- package/dist/server/resource-server.d.ts +10 -10
- package/dist/server/resource-server.js +3 -4
- package/dist/server/services/token.d.ts +37 -3
- package/dist/testing/contract/mod.d.ts +7 -0
- package/dist/testing/contract/mod.js +7 -0
- package/dist/testing/contract/token-rotation.d.ts +95 -0
- package/dist/testing/contract/token-rotation.js +383 -0
- package/dist/testing/contract/token.d.ts +3 -0
- package/dist/testing/contract/token.js +44 -2
- package/dist/testing/services.d.ts +7 -0
- package/dist/testing/services.js +43 -14
- package/package.json +1 -1
|
@@ -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
|
|
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
|
-
|
|
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
|
/**
|
package/dist/testing/services.js
CHANGED
|
@@ -317,38 +317,46 @@ export class MemoryTokenService {
|
|
|
317
317
|
return this.#hydrate(stored, "", refreshToken);
|
|
318
318
|
}
|
|
319
319
|
async save(token) {
|
|
320
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
3
|
+
"version": "0.25.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",
|