@udibo/oauth2 0.34.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.
- package/dist/adapters/hono/bff/auth-request-store.d.ts +4 -1
- package/dist/adapters/hono/bff/auth-request-store.js +4 -1
- package/dist/cli/idp/server.js +2 -1
- package/dist/server/authorization-server.d.ts +32 -2
- package/dist/server/authorization-server.js +21 -2
- package/dist/server/grants/authorization-code.d.ts +56 -5
- package/dist/server/grants/authorization-code.js +97 -21
- package/dist/server/services/token.d.ts +58 -9
- package/dist/testing/contract/code-exchange.d.ts +100 -0
- package/dist/testing/contract/code-exchange.js +309 -0
- package/dist/testing/contract/mod.d.ts +6 -0
- package/dist/testing/contract/mod.js +6 -0
- package/dist/testing/contract/token-rotation.js +13 -0
- package/dist/testing/contract/token.d.ts +8 -0
- package/dist/testing/contract/token.js +121 -19
- package/dist/testing/server.js +3 -2
- package/dist/testing/services.d.ts +26 -6
- package/dist/testing/services.js +55 -10
- package/package.json +1 -1
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contract test suite for {@link TokenServiceInterface.exchangeCode} and its
|
|
3
|
+
* linearizability with {@link TokenServiceInterface.revokeCode}.
|
|
4
|
+
*
|
|
5
|
+
* `runTokenServiceContractTests` checks `revokeCode` for one caller. This
|
|
6
|
+
* suite checks the atomic code exchange under concurrent callers: two
|
|
7
|
+
* exchanges of one code, a replay of the code racing its exchange through
|
|
8
|
+
* the authorization code grant, an exchange of an unrelated code, and an
|
|
9
|
+
* exchange whose insert fails. Each case asserts the store's end state, the
|
|
10
|
+
* live tokens and whether the code can still be claimed, not only the value
|
|
11
|
+
* each call resolved.
|
|
12
|
+
*
|
|
13
|
+
* The store under test awaits {@link CodeExchangeBarriers.afterClaim} inside
|
|
14
|
+
* `exchangeCode`, between its claim and its insert, so the suite can hold an
|
|
15
|
+
* exchange there and race the other call against it; a store that never
|
|
16
|
+
* reaches the barrier fails every held case. Only a store whose
|
|
17
|
+
* `exchangeCode` never awaits between its claim and its insert, such as
|
|
18
|
+
* `MemoryTokenService` paired with its code store, opts out with
|
|
19
|
+
* `exchangeAwaitsBarrier: false`; the suite then runs those schedules as
|
|
20
|
+
* overlapping calls.
|
|
21
|
+
*
|
|
22
|
+
* @module
|
|
23
|
+
*/
|
|
24
|
+
import { assert, beforeEach, describe, expect, it } from "vitest";
|
|
25
|
+
import { InvalidGrantError } from "../../errors.js";
|
|
26
|
+
import { AuthorizationCodeGrant, } from "../../server/grants/authorization-code.js";
|
|
27
|
+
import { race } from "./_race.js";
|
|
28
|
+
const defaultMakeUser = (seq) => ({ id: `u${seq}`, username: `user${seq}` });
|
|
29
|
+
const defaultMakeClient = (seq) => ({
|
|
30
|
+
id: `client-${seq}`,
|
|
31
|
+
grants: ["authorization_code", "refresh_token"],
|
|
32
|
+
});
|
|
33
|
+
async function settledWithin(promise, ms) {
|
|
34
|
+
let timer;
|
|
35
|
+
const timeout = new Promise((resolve) => {
|
|
36
|
+
timer = setTimeout(() => resolve(false), ms);
|
|
37
|
+
});
|
|
38
|
+
try {
|
|
39
|
+
return await Promise.race([
|
|
40
|
+
promise.then(() => true, () => true),
|
|
41
|
+
timeout,
|
|
42
|
+
]);
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
clearTimeout(timer);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Runs the code exchange contract suite: concurrent schedules of
|
|
50
|
+
* {@linkcode TokenServiceInterface.exchangeCode} against itself, and against
|
|
51
|
+
* a replay of the code through the authorization code grant, which calls
|
|
52
|
+
* {@linkcode TokenServiceInterface.revokeCode}. The service must implement
|
|
53
|
+
* `exchangeCode`.
|
|
54
|
+
*/
|
|
55
|
+
export function runCodeExchangeContractTests(options) {
|
|
56
|
+
const makeUser = options.makeUser ?? defaultMakeUser;
|
|
57
|
+
const makeClient = options.makeClient ?? defaultMakeClient;
|
|
58
|
+
const pausable = options.exchangeAwaitsBarrier ?? true;
|
|
59
|
+
const blockedAfterMs = options.blockedAfterMs ?? 100;
|
|
60
|
+
describe(options.describeName ?? "TokenServiceInterface.exchangeCode contract", () => {
|
|
61
|
+
let services;
|
|
62
|
+
let codeService;
|
|
63
|
+
let tokenService;
|
|
64
|
+
let user;
|
|
65
|
+
let client;
|
|
66
|
+
let armed;
|
|
67
|
+
const barriers = {
|
|
68
|
+
async afterClaim() {
|
|
69
|
+
const hold = armed;
|
|
70
|
+
if (!hold)
|
|
71
|
+
return;
|
|
72
|
+
armed = undefined;
|
|
73
|
+
hold.reached.resolve();
|
|
74
|
+
await hold.release.promise;
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
beforeEach(async () => {
|
|
78
|
+
armed = undefined;
|
|
79
|
+
services = await options.makeServices(barriers);
|
|
80
|
+
codeService = services.authorizationCodeService;
|
|
81
|
+
tokenService = services.tokenService;
|
|
82
|
+
user = makeUser(1);
|
|
83
|
+
client = makeClient(1);
|
|
84
|
+
await options.addUser(services.userService, user, "pw");
|
|
85
|
+
await options.addClient(services.clientService, client);
|
|
86
|
+
});
|
|
87
|
+
function exchangeCode(authorizationCode, token) {
|
|
88
|
+
assert(typeof tokenService.exchangeCode === "function", "runCodeExchangeContractTests needs a token service that implements exchangeCode");
|
|
89
|
+
return tokenService.exchangeCode(authorizationCode, token);
|
|
90
|
+
}
|
|
91
|
+
async function savedCode() {
|
|
92
|
+
const authorizationCode = {
|
|
93
|
+
code: await codeService.generateCode(client, user),
|
|
94
|
+
expiresAt: await codeService.expiresAt(client, user),
|
|
95
|
+
client,
|
|
96
|
+
user,
|
|
97
|
+
};
|
|
98
|
+
await codeService.save(authorizationCode);
|
|
99
|
+
return authorizationCode;
|
|
100
|
+
}
|
|
101
|
+
async function tokenFor(authorizationCode) {
|
|
102
|
+
return {
|
|
103
|
+
accessToken: await tokenService.generateAccessToken(client, user),
|
|
104
|
+
accessTokenExpiresAt: new Date(Date.now() + 60_000),
|
|
105
|
+
client,
|
|
106
|
+
user,
|
|
107
|
+
code: authorizationCode.code,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
async function live(tokens) {
|
|
111
|
+
const found = [];
|
|
112
|
+
for (const token of tokens) {
|
|
113
|
+
if (await tokenService.getToken(token.accessToken)) {
|
|
114
|
+
found.push(token.accessToken);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return found;
|
|
118
|
+
}
|
|
119
|
+
async function claimable(authorizationCode) {
|
|
120
|
+
return (await codeService.get(authorizationCode.code)) !== undefined;
|
|
121
|
+
}
|
|
122
|
+
function arm() {
|
|
123
|
+
const hold = {
|
|
124
|
+
reached: Promise.withResolvers(),
|
|
125
|
+
release: Promise.withResolvers(),
|
|
126
|
+
};
|
|
127
|
+
armed = hold;
|
|
128
|
+
return hold;
|
|
129
|
+
}
|
|
130
|
+
async function untilHeld(hold, exchange) {
|
|
131
|
+
const outcome = await Promise.race([
|
|
132
|
+
hold.reached.promise.then(() => "held"),
|
|
133
|
+
exchange.then(() => "settled", () => "settled"),
|
|
134
|
+
]);
|
|
135
|
+
assert(outcome === "held", "exchangeCode settled without awaiting barriers.afterClaim; wire " +
|
|
136
|
+
"the barrier into exchangeCode between its claim and its " +
|
|
137
|
+
"insert, or, only if exchangeCode never awaits between them, " +
|
|
138
|
+
"set exchangeAwaitsBarrier: false");
|
|
139
|
+
}
|
|
140
|
+
function codeGrant(reuseEvents) {
|
|
141
|
+
return new AuthorizationCodeGrant({
|
|
142
|
+
resolve: () => ({
|
|
143
|
+
clientService: services.clientService,
|
|
144
|
+
authorizationCodeService: codeService,
|
|
145
|
+
tokenService,
|
|
146
|
+
}),
|
|
147
|
+
requirePKCE: false,
|
|
148
|
+
onCodeReuse: (event) => {
|
|
149
|
+
reuseEvents.push(event);
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
function redeem(grant, authorizationCode) {
|
|
154
|
+
const body = new FormData();
|
|
155
|
+
body.set("grant_type", "authorization_code");
|
|
156
|
+
body.set("code", authorizationCode.code);
|
|
157
|
+
return grant.token(new Request("https://as.test/token", { method: "POST" }), client, body);
|
|
158
|
+
}
|
|
159
|
+
it("implements exchangeCode", () => {
|
|
160
|
+
assert(typeof tokenService.exchangeCode === "function", "the code exchange contract covers exchangeCode");
|
|
161
|
+
});
|
|
162
|
+
it("claims the code and saves its token in one call, findable by the code", async () => {
|
|
163
|
+
const authorizationCode = await savedCode();
|
|
164
|
+
const token = await tokenFor(authorizationCode);
|
|
165
|
+
const saved = await exchangeCode(authorizationCode, token);
|
|
166
|
+
expect(saved?.accessToken).toBe(token.accessToken);
|
|
167
|
+
expect(await claimable(authorizationCode), "an exchanged code must be spent").toBe(false);
|
|
168
|
+
expect(await codeService.revoke(authorizationCode.code)).toBe(false);
|
|
169
|
+
expect(await live([token])).toStrictEqual([token.accessToken]);
|
|
170
|
+
expect(await tokenService.revokeCode(authorizationCode.code, client), "revokeCode must find the token exchangeCode saved by its code").toBe(true);
|
|
171
|
+
expect(await live([token])).toStrictEqual([]);
|
|
172
|
+
});
|
|
173
|
+
it("refuses a code that was already claimed or never stored, persisting nothing", async () => {
|
|
174
|
+
const claimed = await savedCode();
|
|
175
|
+
await codeService.revoke(claimed.code);
|
|
176
|
+
const afterClaim = await tokenFor(claimed);
|
|
177
|
+
const unknown = {
|
|
178
|
+
...claimed,
|
|
179
|
+
code: await codeService.generateCode(client, user),
|
|
180
|
+
};
|
|
181
|
+
const forUnknown = await tokenFor(unknown);
|
|
182
|
+
expect(await exchangeCode(claimed, afterClaim)).toBe(undefined);
|
|
183
|
+
expect(await exchangeCode(unknown, forUnknown)).toBe(undefined);
|
|
184
|
+
expect(await live([afterClaim, forUnknown])).toStrictEqual([]);
|
|
185
|
+
});
|
|
186
|
+
it("gives concurrent exchanges of one code exactly one token", async () => {
|
|
187
|
+
const authorizationCode = await savedCode();
|
|
188
|
+
const tokens = await race(() => tokenFor(authorizationCode));
|
|
189
|
+
const results = await race((caller) => exchangeCode(authorizationCode, tokens[caller]));
|
|
190
|
+
expect(results.filter((result) => result !== undefined).length, "exchangeCode must claim its code once, however many callers race").toBe(1);
|
|
191
|
+
expect(await live(tokens)).toHaveLength(1);
|
|
192
|
+
});
|
|
193
|
+
it("revokes the exchanged token when the code's own client replays it through the grant", async () => {
|
|
194
|
+
const authorizationCode = await savedCode();
|
|
195
|
+
const reuseEvents = [];
|
|
196
|
+
const grant = codeGrant(reuseEvents);
|
|
197
|
+
const issued = await redeem(grant, authorizationCode);
|
|
198
|
+
const replay = await Promise.allSettled([
|
|
199
|
+
redeem(grant, authorizationCode),
|
|
200
|
+
]);
|
|
201
|
+
assert(replay[0].status === "rejected");
|
|
202
|
+
expect(replay[0].reason).toBeInstanceOf(InvalidGrantError);
|
|
203
|
+
expect(await live([issued])).toStrictEqual([]);
|
|
204
|
+
expect(reuseEvents).toHaveLength(1);
|
|
205
|
+
});
|
|
206
|
+
it("leaves no live token when the code is replayed through the grant while its exchange is in flight", async () => {
|
|
207
|
+
const authorizationCode = await savedCode();
|
|
208
|
+
const reuseEvents = [];
|
|
209
|
+
const grant = codeGrant(reuseEvents);
|
|
210
|
+
let outcomes;
|
|
211
|
+
if (pausable) {
|
|
212
|
+
const hold = arm();
|
|
213
|
+
const first = redeem(grant, authorizationCode);
|
|
214
|
+
await untilHeld(hold, first);
|
|
215
|
+
const replay = redeem(grant, authorizationCode);
|
|
216
|
+
await settledWithin(replay, blockedAfterMs);
|
|
217
|
+
hold.release.resolve();
|
|
218
|
+
outcomes = await Promise.allSettled([first, replay]);
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
outcomes = await Promise.allSettled([
|
|
222
|
+
redeem(grant, authorizationCode),
|
|
223
|
+
redeem(grant, authorizationCode),
|
|
224
|
+
]);
|
|
225
|
+
}
|
|
226
|
+
const issued = outcomes.flatMap((outcome) => outcome.status === "fulfilled" ? [outcome.value] : []);
|
|
227
|
+
const refused = outcomes.flatMap((outcome) => outcome.status === "rejected" ? [outcome.reason] : []);
|
|
228
|
+
expect(issued).toHaveLength(1);
|
|
229
|
+
expect(refused).toHaveLength(1);
|
|
230
|
+
expect(refused[0]).toBeInstanceOf(InvalidGrantError);
|
|
231
|
+
expect(await live(issued), "a replay that lands while the exchange is between its claim and " +
|
|
232
|
+
"its insert must still leave no live token issued from the code: " +
|
|
233
|
+
"revokeCode has to see the token, or the replay has to wait for " +
|
|
234
|
+
"the exchange and lose the claim").toStrictEqual([]);
|
|
235
|
+
expect(reuseEvents).toHaveLength(1);
|
|
236
|
+
});
|
|
237
|
+
it("refuses a second exchange that starts while the first is held after its claim", async ({ skip, }) => {
|
|
238
|
+
skip(!pausable, "exchangeCode cannot be held (exchangeAwaitsBarrier: false)");
|
|
239
|
+
const authorizationCode = await savedCode();
|
|
240
|
+
const first = await tokenFor(authorizationCode);
|
|
241
|
+
const second = await tokenFor(authorizationCode);
|
|
242
|
+
const hold = arm();
|
|
243
|
+
const held = exchangeCode(authorizationCode, first);
|
|
244
|
+
await untilHeld(hold, held);
|
|
245
|
+
const racing = exchangeCode(authorizationCode, second);
|
|
246
|
+
await settledWithin(racing, blockedAfterMs);
|
|
247
|
+
hold.release.resolve();
|
|
248
|
+
expect((await held)?.accessToken).toBe(first.accessToken);
|
|
249
|
+
expect(await racing).toBe(undefined);
|
|
250
|
+
expect(await live([first, second])).toStrictEqual([first.accessToken]);
|
|
251
|
+
});
|
|
252
|
+
it("does not hold an exchange of another code behind a held one", async ({ skip, }) => {
|
|
253
|
+
skip(!pausable, "exchangeCode cannot be held (exchangeAwaitsBarrier: false)");
|
|
254
|
+
const authorizationCode = await savedCode();
|
|
255
|
+
const token = await tokenFor(authorizationCode);
|
|
256
|
+
const otherCode = await savedCode();
|
|
257
|
+
const otherToken = await tokenFor(otherCode);
|
|
258
|
+
const hold = arm();
|
|
259
|
+
const exchange = exchangeCode(authorizationCode, token);
|
|
260
|
+
await untilHeld(hold, exchange);
|
|
261
|
+
const other = exchangeCode(otherCode, otherToken);
|
|
262
|
+
const settled = await settledWithin(other, 5_000);
|
|
263
|
+
hold.release.resolve();
|
|
264
|
+
expect(settled, "the code lock must be keyed by code, so one login's exchange " +
|
|
265
|
+
"never waits on another's").toBe(true);
|
|
266
|
+
expect((await other)?.accessToken).toBe(otherToken.accessToken);
|
|
267
|
+
expect((await exchange)?.accessToken).toBe(token.accessToken);
|
|
268
|
+
});
|
|
269
|
+
it("leaves the code claimable and persists nothing when the token cannot be persisted", async () => {
|
|
270
|
+
const authorizationCode = await savedCode();
|
|
271
|
+
const unpersistable = await tokenFor(authorizationCode);
|
|
272
|
+
const accessToken = unpersistable.accessToken;
|
|
273
|
+
Object.defineProperty(unpersistable, "accessToken", {
|
|
274
|
+
enumerable: true,
|
|
275
|
+
get() {
|
|
276
|
+
throw new Error("forced persistence failure");
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
const outcome = await Promise.allSettled([
|
|
280
|
+
exchangeCode(authorizationCode, unpersistable),
|
|
281
|
+
]);
|
|
282
|
+
if (outcome[0].status === "fulfilled") {
|
|
283
|
+
expect(outcome[0].value).toBe(undefined);
|
|
284
|
+
}
|
|
285
|
+
expect(await claimable(authorizationCode), "a failed exchange must not spend its code").toBe(true);
|
|
286
|
+
expect(await tokenService.getToken(accessToken)).toBe(undefined);
|
|
287
|
+
const retry = await tokenFor(authorizationCode);
|
|
288
|
+
expect((await exchangeCode(authorizationCode, retry))?.accessToken).toBe(retry.accessToken);
|
|
289
|
+
});
|
|
290
|
+
it("rolls the claim back when the insert fails after it", async ({ skip, }) => {
|
|
291
|
+
skip(!pausable, "exchangeCode cannot be held (exchangeAwaitsBarrier: false)");
|
|
292
|
+
const authorizationCode = await savedCode();
|
|
293
|
+
const token = await tokenFor(authorizationCode);
|
|
294
|
+
const hold = arm();
|
|
295
|
+
const exchange = exchangeCode(authorizationCode, token);
|
|
296
|
+
await untilHeld(hold, exchange);
|
|
297
|
+
hold.release.reject(new Error("forced insert failure"));
|
|
298
|
+
const outcome = await Promise.allSettled([exchange]);
|
|
299
|
+
if (outcome[0].status === "fulfilled") {
|
|
300
|
+
expect(outcome[0].value).toBe(undefined);
|
|
301
|
+
}
|
|
302
|
+
expect(await claimable(authorizationCode), "the claim and the insert are one step: a failed insert must " +
|
|
303
|
+
"leave the code claimable").toBe(true);
|
|
304
|
+
expect(await live([token])).toStrictEqual([]);
|
|
305
|
+
const retry = await tokenFor(authorizationCode);
|
|
306
|
+
expect((await exchangeCode(authorizationCode, retry))?.accessToken, "a failed exchange must release the code").toBe(retry.accessToken);
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
}
|
|
@@ -30,6 +30,11 @@
|
|
|
30
30
|
* claim and its insert wires `TokenRotationBarriers` into `rotate`, so the
|
|
31
31
|
* suite can hold a rotation there.
|
|
32
32
|
*
|
|
33
|
+
* `TokenServiceInterface.exchangeCode` has one too: two exchanges of one
|
|
34
|
+
* code, a replay racing an exchange through the authorization code grant,
|
|
35
|
+
* and an exchange whose insert fails. A store that awaits between its claim
|
|
36
|
+
* and its insert wires `CodeExchangeBarriers` into `exchangeCode`.
|
|
37
|
+
*
|
|
33
38
|
* The service runners take a factory plus seed helpers because `add` /
|
|
34
39
|
* `register` methods aren't part of the public service interfaces — the
|
|
35
40
|
* consumer tells the runner how to populate test data.
|
|
@@ -40,6 +45,7 @@ export { runUserServiceContractTests, type UserServiceContractOptions, } from ".
|
|
|
40
45
|
export { type ClientServiceContractOptions, runClientServiceContractTests, } from "./client.ts";
|
|
41
46
|
export { runTokenServiceContractTests, type TokenServiceContractOptions, } from "./token.ts";
|
|
42
47
|
export { runTokenRotationContractTests, type TokenRotationBarriers, type TokenRotationContractOptions, type TokenRotationContractServices, } from "./token-rotation.ts";
|
|
48
|
+
export { type CodeExchangeBarriers, type CodeExchangeContractOptions, type CodeExchangeContractServices, runCodeExchangeContractTests, } from "./code-exchange.ts";
|
|
43
49
|
export { runTokenReaderContractTests, type TokenReaderContractFixture, type TokenReaderContractOptions, } from "./token-reader.ts";
|
|
44
50
|
export { type AuthorizationCodeServiceContractOptions, runAuthorizationCodeServiceContractTests, } from "./authorization-code.ts";
|
|
45
51
|
export { type DeviceAuthorizationServiceContractOptions, runDeviceAuthorizationServiceContractTests, } from "./device-authorization.ts";
|
|
@@ -30,6 +30,11 @@
|
|
|
30
30
|
* claim and its insert wires `TokenRotationBarriers` into `rotate`, so the
|
|
31
31
|
* suite can hold a rotation there.
|
|
32
32
|
*
|
|
33
|
+
* `TokenServiceInterface.exchangeCode` has one too: two exchanges of one
|
|
34
|
+
* code, a replay racing an exchange through the authorization code grant,
|
|
35
|
+
* and an exchange whose insert fails. A store that awaits between its claim
|
|
36
|
+
* and its insert wires `CodeExchangeBarriers` into `exchangeCode`.
|
|
37
|
+
*
|
|
33
38
|
* The service runners take a factory plus seed helpers because `add` /
|
|
34
39
|
* `register` methods aren't part of the public service interfaces — the
|
|
35
40
|
* consumer tells the runner how to populate test data.
|
|
@@ -40,6 +45,7 @@ export { runUserServiceContractTests, } from "./user.js";
|
|
|
40
45
|
export { runClientServiceContractTests, } from "./client.js";
|
|
41
46
|
export { runTokenServiceContractTests, } from "./token.js";
|
|
42
47
|
export { runTokenRotationContractTests, } from "./token-rotation.js";
|
|
48
|
+
export { runCodeExchangeContractTests, } from "./code-exchange.js";
|
|
43
49
|
export { runTokenReaderContractTests, } from "./token-reader.js";
|
|
44
50
|
export { runAuthorizationCodeServiceContractTests, } from "./authorization-code.js";
|
|
45
51
|
export { runDeviceAuthorizationServiceContractTests, } from "./device-authorization.js";
|
|
@@ -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);
|
|
@@ -78,6 +78,14 @@ export interface TokenServiceContractOptions<C extends ClientInterface, U extend
|
|
|
78
78
|
* loudly rather than losing reuse detection quietly.
|
|
79
79
|
*/
|
|
80
80
|
refreshTokenReuseDetection?: boolean;
|
|
81
|
+
/**
|
|
82
|
+
* Set to `false` when the service's `revokeCode` deliberately ignores its
|
|
83
|
+
* `client` argument. The suite then registers an ignored case naming what is
|
|
84
|
+
* not covered — any client that presents a spent code revokes the tokens
|
|
85
|
+
* issued from it, so a client that sees a leaked code can end the session it
|
|
86
|
+
* started — instead of passing in silence. Defaults to `true`.
|
|
87
|
+
*/
|
|
88
|
+
revokeCodeChecksClient?: boolean;
|
|
81
89
|
/** Overrides the name passed to the outer `describe` block. */
|
|
82
90
|
describeName?: string;
|
|
83
91
|
}
|
|
@@ -35,6 +35,7 @@ export function runTokenServiceContractTests(options) {
|
|
|
35
35
|
const supportedScope = options.supportedScope ?? "read";
|
|
36
36
|
const makeFamilyId = options.makeFamilyId ?? defaultMakeFamilyId;
|
|
37
37
|
const expectReuseDetection = options.refreshTokenReuseDetection ?? true;
|
|
38
|
+
const expectCodeClientCheck = options.revokeCodeChecksClient ?? true;
|
|
38
39
|
describe(options.describeName ?? "TokenServiceInterface contract", () => {
|
|
39
40
|
let userService;
|
|
40
41
|
let clientService;
|
|
@@ -51,6 +52,41 @@ export function runTokenServiceContractTests(options) {
|
|
|
51
52
|
await options.addUser(userService, user, "pw");
|
|
52
53
|
await options.addClient(clientService, client);
|
|
53
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
|
+
}
|
|
54
90
|
describe("generateAccessToken / generateRefreshToken", () => {
|
|
55
91
|
it("produces unique access tokens", async () => {
|
|
56
92
|
const a = await tokenService.generateAccessToken(client, user);
|
|
@@ -214,6 +250,48 @@ export function runTokenServiceContractTests(options) {
|
|
|
214
250
|
it("returns false when no tokens were issued for the code", async () => {
|
|
215
251
|
expect(await tokenService.revokeCode("never-used")).toBe(false);
|
|
216
252
|
});
|
|
253
|
+
if (expectCodeClientCheck) {
|
|
254
|
+
async function issueFromCode(code) {
|
|
255
|
+
const accessToken = await tokenService.generateAccessToken(client, user);
|
|
256
|
+
await tokenService.save({ accessToken, code, client, user });
|
|
257
|
+
return accessToken;
|
|
258
|
+
}
|
|
259
|
+
it("revokes nothing for a client other than the one the code's tokens were issued to", async () => {
|
|
260
|
+
const other = makeClient(2);
|
|
261
|
+
await options.addClient(clientService, other);
|
|
262
|
+
const accessToken = await issueFromCode("replayed-code");
|
|
263
|
+
expect(await tokenService.revokeCode("replayed-code", other), "a client that could never have redeemed the code must not " +
|
|
264
|
+
"revoke what it issued").toBe(false);
|
|
265
|
+
expect(await tokenService.getToken(accessToken)).not.toBe(undefined);
|
|
266
|
+
});
|
|
267
|
+
it("revokes for the client the code's tokens were issued to, after another client was refused", async () => {
|
|
268
|
+
const other = makeClient(2);
|
|
269
|
+
await options.addClient(clientService, other);
|
|
270
|
+
const accessToken = await issueFromCode("replayed-code");
|
|
271
|
+
await tokenService.revokeCode("replayed-code", other);
|
|
272
|
+
expect(await tokenService.revokeCode("replayed-code", client)).toBe(true);
|
|
273
|
+
expect(await tokenService.getToken(accessToken)).toBe(undefined);
|
|
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
|
+
});
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
it.skip("revokeCode ignores the presenting client — any client that presents a spent code revokes its tokens (revokeCodeChecksClient: false)", () => { });
|
|
294
|
+
}
|
|
217
295
|
});
|
|
218
296
|
if (expectReuseDetection) {
|
|
219
297
|
describe("refresh-token rotation family", () => {
|
|
@@ -223,25 +301,6 @@ export function runTokenServiceContractTests(options) {
|
|
|
223
301
|
family = makeFamilyId();
|
|
224
302
|
otherFamily = makeFamilyId();
|
|
225
303
|
});
|
|
226
|
-
async function familyMember(familyId, refreshTokenExpiresAt = new Date(Date.now() + 600_000)) {
|
|
227
|
-
const accessToken = await tokenService.generateAccessToken(client, user);
|
|
228
|
-
const refreshToken = (await tokenService.generateRefreshToken(client, user));
|
|
229
|
-
return {
|
|
230
|
-
accessToken,
|
|
231
|
-
accessTokenExpiresAt: new Date(Date.now() + 60_000),
|
|
232
|
-
refreshToken,
|
|
233
|
-
refreshTokenExpiresAt,
|
|
234
|
-
familyId,
|
|
235
|
-
familyCreatedAt: new Date(Date.now() - 60_000),
|
|
236
|
-
client,
|
|
237
|
-
user,
|
|
238
|
-
};
|
|
239
|
-
}
|
|
240
|
-
async function saveFamilyMember(familyId, refreshTokenExpiresAt) {
|
|
241
|
-
const token = await familyMember(familyId, refreshTokenExpiresAt);
|
|
242
|
-
await tokenService.save(token);
|
|
243
|
-
return token;
|
|
244
|
-
}
|
|
245
304
|
it("implements both halves of reuse detection", () => {
|
|
246
305
|
assert(typeof tokenService.getRevokedRefreshToken === "function" &&
|
|
247
306
|
typeof tokenService.revokeFamily === "function", "getRevokedRefreshToken and revokeFamily are optional, but " +
|
|
@@ -377,6 +436,49 @@ export function runTokenServiceContractTests(options) {
|
|
|
377
436
|
assert((await tokenService.getRefreshToken(unrelated.refreshToken)) !==
|
|
378
437
|
undefined);
|
|
379
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
|
+
});
|
|
380
482
|
it("leaves rotatedAt unset on a token revoked other than by rotation", async () => {
|
|
381
483
|
const token = await saveFamilyMember(family);
|
|
382
484
|
await tokenService.revoke(token.refreshToken, "refresh_token");
|
package/dist/testing/server.js
CHANGED
|
@@ -78,13 +78,14 @@ export async function createMemoryAuthorizationServer(options = {}) {
|
|
|
78
78
|
const enabledGrants = { ...DEFAULT_GRANTS, ...options.grants };
|
|
79
79
|
const userService = new MemoryUserService();
|
|
80
80
|
const clientService = new MemoryClientService(userService);
|
|
81
|
-
const
|
|
81
|
+
const authorizationCodeService = new MemoryAuthorizationCodeService({
|
|
82
82
|
clientService,
|
|
83
83
|
userService,
|
|
84
84
|
});
|
|
85
|
-
const
|
|
85
|
+
const tokenService = new MemoryTokenService({
|
|
86
86
|
clientService,
|
|
87
87
|
userService,
|
|
88
|
+
authorizationCodeService,
|
|
88
89
|
});
|
|
89
90
|
const deviceAuthorizationService = new MemoryDeviceAuthorizationService({ clientService, userService });
|
|
90
91
|
const grants = {};
|
|
@@ -135,7 +135,7 @@ export declare class MemoryClientService<C extends ClientInterface, U> implement
|
|
|
135
135
|
getUser(client: C | string): Promise<U | undefined>;
|
|
136
136
|
}
|
|
137
137
|
/** Options for {@link MemoryTokenService}. */
|
|
138
|
-
export interface MemoryTokenServiceOptions<C extends ClientInterface, U extends MemoryUserShape> {
|
|
138
|
+
export interface MemoryTokenServiceOptions<C extends ClientInterface, U extends MemoryUserShape, S extends AbstractScope = BasicScope> {
|
|
139
139
|
/** Resolves clients when hydrating stored tokens back into full records. */
|
|
140
140
|
clientService: ClientServiceInterface<C, U>;
|
|
141
141
|
/** Resolves users when hydrating stored tokens back into full records. */
|
|
@@ -149,6 +149,14 @@ export interface MemoryTokenServiceOptions<C extends ClientInterface, U extends
|
|
|
149
149
|
* leave rotation uncapped.
|
|
150
150
|
*/
|
|
151
151
|
refreshTokenMaxLifetime?: number;
|
|
152
|
+
/**
|
|
153
|
+
* The code store the authorization code grant claims codes from. Pass it to
|
|
154
|
+
* give the token service {@linkcode MemoryTokenService.exchangeCode}, so the
|
|
155
|
+
* grant claims a code and saves its token in one step; without it the grant
|
|
156
|
+
* claims and saves in two. `exchangeCode` claims from the code store's own
|
|
157
|
+
* map, so a subclass that overrides the code store's `revoke` is bypassed.
|
|
158
|
+
*/
|
|
159
|
+
authorizationCodeService?: MemoryAuthorizationCodeService<C, U, S>;
|
|
152
160
|
}
|
|
153
161
|
/**
|
|
154
162
|
* In-memory `TokenServiceInterface` implementation. Tokens are hashed on
|
|
@@ -171,13 +179,21 @@ export declare class MemoryTokenService<C extends ClientInterface, U extends Mem
|
|
|
171
179
|
* client sets none. Undefined leaves families uncapped.
|
|
172
180
|
*/
|
|
173
181
|
refreshTokenMaxLifetime?: number;
|
|
182
|
+
/**
|
|
183
|
+
* Claims a code from the paired `authorizationCodeService` and saves the
|
|
184
|
+
* token issued from it in one step, or does neither, resolving `undefined`
|
|
185
|
+
* when the code was already claimed. Present only when the service was
|
|
186
|
+
* constructed with `authorizationCodeService`, so the grant falls back to
|
|
187
|
+
* two calls for a token service that has no code store to claim from.
|
|
188
|
+
*/
|
|
189
|
+
readonly exchangeCode?: (authorizationCode: AuthorizationCode<C, U, S>, token: Token<C, U, S>) => Promise<Token<C, U, S> | undefined>;
|
|
174
190
|
/**
|
|
175
191
|
* Creates an empty token store.
|
|
176
192
|
*
|
|
177
|
-
* @param options Backing client/user services
|
|
178
|
-
* lifetimes
|
|
193
|
+
* @param options Backing client/user services, optional default token
|
|
194
|
+
* lifetimes, and the code store that enables `exchangeCode`.
|
|
179
195
|
*/
|
|
180
|
-
constructor(options: MemoryTokenServiceOptions<C, U>);
|
|
196
|
+
constructor(options: MemoryTokenServiceOptions<C, U, S>);
|
|
181
197
|
/**
|
|
182
198
|
* Accepts the requested scope verbatim; the stub performs no scope
|
|
183
199
|
* narrowing. Return `false` from a subclass to reject a request.
|
|
@@ -278,9 +294,13 @@ export declare class MemoryTokenService<C extends ClientInterface, U extends Mem
|
|
|
278
294
|
revokeFamily(familyId: string): Promise<boolean>;
|
|
279
295
|
/**
|
|
280
296
|
* Removes every token issued from the given authorization code, used when a
|
|
281
|
-
* 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.
|
|
300
|
+
* Removes nothing and resolves `false` when `client` is given and is not the
|
|
301
|
+
* client the code's tokens were issued to.
|
|
282
302
|
*/
|
|
283
|
-
revokeCode(code: string): Promise<boolean>;
|
|
303
|
+
revokeCode(code: string, client?: C): Promise<boolean>;
|
|
284
304
|
}
|
|
285
305
|
/** Options for {@link MemoryAuthorizationCodeService}. */
|
|
286
306
|
export interface MemoryAuthorizationCodeServiceOptions<C extends ClientInterface, U extends MemoryUserShape> {
|