@udibo/oauth2 0.35.0 → 0.37.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/README.md +1 -1
- package/dist/models/token.d.ts +8 -0
- package/dist/server/grants/authorization-code.d.ts +15 -4
- package/dist/server/grants/authorization-code.js +38 -11
- package/dist/server/services/token.d.ts +26 -13
- package/dist/testing/contract/code-exchange.d.ts +9 -0
- package/dist/testing/contract/code-exchange.js +45 -1
- package/dist/testing/contract/token-rotation.js +13 -0
- package/dist/testing/contract/token.d.ts +9 -0
- package/dist/testing/contract/token.js +162 -19
- package/dist/testing/services.d.ts +6 -3
- package/dist/testing/services.js +27 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @udibo/oauth2
|
|
2
2
|
|
|
3
|
-
[](https://www.npmjs.com/package/@udibo/oauth2) [](https://jsr.io/@udibo/oauth2) [](https://www.npmjs.com/package/@udibo/oauth2) [](https://jsr.io/@udibo/oauth2) [](https://github.com/udibo/oauth2/actions/workflows/ci-cd.yml?query=branch%3Amain) [](https://codecov.io/gh/udibo/oauth2) [](LICENSE) [](https://www.npmjs.com/package/@udibo/oauth2) [](https://www.npmjs.com/package/@udibo/oauth2)
|
|
4
4
|
|
|
5
5
|
OAuth2 and OpenID Connect tools for TypeScript applications: clients,
|
|
6
6
|
authorization and resource servers, Hono middleware, React bindings, and
|
package/dist/models/token.d.ts
CHANGED
|
@@ -30,6 +30,14 @@ export interface Token<Client extends ClientInterface, User, Scope extends Abstr
|
|
|
30
30
|
claims?: Record<string, unknown>;
|
|
31
31
|
/** The authorization code used to generate this token, for replay detection per RFC 6819. */
|
|
32
32
|
code?: string;
|
|
33
|
+
/**
|
|
34
|
+
* The SHA-256 digest, as lowercase hex, of the PKCE `code_verifier` that
|
|
35
|
+
* redeemed {@linkcode Token.code}. The authorization code grant sets it on a
|
|
36
|
+
* code redeemed with PKCE. A token service persists it with the code, so
|
|
37
|
+
* `revokeCode` can tell a replay that presents that verifier from one that
|
|
38
|
+
* only names the client.
|
|
39
|
+
*/
|
|
40
|
+
codeVerifierHash?: string;
|
|
33
41
|
/**
|
|
34
42
|
* OIDC nonce carried transiently from the authorization code so the token
|
|
35
43
|
* endpoint can echo it in the id_token. Not persisted by token stores.
|
|
@@ -33,8 +33,10 @@ export interface CodeReuseEvent<Client extends ClientInterface> {
|
|
|
33
33
|
* The client that presented the code. It is the code's own client only when
|
|
34
34
|
* the token service's `revokeCode` honors its `client` argument. A public
|
|
35
35
|
* client, or any client the grant looks up by id because
|
|
36
|
-
* `requireClientAuthentication` is `false`, is named by `client_id` alone
|
|
37
|
-
*
|
|
36
|
+
* `requireClientAuthentication` is `false`, is named by `client_id` alone;
|
|
37
|
+
* its replay revokes, and so reaches this event, only when it presented the
|
|
38
|
+
* `code_verifier` that redeemed the code and `revokeCode` honors its
|
|
39
|
+
* `codeVerifierHash` argument.
|
|
38
40
|
*/
|
|
39
41
|
client: Client;
|
|
40
42
|
}
|
|
@@ -90,7 +92,9 @@ export interface AuthorizationCodeGrantOptions<Client extends ClientInterface, U
|
|
|
90
92
|
* token service's `revokeCode` revoked those tokens — record it as a
|
|
91
93
|
* security event. A `revokeCode` that honors its `client` argument revokes
|
|
92
94
|
* nothing, and so does not fire this, for a client other than the code's
|
|
93
|
-
* own; {@linkcode CodeReuseEvent.client} says who that excludes.
|
|
95
|
+
* own; {@linkcode CodeReuseEvent.client} says who that excludes. A client
|
|
96
|
+
* that did not authenticate with its secret fires it only by presenting the
|
|
97
|
+
* `code_verifier` that redeemed the code.
|
|
94
98
|
*
|
|
95
99
|
* Trigger point `code-reuse` (see `docs/trigger-points.md`).
|
|
96
100
|
* Fire-and-forget: it is awaited, but a throw is caught and logged, never
|
|
@@ -225,7 +229,14 @@ export declare class AuthorizationCodeGrant<Client extends ClientInterface, User
|
|
|
225
229
|
* then fails its checks, asks
|
|
226
230
|
* `tokenService.revokeCode` to revoke the tokens issued from it on behalf of
|
|
227
231
|
* the presenting client, fires `onCodeReuse` when it did, and fails with
|
|
228
|
-
* `invalid_grant` (RFC 6819 §4.4.1.1).
|
|
232
|
+
* `invalid_grant` (RFC 6819 §4.4.1.1). The replay must prove it could have
|
|
233
|
+
* redeemed the code: a client that authenticated with a `client_secret` in
|
|
234
|
+
* `request` asks with the client alone, and any other client asks with the
|
|
235
|
+
* SHA-256 hex digest of the `code_verifier` it presented, which `revokeCode`
|
|
236
|
+
* matches against {@linkcode Token.codeVerifierHash}. Without a verifier,
|
|
237
|
+
* such a client's replay revokes nothing. Pass the `client` that
|
|
238
|
+
* {@linkcode getAuthenticatedClient} resolved from this same `request`.
|
|
239
|
+
* A code redeemed with PKCE issues its token with `codeVerifierHash` set.
|
|
229
240
|
*
|
|
230
241
|
* @throws {InvalidRequestError} When `code` is missing, a required `code_verifier` is absent, or the verifier is malformed.
|
|
231
242
|
* @throws {InvalidGrantError} When the code is unknown, expired, already claimed, or its verifier or `redirect_uri` does not match.
|
|
@@ -188,7 +188,14 @@ export class AuthorizationCodeGrant extends AbstractGrant {
|
|
|
188
188
|
* then fails its checks, asks
|
|
189
189
|
* `tokenService.revokeCode` to revoke the tokens issued from it on behalf of
|
|
190
190
|
* the presenting client, fires `onCodeReuse` when it did, and fails with
|
|
191
|
-
* `invalid_grant` (RFC 6819 §4.4.1.1).
|
|
191
|
+
* `invalid_grant` (RFC 6819 §4.4.1.1). The replay must prove it could have
|
|
192
|
+
* redeemed the code: a client that authenticated with a `client_secret` in
|
|
193
|
+
* `request` asks with the client alone, and any other client asks with the
|
|
194
|
+
* SHA-256 hex digest of the `code_verifier` it presented, which `revokeCode`
|
|
195
|
+
* matches against {@linkcode Token.codeVerifierHash}. Without a verifier,
|
|
196
|
+
* such a client's replay revokes nothing. Pass the `client` that
|
|
197
|
+
* {@linkcode getAuthenticatedClient} resolved from this same `request`.
|
|
198
|
+
* A code redeemed with PKCE issues its token with `codeVerifierHash` set.
|
|
192
199
|
*
|
|
193
200
|
* @throws {InvalidRequestError} When `code` is missing, a required `code_verifier` is absent, or the verifier is malformed.
|
|
194
201
|
* @throws {InvalidGrantError} When the code is unknown, expired, already claimed, or its verifier or `redirect_uri` does not match.
|
|
@@ -202,9 +209,10 @@ export class AuthorizationCodeGrant extends AbstractGrant {
|
|
|
202
209
|
if (typeof code !== "string") {
|
|
203
210
|
throw new InvalidRequestError("code parameter required");
|
|
204
211
|
}
|
|
212
|
+
const replay = { code, client, request, body };
|
|
205
213
|
const authorizationCode = await authorizationCodeService.get(code);
|
|
206
214
|
if (!authorizationCode) {
|
|
207
|
-
throw await this.#refuseClaimedCode(tokenService,
|
|
215
|
+
throw await this.#refuseClaimedCode(tokenService, replay, "invalid code");
|
|
208
216
|
}
|
|
209
217
|
if (authorizationCode.expiresAt < new Date()) {
|
|
210
218
|
throw new InvalidGrantError("invalid code");
|
|
@@ -212,7 +220,7 @@ export class AuthorizationCodeGrant extends AbstractGrant {
|
|
|
212
220
|
const exchangeCode = tokenService.exchangeCode?.bind(tokenService);
|
|
213
221
|
if (!exchangeCode &&
|
|
214
222
|
!(await authorizationCodeService.revoke(authorizationCode))) {
|
|
215
|
-
throw await this.#refuseClaimedCode(tokenService,
|
|
223
|
+
throw await this.#refuseClaimedCode(tokenService, replay);
|
|
216
224
|
}
|
|
217
225
|
let token;
|
|
218
226
|
try {
|
|
@@ -221,11 +229,15 @@ export class AuthorizationCodeGrant extends AbstractGrant {
|
|
|
221
229
|
}
|
|
222
230
|
catch (error) {
|
|
223
231
|
if (exchangeCode) {
|
|
224
|
-
await this.#consumeFailedExchange(authorizationCodeService, tokenService, authorizationCode,
|
|
232
|
+
await this.#consumeFailedExchange(authorizationCodeService, tokenService, authorizationCode, replay);
|
|
225
233
|
}
|
|
226
234
|
throw error;
|
|
227
235
|
}
|
|
228
236
|
token.code = code;
|
|
237
|
+
const codeVerifier = body.get("code_verifier");
|
|
238
|
+
if (typeof codeVerifier === "string") {
|
|
239
|
+
token.codeVerifierHash = await sha256Hash(codeVerifier);
|
|
240
|
+
}
|
|
229
241
|
let saved;
|
|
230
242
|
if (exchangeCode) {
|
|
231
243
|
try {
|
|
@@ -233,7 +245,7 @@ export class AuthorizationCodeGrant extends AbstractGrant {
|
|
|
233
245
|
}
|
|
234
246
|
catch (error) {
|
|
235
247
|
if (isOAuth2Error(error)) {
|
|
236
|
-
await this.#consumeFailedExchange(authorizationCodeService, tokenService, authorizationCode,
|
|
248
|
+
await this.#consumeFailedExchange(authorizationCodeService, tokenService, authorizationCode, replay);
|
|
237
249
|
}
|
|
238
250
|
throw error;
|
|
239
251
|
}
|
|
@@ -242,7 +254,7 @@ export class AuthorizationCodeGrant extends AbstractGrant {
|
|
|
242
254
|
saved = await tokenService.save(token);
|
|
243
255
|
}
|
|
244
256
|
if (!saved) {
|
|
245
|
-
throw await this.#refuseClaimedCode(tokenService,
|
|
257
|
+
throw await this.#refuseClaimedCode(tokenService, replay);
|
|
246
258
|
}
|
|
247
259
|
if (authorizationCode.nonce)
|
|
248
260
|
saved.nonce = authorizationCode.nonce;
|
|
@@ -281,23 +293,38 @@ export class AuthorizationCodeGrant extends AbstractGrant {
|
|
|
281
293
|
throw new InvalidGrantError("did not expect redirect_uri parameter");
|
|
282
294
|
}
|
|
283
295
|
}
|
|
284
|
-
async #consumeFailedExchange(authorizationCodeService, tokenService, authorizationCode,
|
|
296
|
+
async #consumeFailedExchange(authorizationCodeService, tokenService, authorizationCode, replay) {
|
|
285
297
|
try {
|
|
286
298
|
if (!(await authorizationCodeService.revoke(authorizationCode))) {
|
|
287
|
-
await this.#refuseClaimedCode(tokenService,
|
|
299
|
+
await this.#refuseClaimedCode(tokenService, replay);
|
|
288
300
|
}
|
|
289
301
|
}
|
|
290
302
|
catch (error) {
|
|
291
303
|
console.error("[@udibo/oauth2] could not consume the code of a failed exchange:", error instanceof Error ? error.message : error);
|
|
292
304
|
}
|
|
293
305
|
}
|
|
294
|
-
async #refuseClaimedCode(tokenService, code, client, unrevokedMessage = "code already used") {
|
|
295
|
-
|
|
296
|
-
|
|
306
|
+
async #refuseClaimedCode(tokenService, { code, client, request, body }, unrevokedMessage = "code already used") {
|
|
307
|
+
const codeVerifier = body.get("code_verifier");
|
|
308
|
+
let revoked = false;
|
|
309
|
+
if (this.#authenticatedBySecret(request, body)) {
|
|
310
|
+
revoked = await tokenService.revokeCode(code, client);
|
|
311
|
+
}
|
|
312
|
+
else if (typeof codeVerifier === "string") {
|
|
313
|
+
revoked = await tokenService.revokeCode(code, client, await sha256Hash(codeVerifier));
|
|
297
314
|
}
|
|
315
|
+
if (!revoked)
|
|
316
|
+
return new InvalidGrantError(unrevokedMessage);
|
|
298
317
|
await this.#notifyCodeReuse({ codeHash: await sha256Hash(code), client });
|
|
299
318
|
return new InvalidGrantError("code already used");
|
|
300
319
|
}
|
|
320
|
+
#authenticatedBySecret(request, body) {
|
|
321
|
+
try {
|
|
322
|
+
return !!this.getClientCredentials(request, body).clientSecret;
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
301
328
|
async #notifyCodeReuse(event) {
|
|
302
329
|
if (!this.#onCodeReuse)
|
|
303
330
|
return;
|
|
@@ -162,17 +162,28 @@ export interface TokenServiceInterface<Client extends ClientInterface, User, Sco
|
|
|
162
162
|
* Returns true if any tokens were revoked, which the grant reports as a code
|
|
163
163
|
* replay (RFC 6819 §4.4.1.1).
|
|
164
164
|
*
|
|
165
|
+
* After the client check below passes, revoke every live member of the
|
|
166
|
+
* rotation families of those tokens too, as {@linkcode revokeFamily} does,
|
|
167
|
+
* keeping them revoked against a rotation in flight. A refresh token rotated
|
|
168
|
+
* from the code's token carries no `code`, so revoking only the tokens saved
|
|
169
|
+
* with the code leaves the stolen code's session refreshing once it has
|
|
170
|
+
* rotated.
|
|
171
|
+
*
|
|
165
172
|
* `client` is the client that presented the code. Revoke only when it is the
|
|
166
173
|
* client the code's tokens were issued to; for any other client revoke
|
|
167
174
|
* nothing and resolve `false`. A different confidential client could never
|
|
168
175
|
* have redeemed the code, since that needs the code's own client's
|
|
169
|
-
* credentials, so its replay says nothing about theft.
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
+
* credentials, so its replay says nothing about theft. A store that ignores
|
|
177
|
+
* `client` revokes on any client's replay, which lets a client that sees a
|
|
178
|
+
* leaked, spent code end the session it started.
|
|
179
|
+
*
|
|
180
|
+
* `codeVerifierHash`, the SHA-256 hex digest of the `code_verifier` the
|
|
181
|
+
* replay presented, is passed for a client that did not authenticate with a
|
|
182
|
+
* secret. When it is given, also revoke only when it equals the
|
|
183
|
+
* {@linkcode Token.codeVerifierHash} persisted with the code's tokens;
|
|
184
|
+
* tokens saved without one never match. Omitted, the client check alone
|
|
185
|
+
* applies. A store that ignores it lets anyone holding a public client's
|
|
186
|
+
* spent code end the session it started.
|
|
176
187
|
*
|
|
177
188
|
* With {@linkcode exchangeCode}, make the two linearizable: a call that
|
|
178
189
|
* begins once `exchangeCode` has claimed the code must find and revoke the
|
|
@@ -181,7 +192,7 @@ export interface TokenServiceInterface<Client extends ClientInterface, User, Sco
|
|
|
181
192
|
*
|
|
182
193
|
* @see https://datatracker.ietf.org/doc/html/rfc6819#section-4.4.1.1
|
|
183
194
|
*/
|
|
184
|
-
revokeCode(code: string, client?: Client): Promise<boolean>;
|
|
195
|
+
revokeCode(code: string, client?: Client, codeVerifierHash?: string): Promise<boolean>;
|
|
185
196
|
/**
|
|
186
197
|
* Exchanges an authorization code in one atomic step: claims
|
|
187
198
|
* `authorizationCode` as `AuthorizationCodeServiceInterface.revoke`
|
|
@@ -200,8 +211,9 @@ export interface TokenServiceInterface<Client extends ClientInterface, User, Sco
|
|
|
200
211
|
*
|
|
201
212
|
* After a successful call the code service's `get` must resolve `undefined`
|
|
202
213
|
* for the code, as after `revoke`. A failed insert must leave the code
|
|
203
|
-
* claimable. Store the token so `revokeCode` finds it by the code,
|
|
204
|
-
* the two linearizable, as
|
|
214
|
+
* claimable. Store the token so `revokeCode` finds it by the code, with its
|
|
215
|
+
* {@linkcode Token.codeVerifierHash}, and make the two linearizable, as
|
|
216
|
+
* `revokeCode` requires.
|
|
205
217
|
* `runCodeExchangeContractTests` checks it under concurrent callers, with
|
|
206
218
|
* the store's barrier wired between its claim and its insert.
|
|
207
219
|
*/
|
|
@@ -365,8 +377,9 @@ export declare abstract class AbstractTokenService<Client extends ClientInterfac
|
|
|
365
377
|
/**
|
|
366
378
|
* Revokes every token issued from the given authorization code, returning
|
|
367
379
|
* true if any were revoked (signaling code replay per RFC 6819 §4.4.1.1).
|
|
368
|
-
* Revoke nothing when `client` is not the client the tokens were issued to
|
|
369
|
-
*
|
|
380
|
+
* Revoke nothing when `client` is not the client the tokens were issued to,
|
|
381
|
+
* or when `codeVerifierHash` is given and is not the one they were saved
|
|
382
|
+
* with ({@linkcode TokenServiceInterface.revokeCode} explains why).
|
|
370
383
|
*/
|
|
371
|
-
abstract revokeCode(code: string, client?: Client): Promise<boolean>;
|
|
384
|
+
abstract revokeCode(code: string, client?: Client, codeVerifierHash?: string): Promise<boolean>;
|
|
372
385
|
}
|
|
@@ -87,6 +87,15 @@ export interface CodeExchangeContractOptions<C extends ClientInterface, U extend
|
|
|
87
87
|
* longer to reach the lock they wait on.
|
|
88
88
|
*/
|
|
89
89
|
blockedAfterMs?: number;
|
|
90
|
+
/**
|
|
91
|
+
* Set to `false` when the service's `revokeCode` deliberately ignores its
|
|
92
|
+
* `codeVerifierHash` argument, as `runTokenServiceContractTests` allows. The
|
|
93
|
+
* suite still checks that a token `exchangeCode` saved revokes for its own
|
|
94
|
+
* verifier, and registers an ignored case naming what is not covered — a
|
|
95
|
+
* replay that names a public client, with any verifier, revokes the token
|
|
96
|
+
* `exchangeCode` saved. Defaults to `true`.
|
|
97
|
+
*/
|
|
98
|
+
revokeCodeChecksVerifier?: boolean;
|
|
90
99
|
/** Overrides the name passed to the outer `describe` block. */
|
|
91
100
|
describeName?: string;
|
|
92
101
|
}
|
|
@@ -24,6 +24,8 @@
|
|
|
24
24
|
import { assert, beforeEach, describe, expect, it } from "vitest";
|
|
25
25
|
import { InvalidGrantError } from "../../errors.js";
|
|
26
26
|
import { AuthorizationCodeGrant, } from "../../server/grants/authorization-code.js";
|
|
27
|
+
import { sha256Hash } from "../../server/utils/hash.js";
|
|
28
|
+
import { generateCodeChallenge, generateCodeVerifier, } from "../../utils/pkce.js";
|
|
27
29
|
import { race } from "./_race.js";
|
|
28
30
|
const defaultMakeUser = (seq) => ({ id: `u${seq}`, username: `user${seq}` });
|
|
29
31
|
const defaultMakeClient = (seq) => ({
|
|
@@ -57,6 +59,7 @@ export function runCodeExchangeContractTests(options) {
|
|
|
57
59
|
const makeClient = options.makeClient ?? defaultMakeClient;
|
|
58
60
|
const pausable = options.exchangeAwaitsBarrier ?? true;
|
|
59
61
|
const blockedAfterMs = options.blockedAfterMs ?? 100;
|
|
62
|
+
const expectCodeVerifierCheck = options.revokeCodeChecksVerifier ?? true;
|
|
60
63
|
describe(options.describeName ?? "TokenServiceInterface.exchangeCode contract", () => {
|
|
61
64
|
let services;
|
|
62
65
|
let codeService;
|
|
@@ -64,6 +67,7 @@ export function runCodeExchangeContractTests(options) {
|
|
|
64
67
|
let user;
|
|
65
68
|
let client;
|
|
66
69
|
let armed;
|
|
70
|
+
let verifiers;
|
|
67
71
|
const barriers = {
|
|
68
72
|
async afterClaim() {
|
|
69
73
|
const hold = armed;
|
|
@@ -76,6 +80,7 @@ export function runCodeExchangeContractTests(options) {
|
|
|
76
80
|
};
|
|
77
81
|
beforeEach(async () => {
|
|
78
82
|
armed = undefined;
|
|
83
|
+
verifiers = new Map();
|
|
79
84
|
services = await options.makeServices(barriers);
|
|
80
85
|
codeService = services.authorizationCodeService;
|
|
81
86
|
tokenService = services.tokenService;
|
|
@@ -88,24 +93,37 @@ export function runCodeExchangeContractTests(options) {
|
|
|
88
93
|
assert(typeof tokenService.exchangeCode === "function", "runCodeExchangeContractTests needs a token service that implements exchangeCode");
|
|
89
94
|
return tokenService.exchangeCode(authorizationCode, token);
|
|
90
95
|
}
|
|
96
|
+
function verifierOf(authorizationCode) {
|
|
97
|
+
const verifier = verifiers.get(authorizationCode.code);
|
|
98
|
+
assert(verifier, "the suite saved no verifier for this code");
|
|
99
|
+
return verifier;
|
|
100
|
+
}
|
|
91
101
|
async function savedCode() {
|
|
102
|
+
const verifier = generateCodeVerifier();
|
|
92
103
|
const authorizationCode = {
|
|
93
104
|
code: await codeService.generateCode(client, user),
|
|
94
105
|
expiresAt: await codeService.expiresAt(client, user),
|
|
95
106
|
client,
|
|
96
107
|
user,
|
|
108
|
+
challenge: await generateCodeChallenge(verifier),
|
|
109
|
+
challengeMethod: "S256",
|
|
97
110
|
};
|
|
111
|
+
verifiers.set(authorizationCode.code, verifier);
|
|
98
112
|
await codeService.save(authorizationCode);
|
|
99
113
|
return authorizationCode;
|
|
100
114
|
}
|
|
101
115
|
async function tokenFor(authorizationCode) {
|
|
102
|
-
|
|
116
|
+
const token = {
|
|
103
117
|
accessToken: await tokenService.generateAccessToken(client, user),
|
|
104
118
|
accessTokenExpiresAt: new Date(Date.now() + 60_000),
|
|
105
119
|
client,
|
|
106
120
|
user,
|
|
107
121
|
code: authorizationCode.code,
|
|
108
122
|
};
|
|
123
|
+
const verifier = verifiers.get(authorizationCode.code);
|
|
124
|
+
if (verifier)
|
|
125
|
+
token.codeVerifierHash = await sha256Hash(verifier);
|
|
126
|
+
return token;
|
|
109
127
|
}
|
|
110
128
|
async function live(tokens) {
|
|
111
129
|
const found = [];
|
|
@@ -154,6 +172,8 @@ export function runCodeExchangeContractTests(options) {
|
|
|
154
172
|
const body = new FormData();
|
|
155
173
|
body.set("grant_type", "authorization_code");
|
|
156
174
|
body.set("code", authorizationCode.code);
|
|
175
|
+
body.set("client_id", client.id.toString());
|
|
176
|
+
body.set("code_verifier", verifierOf(authorizationCode));
|
|
157
177
|
return grant.token(new Request("https://as.test/token", { method: "POST" }), client, body);
|
|
158
178
|
}
|
|
159
179
|
it("implements exchangeCode", () => {
|
|
@@ -170,6 +190,30 @@ export function runCodeExchangeContractTests(options) {
|
|
|
170
190
|
expect(await tokenService.revokeCode(authorizationCode.code, client), "revokeCode must find the token exchangeCode saved by its code").toBe(true);
|
|
171
191
|
expect(await live([token])).toStrictEqual([]);
|
|
172
192
|
});
|
|
193
|
+
it("revokes the token it saved for the codeVerifierHash it was saved with", async () => {
|
|
194
|
+
const authorizationCode = await savedCode();
|
|
195
|
+
const token = await tokenFor(authorizationCode);
|
|
196
|
+
await exchangeCode(authorizationCode, token);
|
|
197
|
+
expect(await tokenService.revokeCode(authorizationCode.code, client, token.codeVerifierHash), "exchangeCode must persist codeVerifierHash with the token, or a " +
|
|
198
|
+
"replay with the redeeming verifier cannot revoke it").toBe(true);
|
|
199
|
+
expect(await live([token])).toStrictEqual([]);
|
|
200
|
+
});
|
|
201
|
+
if (expectCodeVerifierCheck) {
|
|
202
|
+
it("keeps the token's codeVerifierHash, so revokeCode refuses another verifier and revokes for its own", async () => {
|
|
203
|
+
const authorizationCode = await savedCode();
|
|
204
|
+
const token = await tokenFor(authorizationCode);
|
|
205
|
+
await exchangeCode(authorizationCode, token);
|
|
206
|
+
expect(await tokenService.revokeCode(authorizationCode.code, client, await sha256Hash(generateCodeVerifier())), "a replay that does not present the redeeming verifier must " +
|
|
207
|
+
"revoke nothing").toBe(false);
|
|
208
|
+
expect(await live([token])).toStrictEqual([token.accessToken]);
|
|
209
|
+
expect(await tokenService.revokeCode(authorizationCode.code, client, token.codeVerifierHash), "exchangeCode must persist codeVerifierHash with the token, or a " +
|
|
210
|
+
"replay with the redeeming verifier cannot revoke it").toBe(true);
|
|
211
|
+
expect(await live([token])).toStrictEqual([]);
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
it.skip("revokeCode ignores the presented verifier — a replay that names a public client, with any verifier, revokes the token exchangeCode saved (revokeCodeChecksVerifier: false)", () => { });
|
|
216
|
+
}
|
|
173
217
|
it("refuses a code that was already claimed or never stored, persisting nothing", async () => {
|
|
174
218
|
const claimed = await savedCode();
|
|
175
219
|
await codeService.revoke(claimed.code);
|
|
@@ -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);
|
|
@@ -86,6 +86,15 @@ export interface TokenServiceContractOptions<C extends ClientInterface, U extend
|
|
|
86
86
|
* started — instead of passing in silence. Defaults to `true`.
|
|
87
87
|
*/
|
|
88
88
|
revokeCodeChecksClient?: boolean;
|
|
89
|
+
/**
|
|
90
|
+
* Set to `false` when the service's `revokeCode` deliberately ignores its
|
|
91
|
+
* `codeVerifierHash` argument. The suite then registers an ignored case
|
|
92
|
+
* naming what is not covered — a replay that names a public client by
|
|
93
|
+
* `client_id`, with any verifier, revokes the tokens issued from its spent
|
|
94
|
+
* code, so anyone who holds a leaked code can end the session it started —
|
|
95
|
+
* instead of passing in silence. Defaults to `true`.
|
|
96
|
+
*/
|
|
97
|
+
revokeCodeChecksVerifier?: boolean;
|
|
89
98
|
/** Overrides the name passed to the outer `describe` block. */
|
|
90
99
|
describeName?: string;
|
|
91
100
|
}
|
|
@@ -27,6 +27,7 @@ const defaultMakeClient = (seq) => ({
|
|
|
27
27
|
});
|
|
28
28
|
const defaultMakeScope = (value) => new BasicScope(value);
|
|
29
29
|
const defaultMakeFamilyId = () => crypto.randomUUID();
|
|
30
|
+
const randomVerifierHash = () => Array.from(crypto.getRandomValues(new Uint8Array(32)), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
30
31
|
/** Runs the {@link TokenServiceInterface} contract suite. */
|
|
31
32
|
export function runTokenServiceContractTests(options) {
|
|
32
33
|
const makeUser = options.makeUser ?? defaultMakeUser;
|
|
@@ -36,6 +37,7 @@ export function runTokenServiceContractTests(options) {
|
|
|
36
37
|
const makeFamilyId = options.makeFamilyId ?? defaultMakeFamilyId;
|
|
37
38
|
const expectReuseDetection = options.refreshTokenReuseDetection ?? true;
|
|
38
39
|
const expectCodeClientCheck = options.revokeCodeChecksClient ?? true;
|
|
40
|
+
const expectCodeVerifierCheck = options.revokeCodeChecksVerifier ?? true;
|
|
39
41
|
describe(options.describeName ?? "TokenServiceInterface contract", () => {
|
|
40
42
|
let userService;
|
|
41
43
|
let clientService;
|
|
@@ -52,6 +54,41 @@ export function runTokenServiceContractTests(options) {
|
|
|
52
54
|
await options.addUser(userService, user, "pw");
|
|
53
55
|
await options.addClient(clientService, client);
|
|
54
56
|
});
|
|
57
|
+
async function familyMember(familyId, refreshTokenExpiresAt = new Date(Date.now() + 600_000)) {
|
|
58
|
+
const accessToken = await tokenService.generateAccessToken(client, user);
|
|
59
|
+
const refreshToken = (await tokenService.generateRefreshToken(client, user));
|
|
60
|
+
return {
|
|
61
|
+
accessToken,
|
|
62
|
+
accessTokenExpiresAt: new Date(Date.now() + 60_000),
|
|
63
|
+
refreshToken,
|
|
64
|
+
refreshTokenExpiresAt,
|
|
65
|
+
familyId,
|
|
66
|
+
familyCreatedAt: new Date(Date.now() - 60_000),
|
|
67
|
+
client,
|
|
68
|
+
user,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
async function saveFamilyMember(familyId, refreshTokenExpiresAt) {
|
|
72
|
+
const token = await familyMember(familyId, refreshTokenExpiresAt);
|
|
73
|
+
await tokenService.save(token);
|
|
74
|
+
return token;
|
|
75
|
+
}
|
|
76
|
+
async function isLive(token) {
|
|
77
|
+
return ((await tokenService.getRefreshToken(token.refreshToken)) !==
|
|
78
|
+
undefined ||
|
|
79
|
+
(await tokenService.getToken(token.accessToken)) !== undefined);
|
|
80
|
+
}
|
|
81
|
+
async function rotateTo(current, next) {
|
|
82
|
+
if (tokenService.rotate) {
|
|
83
|
+
assert(await tokenService.rotate(current, next));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const claimed = tokenService.revokeRotated
|
|
87
|
+
? await tokenService.revokeRotated(current)
|
|
88
|
+
: await tokenService.revoke(current);
|
|
89
|
+
assert(claimed);
|
|
90
|
+
await tokenService.save(next);
|
|
91
|
+
}
|
|
55
92
|
describe("generateAccessToken / generateRefreshToken", () => {
|
|
56
93
|
it("produces unique access tokens", async () => {
|
|
57
94
|
const a = await tokenService.generateAccessToken(client, user);
|
|
@@ -237,10 +274,92 @@ export function runTokenServiceContractTests(options) {
|
|
|
237
274
|
expect(await tokenService.revokeCode("replayed-code", client)).toBe(true);
|
|
238
275
|
expect(await tokenService.getToken(accessToken)).toBe(undefined);
|
|
239
276
|
});
|
|
277
|
+
it("revokes nothing in a rotated family for a client other than the one the code's tokens were issued to", async () => {
|
|
278
|
+
const other = makeClient(2);
|
|
279
|
+
await options.addClient(clientService, other);
|
|
280
|
+
const family = makeFamilyId();
|
|
281
|
+
const issued = {
|
|
282
|
+
...(await familyMember(family)),
|
|
283
|
+
code: "rotated-replayed-code",
|
|
284
|
+
};
|
|
285
|
+
await tokenService.save(issued);
|
|
286
|
+
const rotated = await familyMember(family);
|
|
287
|
+
await rotateTo(issued, rotated);
|
|
288
|
+
expect(await tokenService.revokeCode("rotated-replayed-code", other)).toBe(false);
|
|
289
|
+
expect(await isLive(rotated), "the client check comes before any family is revoked — a " +
|
|
290
|
+
"client that could never have redeemed the code must not end " +
|
|
291
|
+
"the session rotated from it").toBe(true);
|
|
292
|
+
});
|
|
240
293
|
}
|
|
241
294
|
else {
|
|
242
295
|
it.skip("revokeCode ignores the presenting client — any client that presents a spent code revokes its tokens (revokeCodeChecksClient: false)", () => { });
|
|
243
296
|
}
|
|
297
|
+
if (expectCodeVerifierCheck) {
|
|
298
|
+
async function issueBoundCode(code, codeVerifierHash) {
|
|
299
|
+
const accessToken = await tokenService.generateAccessToken(client, user);
|
|
300
|
+
await tokenService.save({
|
|
301
|
+
accessToken,
|
|
302
|
+
code,
|
|
303
|
+
codeVerifierHash,
|
|
304
|
+
client,
|
|
305
|
+
user,
|
|
306
|
+
});
|
|
307
|
+
return accessToken;
|
|
308
|
+
}
|
|
309
|
+
it("revokes nothing for a codeVerifierHash other than the one the code's tokens were saved with", async () => {
|
|
310
|
+
const accessToken = await issueBoundCode("bound-code", randomVerifierHash());
|
|
311
|
+
expect(await tokenService.revokeCode("bound-code", client, randomVerifierHash()), "a replay without the verifier that redeemed the code proves " +
|
|
312
|
+
"nothing, so it must not revoke what the code issued").toBe(false);
|
|
313
|
+
expect(await tokenService.getToken(accessToken)).not.toBe(undefined);
|
|
314
|
+
});
|
|
315
|
+
it("revokes for the codeVerifierHash the code's tokens were saved with, after another was refused", async () => {
|
|
316
|
+
const codeVerifierHash = randomVerifierHash();
|
|
317
|
+
const accessToken = await issueBoundCode("bound-code", codeVerifierHash);
|
|
318
|
+
await tokenService.revokeCode("bound-code", client, randomVerifierHash());
|
|
319
|
+
expect(await tokenService.revokeCode("bound-code", client, codeVerifierHash)).toBe(true);
|
|
320
|
+
expect(await tokenService.getToken(accessToken)).toBe(undefined);
|
|
321
|
+
});
|
|
322
|
+
if (expectCodeClientCheck) {
|
|
323
|
+
it("revokes nothing for another client that presents the codeVerifierHash the code's tokens were saved with", async () => {
|
|
324
|
+
const other = makeClient(2);
|
|
325
|
+
await options.addClient(clientService, other);
|
|
326
|
+
const codeVerifierHash = randomVerifierHash();
|
|
327
|
+
const accessToken = await issueBoundCode("bound-code", codeVerifierHash);
|
|
328
|
+
expect(await tokenService.revokeCode("bound-code", other, codeVerifierHash), "a matching verifier does not lift the client check").toBe(false);
|
|
329
|
+
expect(await tokenService.getToken(accessToken)).not.toBe(undefined);
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
it("revokes nothing for a codeVerifierHash when the code's tokens were saved without one", async () => {
|
|
333
|
+
const accessToken = await issueBoundCode("unbound-code");
|
|
334
|
+
expect(await tokenService.revokeCode("unbound-code", client, randomVerifierHash()), "a code redeemed without PKCE binds no verifier, so no verifier " +
|
|
335
|
+
"a replay presents can match it").toBe(false);
|
|
336
|
+
expect(await tokenService.getToken(accessToken)).not.toBe(undefined);
|
|
337
|
+
});
|
|
338
|
+
it("revokes a code saved with a codeVerifierHash when none is passed, for a client that authenticated", async () => {
|
|
339
|
+
const accessToken = await issueBoundCode("bound-code", randomVerifierHash());
|
|
340
|
+
expect(await tokenService.revokeCode("bound-code", client), "the grant omits codeVerifierHash for a client that " +
|
|
341
|
+
"authenticated with its secret, where the client check suffices").toBe(true);
|
|
342
|
+
expect(await tokenService.getToken(accessToken)).toBe(undefined);
|
|
343
|
+
});
|
|
344
|
+
it("revokes nothing in a rotated family for a codeVerifierHash other than the one the code's tokens were saved with", async () => {
|
|
345
|
+
const family = makeFamilyId();
|
|
346
|
+
const issued = {
|
|
347
|
+
...(await familyMember(family)),
|
|
348
|
+
code: "rotated-bound-code",
|
|
349
|
+
codeVerifierHash: randomVerifierHash(),
|
|
350
|
+
};
|
|
351
|
+
await tokenService.save(issued);
|
|
352
|
+
const rotated = await familyMember(family);
|
|
353
|
+
await rotateTo(issued, rotated);
|
|
354
|
+
expect(await tokenService.revokeCode("rotated-bound-code", client, randomVerifierHash())).toBe(false);
|
|
355
|
+
expect(await isLive(rotated), "the verifier check comes before any family is revoked — a " +
|
|
356
|
+
"replay without the redeeming verifier must not end the " +
|
|
357
|
+
"session rotated from the code").toBe(true);
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
else {
|
|
361
|
+
it.skip("revokeCode ignores the presented verifier — a replay that names a public client, with any verifier, revokes its spent code's tokens (revokeCodeChecksVerifier: false)", () => { });
|
|
362
|
+
}
|
|
244
363
|
});
|
|
245
364
|
if (expectReuseDetection) {
|
|
246
365
|
describe("refresh-token rotation family", () => {
|
|
@@ -250,25 +369,6 @@ export function runTokenServiceContractTests(options) {
|
|
|
250
369
|
family = makeFamilyId();
|
|
251
370
|
otherFamily = makeFamilyId();
|
|
252
371
|
});
|
|
253
|
-
async function familyMember(familyId, refreshTokenExpiresAt = new Date(Date.now() + 600_000)) {
|
|
254
|
-
const accessToken = await tokenService.generateAccessToken(client, user);
|
|
255
|
-
const refreshToken = (await tokenService.generateRefreshToken(client, user));
|
|
256
|
-
return {
|
|
257
|
-
accessToken,
|
|
258
|
-
accessTokenExpiresAt: new Date(Date.now() + 60_000),
|
|
259
|
-
refreshToken,
|
|
260
|
-
refreshTokenExpiresAt,
|
|
261
|
-
familyId,
|
|
262
|
-
familyCreatedAt: new Date(Date.now() - 60_000),
|
|
263
|
-
client,
|
|
264
|
-
user,
|
|
265
|
-
};
|
|
266
|
-
}
|
|
267
|
-
async function saveFamilyMember(familyId, refreshTokenExpiresAt) {
|
|
268
|
-
const token = await familyMember(familyId, refreshTokenExpiresAt);
|
|
269
|
-
await tokenService.save(token);
|
|
270
|
-
return token;
|
|
271
|
-
}
|
|
272
372
|
it("implements both halves of reuse detection", () => {
|
|
273
373
|
assert(typeof tokenService.getRevokedRefreshToken === "function" &&
|
|
274
374
|
typeof tokenService.revokeFamily === "function", "getRevokedRefreshToken and revokeFamily are optional, but " +
|
|
@@ -404,6 +504,49 @@ export function runTokenServiceContractTests(options) {
|
|
|
404
504
|
assert((await tokenService.getRefreshToken(unrelated.refreshToken)) !==
|
|
405
505
|
undefined);
|
|
406
506
|
});
|
|
507
|
+
it("revokes the rotated successors of a replayed code's tokens, leaving other families live", async () => {
|
|
508
|
+
const code = "rotated-code";
|
|
509
|
+
const issued = { ...(await familyMember(family)), code };
|
|
510
|
+
await tokenService.save(issued);
|
|
511
|
+
const rotated = await familyMember(family);
|
|
512
|
+
await rotateTo(issued, rotated);
|
|
513
|
+
const latest = await familyMember(family);
|
|
514
|
+
await rotateTo(rotated, latest);
|
|
515
|
+
const unrelated = await saveFamilyMember(otherFamily);
|
|
516
|
+
expect(await isLive(latest)).toBe(true);
|
|
517
|
+
expect(await tokenService.revokeCode(code, client), "the replay ended a live token, so the grant must report it").toBe(true);
|
|
518
|
+
expect(await tokenService.getRefreshToken(latest.refreshToken), "a rotated successor carries no code, so revoking only the " +
|
|
519
|
+
"rows issued with the code leaves the stolen code's session " +
|
|
520
|
+
"refreshing — revoke every live member of their families").toBe(undefined);
|
|
521
|
+
expect(await tokenService.getToken(latest.accessToken), "the successor's access token ends with its family").toBe(undefined);
|
|
522
|
+
if (tokenService.hasLiveRefreshToken) {
|
|
523
|
+
expect(await tokenService.hasLiveRefreshToken(family)).toBe(false);
|
|
524
|
+
}
|
|
525
|
+
assert((await tokenService.getRefreshToken(unrelated.refreshToken)) !==
|
|
526
|
+
undefined, "a replayed code must not end families it did not issue");
|
|
527
|
+
});
|
|
528
|
+
it("keeps a replayed code's family revoked: a successor saved after revokeCode is not live", async ({ skip, }) => {
|
|
529
|
+
skip(typeof tokenService.rotate === "function", "the service implements rotate, so the refresh grant never saves " +
|
|
530
|
+
"a successor separately; runTokenRotationContractTests checks " +
|
|
531
|
+
"revokeCode against rotate");
|
|
532
|
+
const code = "claimed-code";
|
|
533
|
+
const issued = { ...(await familyMember(family)), code };
|
|
534
|
+
await tokenService.save(issued);
|
|
535
|
+
const next = await familyMember(family);
|
|
536
|
+
const claimed = tokenService.revokeRotated
|
|
537
|
+
? await tokenService.revokeRotated(issued)
|
|
538
|
+
: await tokenService.revoke(issued);
|
|
539
|
+
expect(claimed).toBe(true);
|
|
540
|
+
await tokenService.revokeCode(code, client);
|
|
541
|
+
await tokenService.save(next);
|
|
542
|
+
expect(await isLive(next), "a successor saved after its code was replayed must be stored " +
|
|
543
|
+
"already revoked: the refresh grant claims a token and saves " +
|
|
544
|
+
"its successor in two calls, and a replay that lands between " +
|
|
545
|
+
"them otherwise leaves the successor live").toBe(false);
|
|
546
|
+
if (tokenService.hasLiveRefreshToken) {
|
|
547
|
+
expect(await tokenService.hasLiveRefreshToken(family)).toBe(false);
|
|
548
|
+
}
|
|
549
|
+
});
|
|
407
550
|
it("leaves rotatedAt unset on a token revoked other than by rotation", async () => {
|
|
408
551
|
const token = await saveFamilyMember(family);
|
|
409
552
|
await tokenService.revoke(token.refreshToken, "refresh_token");
|
|
@@ -294,11 +294,14 @@ export declare class MemoryTokenService<C extends ClientInterface, U extends Mem
|
|
|
294
294
|
revokeFamily(familyId: string): Promise<boolean>;
|
|
295
295
|
/**
|
|
296
296
|
* Removes every token issued from the given authorization code, used when a
|
|
297
|
-
* code is replayed
|
|
297
|
+
* code is replayed, and revokes the rotation families of those tokens as
|
|
298
|
+
* {@linkcode revokeFamily} does, so their rotated successors end with them.
|
|
299
|
+
* Resolves `true` when tokens were issued from the code.
|
|
298
300
|
* Removes nothing and resolves `false` when `client` is given and is not the
|
|
299
|
-
* client the code's tokens were issued to
|
|
301
|
+
* client the code's tokens were issued to, or when `codeVerifierHash` is
|
|
302
|
+
* given and is not the one they were saved with.
|
|
300
303
|
*/
|
|
301
|
-
revokeCode(code: string, client?: C): Promise<boolean>;
|
|
304
|
+
revokeCode(code: string, client?: C, codeVerifierHash?: string): Promise<boolean>;
|
|
302
305
|
}
|
|
303
306
|
/** Options for {@link MemoryAuthorizationCodeService}. */
|
|
304
307
|
export interface MemoryAuthorizationCodeServiceOptions<C extends ClientInterface, U extends MemoryUserShape> {
|
package/dist/testing/services.js
CHANGED
|
@@ -354,6 +354,9 @@ export class MemoryTokenService {
|
|
|
354
354
|
};
|
|
355
355
|
if (token.code)
|
|
356
356
|
stored.codeHash = await sha256Hash(token.code);
|
|
357
|
+
if (token.codeVerifierHash) {
|
|
358
|
+
stored.codeVerifierHash = token.codeVerifierHash;
|
|
359
|
+
}
|
|
357
360
|
if ("refreshToken" in token) {
|
|
358
361
|
const rt = token;
|
|
359
362
|
stored.refreshTokenHash = await sha256Hash(rt.refreshToken);
|
|
@@ -367,10 +370,17 @@ export class MemoryTokenService {
|
|
|
367
370
|
if (stored.codeHash) {
|
|
368
371
|
let bucket = this.#tokensByCodeHash.get(stored.codeHash);
|
|
369
372
|
if (!bucket) {
|
|
370
|
-
bucket = {
|
|
373
|
+
bucket = {
|
|
374
|
+
clientId: stored.clientId,
|
|
375
|
+
codeVerifierHash: stored.codeVerifierHash,
|
|
376
|
+
accessTokenHashes: new Set(),
|
|
377
|
+
familyIds: new Set(),
|
|
378
|
+
};
|
|
371
379
|
this.#tokensByCodeHash.set(stored.codeHash, bucket);
|
|
372
380
|
}
|
|
373
381
|
bucket.accessTokenHashes.add(stored.accessTokenHash);
|
|
382
|
+
if (stored.familyId)
|
|
383
|
+
bucket.familyIds.add(stored.familyId);
|
|
374
384
|
}
|
|
375
385
|
if (stored.refreshTokenHash) {
|
|
376
386
|
if (stored.familyId && this.#revokedFamilies.has(stored.familyId)) {
|
|
@@ -483,6 +493,9 @@ export class MemoryTokenService {
|
|
|
483
493
|
* live token was removed.
|
|
484
494
|
*/
|
|
485
495
|
revokeFamily(familyId) {
|
|
496
|
+
return Promise.resolve(this.#revokeFamily(familyId));
|
|
497
|
+
}
|
|
498
|
+
#revokeFamily(familyId) {
|
|
486
499
|
this.#revokedFamilies.add(familyId);
|
|
487
500
|
let revoked = false;
|
|
488
501
|
for (const [hash, stored] of this.#tokensByRefreshHash) {
|
|
@@ -499,15 +512,18 @@ export class MemoryTokenService {
|
|
|
499
512
|
revoked = true;
|
|
500
513
|
}
|
|
501
514
|
}
|
|
502
|
-
return
|
|
515
|
+
return revoked;
|
|
503
516
|
}
|
|
504
517
|
/**
|
|
505
518
|
* Removes every token issued from the given authorization code, used when a
|
|
506
|
-
* code is replayed
|
|
519
|
+
* code is replayed, and revokes the rotation families of those tokens as
|
|
520
|
+
* {@linkcode revokeFamily} does, so their rotated successors end with them.
|
|
521
|
+
* Resolves `true` when tokens were issued from the code.
|
|
507
522
|
* Removes nothing and resolves `false` when `client` is given and is not the
|
|
508
|
-
* client the code's tokens were issued to
|
|
523
|
+
* client the code's tokens were issued to, or when `codeVerifierHash` is
|
|
524
|
+
* given and is not the one they were saved with.
|
|
509
525
|
*/
|
|
510
|
-
async revokeCode(code, client) {
|
|
526
|
+
async revokeCode(code, client, codeVerifierHash) {
|
|
511
527
|
const codeHash = await sha256Hash(code);
|
|
512
528
|
const bucket = this.#tokensByCodeHash.get(codeHash);
|
|
513
529
|
if (!bucket || bucket.accessTokenHashes.size === 0)
|
|
@@ -515,6 +531,12 @@ export class MemoryTokenService {
|
|
|
515
531
|
if (client && client.id.toString() !== bucket.clientId.toString()) {
|
|
516
532
|
return false;
|
|
517
533
|
}
|
|
534
|
+
if (codeVerifierHash !== undefined &&
|
|
535
|
+
codeVerifierHash !== bucket.codeVerifierHash) {
|
|
536
|
+
return false;
|
|
537
|
+
}
|
|
538
|
+
for (const familyId of bucket.familyIds)
|
|
539
|
+
this.#revokeFamily(familyId);
|
|
518
540
|
for (const accessTokenHash of bucket.accessTokenHashes) {
|
|
519
541
|
const stored = this.#tokensByAccessHash.get(accessTokenHash);
|
|
520
542
|
if (stored) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@udibo/oauth2",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.37.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",
|