@udibo/oauth2 0.36.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @udibo/oauth2
2
2
 
3
- [![npm version](https://img.shields.io/npm/v/@udibo/oauth2)](https://www.npmjs.com/package/@udibo/oauth2) [![JSR version](https://jsr.io/badges/@udibo/oauth2)](https://jsr.io/@udibo/oauth2) [![JSR score](https://jsr.io/badges/@udibo/oauth2/score)](https://jsr.io/@udibo/oauth2/score) [![CI](https://github.com/udibo/oauth2/actions/workflows/ci-cd.yml/badge.svg?branch=main)](https://github.com/udibo/oauth2/actions/workflows/ci-cd.yml?query=branch%3Amain) [![License: MIT](https://img.shields.io/npm/l/@udibo/oauth2)](LICENSE) [![npm downloads](https://img.shields.io/npm/dw/@udibo/oauth2)](https://www.npmjs.com/package/@udibo/oauth2) [![Node.js version](https://img.shields.io/node/v/@udibo/oauth2)](https://www.npmjs.com/package/@udibo/oauth2)
3
+ [![npm version](https://img.shields.io/npm/v/@udibo/oauth2)](https://www.npmjs.com/package/@udibo/oauth2) [![JSR version](https://jsr.io/badges/@udibo/oauth2)](https://jsr.io/@udibo/oauth2) [![CI](https://github.com/udibo/oauth2/actions/workflows/ci-cd.yml/badge.svg?branch=main)](https://github.com/udibo/oauth2/actions/workflows/ci-cd.yml?query=branch%3Amain) [![Coverage](https://codecov.io/gh/udibo/oauth2/branch/main/graph/badge.svg)](https://codecov.io/gh/udibo/oauth2) [![License: MIT](https://img.shields.io/npm/l/@udibo/oauth2)](LICENSE) [![npm downloads](https://img.shields.io/npm/dw/@udibo/oauth2)](https://www.npmjs.com/package/@udibo/oauth2) [![Node.js version](https://img.shields.io/node/v/@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
@@ -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
- * so whoever holds the spent code can present it as that client.
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, code, client, "invalid code");
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, code, client);
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, client);
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, client);
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, code, client);
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, client) {
296
+ async #consumeFailedExchange(authorizationCodeService, tokenService, authorizationCode, replay) {
285
297
  try {
286
298
  if (!(await authorizationCodeService.revoke(authorizationCode))) {
287
- await this.#refuseClaimedCode(tokenService, authorizationCode.code, client);
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
- if (!(await tokenService.revokeCode(code, client))) {
296
- return new InvalidGrantError(unrevokedMessage);
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;
@@ -173,13 +173,17 @@ export interface TokenServiceInterface<Client extends ClientInterface, User, Sco
173
173
  * client the code's tokens were issued to; for any other client revoke
174
174
  * nothing and resolve `false`. A different confidential client could never
175
175
  * have redeemed the code, since that needs the code's own client's
176
- * credentials, so its replay says nothing about theft. The gate names a
177
- * public client, or any client when the grant's
178
- * `requireClientAuthentication` is `false`, by `client_id` alone, so anyone
179
- * holding such a client's spent code can still present it as that client
180
- * and revoke. A store that ignores `client` revokes on any client's replay,
181
- * which lets a client that sees a leaked, spent code end the session it
182
- * started.
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.
183
187
  *
184
188
  * With {@linkcode exchangeCode}, make the two linearizable: a call that
185
189
  * begins once `exchangeCode` has claimed the code must find and revoke the
@@ -188,7 +192,7 @@ export interface TokenServiceInterface<Client extends ClientInterface, User, Sco
188
192
  *
189
193
  * @see https://datatracker.ietf.org/doc/html/rfc6819#section-4.4.1.1
190
194
  */
191
- revokeCode(code: string, client?: Client): Promise<boolean>;
195
+ revokeCode(code: string, client?: Client, codeVerifierHash?: string): Promise<boolean>;
192
196
  /**
193
197
  * Exchanges an authorization code in one atomic step: claims
194
198
  * `authorizationCode` as `AuthorizationCodeServiceInterface.revoke`
@@ -207,8 +211,9 @@ export interface TokenServiceInterface<Client extends ClientInterface, User, Sco
207
211
  *
208
212
  * After a successful call the code service's `get` must resolve `undefined`
209
213
  * for the code, as after `revoke`. A failed insert must leave the code
210
- * claimable. Store the token so `revokeCode` finds it by the code, and make
211
- * the two linearizable, as `revokeCode` requires.
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.
212
217
  * `runCodeExchangeContractTests` checks it under concurrent callers, with
213
218
  * the store's barrier wired between its claim and its insert.
214
219
  */
@@ -372,8 +377,9 @@ export declare abstract class AbstractTokenService<Client extends ClientInterfac
372
377
  /**
373
378
  * Revokes every token issued from the given authorization code, returning
374
379
  * true if any were revoked (signaling code replay per RFC 6819 §4.4.1.1).
375
- * Revoke nothing when `client` is not the client the tokens were issued to
376
- * ({@linkcode TokenServiceInterface.revokeCode} explains why).
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).
377
383
  */
378
- abstract revokeCode(code: string, client?: Client): Promise<boolean>;
384
+ abstract revokeCode(code: string, client?: Client, codeVerifierHash?: string): Promise<boolean>;
379
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
- return {
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);
@@ -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;
@@ -292,6 +294,72 @@ export function runTokenServiceContractTests(options) {
292
294
  else {
293
295
  it.skip("revokeCode ignores the presenting client — any client that presents a spent code revokes its tokens (revokeCodeChecksClient: false)", () => { });
294
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
+ }
295
363
  });
296
364
  if (expectReuseDetection) {
297
365
  describe("refresh-token rotation family", () => {
@@ -298,9 +298,10 @@ export declare class MemoryTokenService<C extends ClientInterface, U extends Mem
298
298
  * {@linkcode revokeFamily} does, so their rotated successors end with them.
299
299
  * Resolves `true` when tokens were issued from the code.
300
300
  * Removes nothing and resolves `false` when `client` is given and is not the
301
- * 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.
302
303
  */
303
- revokeCode(code: string, client?: C): Promise<boolean>;
304
+ revokeCode(code: string, client?: C, codeVerifierHash?: string): Promise<boolean>;
304
305
  }
305
306
  /** Options for {@link MemoryAuthorizationCodeService}. */
306
307
  export interface MemoryAuthorizationCodeServiceOptions<C extends ClientInterface, U extends MemoryUserShape> {
@@ -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);
@@ -369,6 +372,7 @@ export class MemoryTokenService {
369
372
  if (!bucket) {
370
373
  bucket = {
371
374
  clientId: stored.clientId,
375
+ codeVerifierHash: stored.codeVerifierHash,
372
376
  accessTokenHashes: new Set(),
373
377
  familyIds: new Set(),
374
378
  };
@@ -516,9 +520,10 @@ export class MemoryTokenService {
516
520
  * {@linkcode revokeFamily} does, so their rotated successors end with them.
517
521
  * Resolves `true` when tokens were issued from the code.
518
522
  * Removes nothing and resolves `false` when `client` is given and is not the
519
- * 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.
520
525
  */
521
- async revokeCode(code, client) {
526
+ async revokeCode(code, client, codeVerifierHash) {
522
527
  const codeHash = await sha256Hash(code);
523
528
  const bucket = this.#tokensByCodeHash.get(codeHash);
524
529
  if (!bucket || bucket.accessTokenHashes.size === 0)
@@ -526,6 +531,10 @@ export class MemoryTokenService {
526
531
  if (client && client.id.toString() !== bucket.clientId.toString()) {
527
532
  return false;
528
533
  }
534
+ if (codeVerifierHash !== undefined &&
535
+ codeVerifierHash !== bucket.codeVerifierHash) {
536
+ return false;
537
+ }
529
538
  for (const familyId of bucket.familyIds)
530
539
  this.#revokeFamily(familyId);
531
540
  for (const accessTokenHash of bucket.accessTokenHashes) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@udibo/oauth2",
3
- "version": "0.36.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",