@udibo/oauth2 0.33.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The AES-256-GCM keys the BFF's encrypted-cookie stores seal under.
3
+ *
4
+ * @module
5
+ */
6
+ /** HKDF `info` label for `EncryptedCookieSessionStore`'s key. */
7
+ export declare const SESSION_COOKIE_KEY_INFO = "udibo:oauth2:bff:session-cookie:v1";
8
+ /** HKDF `info` label for `EncryptedCookieAuthRequestStorage`'s key. */
9
+ export declare const AUTH_REQUEST_COOKIE_KEY_INFO = "udibo:oauth2:bff:auth-request-cookie:v1";
10
+ /**
11
+ * Derives a cookie store's key from its configured secret with HKDF-SHA-256
12
+ * under the store's own `info` label, so one secret given to two stores yields
13
+ * independent keys.
14
+ *
15
+ * @throws {Error} When the secret is shorter than 16 bytes.
16
+ */
17
+ export declare function deriveCookieKey(secret: string | Uint8Array, info: string): Promise<CryptoKey>;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The AES-256-GCM keys the BFF's encrypted-cookie stores seal under.
3
+ *
4
+ * @module
5
+ */
6
+ import { deriveSealKey } from "../../../utils/crypto.js";
7
+ /** HKDF `info` label for `EncryptedCookieSessionStore`'s key. */
8
+ export const SESSION_COOKIE_KEY_INFO = "udibo:oauth2:bff:session-cookie:v1";
9
+ /** HKDF `info` label for `EncryptedCookieAuthRequestStorage`'s key. */
10
+ export const AUTH_REQUEST_COOKIE_KEY_INFO = "udibo:oauth2:bff:auth-request-cookie:v1";
11
+ const MIN_SECRET_BYTES = 16;
12
+ /**
13
+ * Derives a cookie store's key from its configured secret with HKDF-SHA-256
14
+ * under the store's own `info` label, so one secret given to two stores yields
15
+ * independent keys.
16
+ *
17
+ * @throws {Error} When the secret is shorter than 16 bytes.
18
+ */
19
+ export async function deriveCookieKey(secret, info) {
20
+ const bytes = typeof secret === "string" ? new TextEncoder().encode(secret) : secret;
21
+ if (bytes.byteLength < MIN_SECRET_BYTES) {
22
+ throw new Error("secret must be at least 16 bytes; 32+ bytes of entropy is recommended.");
23
+ }
24
+ return await deriveSealKey(bytes, info);
25
+ }
@@ -11,10 +11,10 @@
11
11
  * caller chooses.
12
12
  *
13
13
  * {@link EncryptedCookieAuthRequestStorage} carries the record in an encrypted
14
- * cookie instead (AES-GCM under a server-held secret, the same construction
15
- * {@link EncryptedCookieSessionStore} uses), so no server-side storage is
16
- * involved and any instance can complete the flow. Apps that already have a
17
- * database can implement {@link AuthRequestStorage} against it instead.
14
+ * cookie instead (AES-GCM under a key derived from a server-held secret), so no
15
+ * server-side storage is involved and any instance can complete the flow. Apps
16
+ * that already have a database can implement {@link AuthRequestStorage} against
17
+ * it instead.
18
18
  *
19
19
  * **Which storage you pick is a reliability decision, not a security one.**
20
20
  * Server-side storage on its own says a `state` is pending somewhere — never
@@ -84,6 +84,13 @@ export interface EncryptedCookieAuthRequestStorageOptions {
84
84
  /**
85
85
  * Secret the AES-256 key is derived from; at least 32 bytes. Reuse the
86
86
  * secret across instances so any of them can unseal a cookie another sealed.
87
+ *
88
+ * Give each store instance and each app a secret of its own. Nothing in a
89
+ * sealed cookie binds it to the app or store instance that sealed it, so two
90
+ * apps that share a secret can open each other's pending sign-ins. Sharing
91
+ * one with {@link EncryptedCookieSessionStore} does not let either store open
92
+ * the other's cookies: each derives its key with HKDF-SHA-256 under its own
93
+ * label.
87
94
  */
88
95
  secret: string;
89
96
  /** Cookie attribute overrides. */
@@ -132,14 +139,21 @@ export interface AuthRequestStorageFactory {
132
139
  *
133
140
  * It has no `take`: the record travels with the browser and cannot be claimed
134
141
  * atomically, so a replayed callback is bounded by the authorization server's
135
- * single-use code and PKCE.
142
+ * single-use code and PKCE. Two concurrent callbacks for one sign-in both reach
143
+ * the token endpoint, and an authorization server that revokes on code replay,
144
+ * as `@udibo/oauth2`'s does, revokes the tokens the winner received, so the
145
+ * user is signed out and has to sign in again.
136
146
  *
137
147
  * @example
138
148
  * ```ts
139
149
  * const bff = new HonoBff({
140
150
  * client,
141
- * sessionStore: new EncryptedCookieSessionStore({ secret }),
142
- * authRequestStorage: new EncryptedCookieAuthRequestStorage({ secret }),
151
+ * sessionStore: new EncryptedCookieSessionStore({
152
+ * secret: process.env.SESSION_SECRET!,
153
+ * }),
154
+ * authRequestStorage: new EncryptedCookieAuthRequestStorage({
155
+ * secret: process.env.AUTH_REQUEST_SECRET!,
156
+ * }),
143
157
  * });
144
158
  * ```
145
159
  */
@@ -171,7 +185,7 @@ export declare class EncryptedCookieAuthRequestStorage implements AuthRequestSto
171
185
  * import { encryptedCookieAuthRequestStorage } from "@udibo/oauth2/hono/bff";
172
186
  *
173
187
  * const authRequestStorage = encryptedCookieAuthRequestStorage({
174
- * secret: process.env.SESSION_SECRET!,
188
+ * secret: process.env.AUTH_REQUEST_SECRET!,
175
189
  * });
176
190
  * ```
177
191
  */
@@ -11,10 +11,10 @@
11
11
  * caller chooses.
12
12
  *
13
13
  * {@link EncryptedCookieAuthRequestStorage} carries the record in an encrypted
14
- * cookie instead (AES-GCM under a server-held secret, the same construction
15
- * {@link EncryptedCookieSessionStore} uses), so no server-side storage is
16
- * involved and any instance can complete the flow. Apps that already have a
17
- * database can implement {@link AuthRequestStorage} against it instead.
14
+ * cookie instead (AES-GCM under a key derived from a server-held secret), so no
15
+ * server-side storage is involved and any instance can complete the flow. Apps
16
+ * that already have a database can implement {@link AuthRequestStorage} against
17
+ * it instead.
18
18
  *
19
19
  * **Which storage you pick is a reliability decision, not a security one.**
20
20
  * Server-side storage on its own says a `state` is pending somewhere — never
@@ -29,13 +29,17 @@
29
29
  */
30
30
  import { deleteCookie, getCookie, setCookie } from "hono/cookie";
31
31
  import { MemoryAuthRequestStorage, } from "../../../client/mod.js";
32
- import { deriveAesKey, sealJson, unsealJson } from "../../../utils/crypto.js";
32
+ import { sealJson, unsealJson } from "../../../utils/crypto.js";
33
+ import { AUTH_REQUEST_COOKIE_KEY_INFO, deriveCookieKey, } from "./_cookie-keys.js";
33
34
  import { resolveCookieName, resolvePrefixConstrainedAttributes, } from "./cookie-prefix.js";
34
35
  const BASE_NAME = "oauth2_auth_request";
35
36
  const DEFAULT_TTL_MS = 10 * 60 * 1000;
36
37
  const DEFAULT_MAX_PENDING = 3;
37
38
  // RFC 6265 §6.1 only guarantees 4096 bytes per cookie, attributes included.
38
39
  const MAX_COOKIE_NAME_VALUE_BYTES = 3900;
40
+ function isObject(value) {
41
+ return typeof value === "object" && value !== null && !Array.isArray(value);
42
+ }
39
43
  function cookieAttributes(options, maxAgeSeconds) {
40
44
  const { secure, path, domain } = resolvePrefixConstrainedAttributes(options);
41
45
  return {
@@ -59,14 +63,21 @@ function cookieAttributes(options, maxAgeSeconds) {
59
63
  *
60
64
  * It has no `take`: the record travels with the browser and cannot be claimed
61
65
  * atomically, so a replayed callback is bounded by the authorization server's
62
- * single-use code and PKCE.
66
+ * single-use code and PKCE. Two concurrent callbacks for one sign-in both reach
67
+ * the token endpoint, and an authorization server that revokes on code replay,
68
+ * as `@udibo/oauth2`'s does, revokes the tokens the winner received, so the
69
+ * user is signed out and has to sign in again.
63
70
  *
64
71
  * @example
65
72
  * ```ts
66
73
  * const bff = new HonoBff({
67
74
  * client,
68
- * sessionStore: new EncryptedCookieSessionStore({ secret }),
69
- * authRequestStorage: new EncryptedCookieAuthRequestStorage({ secret }),
75
+ * sessionStore: new EncryptedCookieSessionStore({
76
+ * secret: process.env.SESSION_SECRET!,
77
+ * }),
78
+ * authRequestStorage: new EncryptedCookieAuthRequestStorage({
79
+ * secret: process.env.AUTH_REQUEST_SECRET!,
80
+ * }),
70
81
  * });
71
82
  * ```
72
83
  */
@@ -107,17 +118,19 @@ export class EncryptedCookieAuthRequestStorage {
107
118
  }
108
119
  }
109
120
  #key() {
110
- return (this.#keyPromise ??= deriveAesKey(this.#secret));
121
+ return (this.#keyPromise ??= deriveCookieKey(this.#secret, AUTH_REQUEST_COOKIE_KEY_INFO));
111
122
  }
112
123
  async #read(c) {
113
124
  const cookieValue = getCookie(c, this.#name);
114
125
  if (!cookieValue)
115
126
  return {};
116
127
  const pending = await unsealJson(await this.#key(), cookieValue);
117
- if (!pending)
128
+ if (!isObject(pending))
118
129
  return {};
119
130
  const cutoff = Date.now() - this.#ttlMs;
120
- return Object.fromEntries(Object.entries(pending).filter(([, record]) => record.createdAt > cutoff));
131
+ return Object.fromEntries(Object.entries(pending).filter((entry) => isObject(entry[1]) &&
132
+ typeof entry[1].createdAt === "number" &&
133
+ entry[1].createdAt > cutoff));
121
134
  }
122
135
  async #write(c, pending, keep) {
123
136
  const entries = Object.entries(pending)
@@ -143,7 +156,10 @@ export class EncryptedCookieAuthRequestStorage {
143
156
  /** Returns the storage bound to `c`, for the duration of that request. */
144
157
  forRequest(c) {
145
158
  return {
146
- get: async (state) => (await this.#read(c))[state] ?? null,
159
+ get: async (state) => {
160
+ const pending = await this.#read(c);
161
+ return Object.hasOwn(pending, state) ? pending[state] : null;
162
+ },
147
163
  set: async (state, value) => {
148
164
  const pending = await this.#read(c);
149
165
  pending[state] = value;
@@ -168,7 +184,7 @@ export class EncryptedCookieAuthRequestStorage {
168
184
  * import { encryptedCookieAuthRequestStorage } from "@udibo/oauth2/hono/bff";
169
185
  *
170
186
  * const authRequestStorage = encryptedCookieAuthRequestStorage({
171
- * secret: process.env.SESSION_SECRET!,
187
+ * secret: process.env.AUTH_REQUEST_SECRET!,
172
188
  * });
173
189
  * ```
174
190
  */
@@ -283,6 +283,13 @@ export interface EncryptedCookieSessionStoreOptions {
283
283
  * Server-held secret(s) used to derive the AES-256 key(s). Each must have at
284
284
  * least 32 bytes of entropy; pass a base64 / hex string or raw bytes.
285
285
  *
286
+ * Give each store instance and each app a secret of its own. Nothing in a
287
+ * sealed cookie binds it to the app or store instance that sealed it, so two
288
+ * apps that share a secret can open each other's session cookies. Sharing one
289
+ * with `EncryptedCookieAuthRequestStorage` does not let either store open the
290
+ * other's cookies: each derives its key with HKDF-SHA-256 under its own
291
+ * label.
292
+ *
286
293
  * Pass an **ordered list** to rotate the secret without signing everyone out:
287
294
  * the **first** entry is current — every cookie is sealed under it — and
288
295
  * `read` tries each entry in turn, so a cookie sealed under a still-listed
@@ -308,10 +315,6 @@ export interface EncryptedCookieSessionStoreOptions {
308
315
  * `update` re-seals with a fresh stamp, so this is an **inactivity** window
309
316
  * for a session the BFF keeps refreshing. Must be a positive number of
310
317
  * milliseconds.
311
- *
312
- * The store stamps each cookie with a seal time at `create`/`update`; a
313
- * cookie without that stamp is aged from its `updatedAt` instead, so it is
314
- * bounded too.
315
318
  */
316
319
  maxAgeMs?: number;
317
320
  }
@@ -322,6 +325,11 @@ export interface EncryptedCookieSessionStoreOptions {
322
325
  * Security notes:
323
326
  * - Each secret must have at least 256 bits of entropy. A weak secret defeats
324
327
  * the confidentiality of the cookie.
328
+ * - The key is derived from the secret with HKDF-SHA-256 under a label of this
329
+ * store's own, so a cookie sealed by another store under the same secret
330
+ * does not open here.
331
+ * - `read` accepts only what `create` sealed: a cookie that is malformed,
332
+ * foreign, or the wrong shape reads as `null`.
325
333
  * - AES-GCM provides authenticated encryption; tampered cookies fail `read`
326
334
  * (returns `null`).
327
335
  * - `destroy` is a no-op on the server — the cookie must be cleared in the
@@ -366,8 +374,8 @@ export declare class EncryptedCookieSessionStore implements SessionStore, Bounde
366
374
  create(data: SessionData): Promise<string>;
367
375
  /**
368
376
  * Decrypts the cookie value, trying each configured secret in turn. Returns
369
- * `null` if it is tampered, malformed, sealed with an unknown secret, or
370
- * sealed longer ago than
377
+ * `null` if it is tampered, malformed, sealed with an unknown secret or by
378
+ * another store, not in the shape `create` seals, or sealed longer ago than
371
379
  * {@linkcode EncryptedCookieSessionStoreOptions.maxAgeMs}.
372
380
  */
373
381
  read(cookieValue: string): Promise<SessionData | null>;
@@ -24,7 +24,8 @@
24
24
  * @module
25
25
  */
26
26
  import { InvalidGrantError } from "../../../errors.js";
27
- import { deriveAesKey, sealJson, unsealJson } from "../../../utils/crypto.js";
27
+ import { sealJson, unsealJson } from "../../../utils/crypto.js";
28
+ import { deriveCookieKey, SESSION_COOKIE_KEY_INFO } from "./_cookie-keys.js";
28
29
  /** Whether a store implements the {@link BackchannelLogoutStore} capability. */
29
30
  export function supportsBackchannelLogout(store) {
30
31
  return (typeof store.destroyByLogout ===
@@ -233,6 +234,11 @@ function isSealedSession(value) {
233
234
  * Security notes:
234
235
  * - Each secret must have at least 256 bits of entropy. A weak secret defeats
235
236
  * the confidentiality of the cookie.
237
+ * - The key is derived from the secret with HKDF-SHA-256 under a label of this
238
+ * store's own, so a cookie sealed by another store under the same secret
239
+ * does not open here.
240
+ * - `read` accepts only what `create` sealed: a cookie that is malformed,
241
+ * foreign, or the wrong shape reads as `null`.
236
242
  * - AES-GCM provides authenticated encryption; tampered cookies fail `read`
237
243
  * (returns `null`).
238
244
  * - `destroy` is a no-op on the server — the cookie must be cleared in the
@@ -294,7 +300,7 @@ export class EncryptedCookieSessionStore {
294
300
  return this.#maxAgeMs;
295
301
  }
296
302
  #key(index) {
297
- return (this.#keyPromises[index] ??= deriveAesKey(this.#secrets[index]));
303
+ return (this.#keyPromises[index] ??= deriveCookieKey(this.#secrets[index], SESSION_COOKIE_KEY_INFO));
298
304
  }
299
305
  /** Encrypts the session into the cookie value under the current secret; there is no server-side record. */
300
306
  async create(data) {
@@ -303,8 +309,8 @@ export class EncryptedCookieSessionStore {
303
309
  }
304
310
  /**
305
311
  * Decrypts the cookie value, trying each configured secret in turn. Returns
306
- * `null` if it is tampered, malformed, sealed with an unknown secret, or
307
- * sealed longer ago than
312
+ * `null` if it is tampered, malformed, sealed with an unknown secret or by
313
+ * another store, not in the shape `create` seals, or sealed longer ago than
308
314
  * {@linkcode EncryptedCookieSessionStoreOptions.maxAgeMs}.
309
315
  */
310
316
  async read(cookieValue) {
@@ -312,13 +318,9 @@ export class EncryptedCookieSessionStore {
312
318
  const unsealed = await unsealJson(await this.#key(index), cookieValue);
313
319
  if (unsealed === null)
314
320
  continue;
315
- if (isSealedSession(unsealed)) {
316
- return this.#isExpired(unsealed.issuedAt) ? null : unsealed.data;
317
- }
318
- const legacy = unsealed;
319
- return this.#isExpired(legacy.updatedAt ?? legacy.createdAt)
320
- ? null
321
- : legacy;
321
+ if (!isSealedSession(unsealed))
322
+ return null;
323
+ return this.#isExpired(unsealed.issuedAt) ? null : unsealed.data;
322
324
  }
323
325
  return null;
324
326
  }
@@ -55,12 +55,13 @@ function newSession(userId, signedInFor) {
55
55
  async function createState(config, signingKey, issuer) {
56
56
  const userService = new MemoryUserService();
57
57
  const clientService = new MemoryClientService(userService);
58
+ const authorizationCodeService = new MemoryAuthorizationCodeService({ clientService, userService });
58
59
  const tokenService = new MemoryTokenService({
59
60
  clientService,
60
61
  userService,
61
62
  accessTokenLifetime: config.accessTokenLifetime,
63
+ authorizationCodeService,
62
64
  });
63
- const authorizationCodeService = new MemoryAuthorizationCodeService({ clientService, userService });
64
65
  const grants = {};
65
66
  if (config.grants.authorization_code) {
66
67
  grants.authorization_code = new AuthorizationCodeGrant({
@@ -314,6 +314,35 @@ export interface AuthorizationServerOptions<Client extends ClientInterface, User
314
314
  * contains a space or non-ASCII character.
315
315
  */
316
316
  acrValuesSupported?: readonly string[];
317
+ /**
318
+ * Called when the revocation endpoint revokes the rotation family of a
319
+ * refresh token that rotation already replaced — the client presented a
320
+ * token that is no longer live, so record it alongside your reuse events.
321
+ * It fires once per such request, after `revokeFamily`, only for a token
322
+ * issued to the authenticated client. It never fires `onTokenReuse`: a
323
+ * sign-out racing the client's own refresh presents the same token, so this
324
+ * is not by itself evidence of theft.
325
+ *
326
+ * Trigger point `rotated-out-revocation` (see `docs/trigger-points.md`).
327
+ * Fire-and-forget: it is awaited, but a throw is caught and logged, never
328
+ * rethrown, so a failing hook cannot change the `200` the endpoint answers.
329
+ */
330
+ onRotatedOutRevocation?: (event: RotatedOutRevocationEvent<Client, User>) => void | Promise<void>;
331
+ }
332
+ /**
333
+ * What the revocation endpoint observed when it revoked the rotation family of
334
+ * a refresh token that rotation already replaced. Passed to
335
+ * {@link AuthorizationServerOptions.onRotatedOutRevocation}.
336
+ */
337
+ export interface RotatedOutRevocationEvent<Client extends ClientInterface, User> {
338
+ /** The rotation family the presented token belonged to. */
339
+ familyId: string;
340
+ /** The client the presented token was issued to, which authenticated. */
341
+ client: Client;
342
+ /** The user the presented token was issued for, if any. */
343
+ user?: User;
344
+ /** Whether {@linkcode TokenServiceInterface.revokeFamily} revoked anything. */
345
+ familyRevoked: boolean;
317
346
  }
318
347
  /**
319
348
  * Function to authenticate the user during authorization.
@@ -707,7 +736,8 @@ export declare class AuthorizationServer<Client extends ClientInterface, User, S
707
736
  * revoked other than by rotation, a token with no family, and a service
708
737
  * without `getRevokedRefreshToken` and `revokeFamily` revoke nothing more.
709
738
  * The refresh grant's `replayGraceSeconds` does not apply here, and
710
- * `onTokenReuse` does not fire.
739
+ * `onTokenReuse` does not fire; the server reports the revocation to
740
+ * {@linkcode AuthorizationServerOptions.onRotatedOutRevocation} instead.
711
741
  *
712
742
  * Always returns 200 OK, even if the token was invalid, already revoked, or
713
743
  * issued to another client: RFC 7009 Section 2.2 requires that answer so the
@@ -860,7 +890,7 @@ export { AbstractGrant } from "./grants/grant.ts";
860
890
  export type { DispatchableGrant, GrantOptions, GrantServices, } from "./grants/grant.ts";
861
891
  export { AuthorizationCodeGrant } from "./grants/authorization-code.ts";
862
892
  export type { PKCEClientCredentials } from "./grants/authorization-code.ts";
863
- export type { AuthorizationCodeGrantOptions, AuthorizationCodeGrantServices, GenerateAuthorizationCodeOptions, } from "./grants/authorization-code.ts";
893
+ export type { AuthorizationCodeGrantOptions, AuthorizationCodeGrantServices, CodeReuseEvent, GenerateAuthorizationCodeOptions, } from "./grants/authorization-code.ts";
864
894
  export { ClientCredentialsGrant } from "./grants/client-credentials.ts";
865
895
  export type { ClientCredentialsGrantOptions, ClientCredentialsGrantServices, } from "./grants/client-credentials.ts";
866
896
  export { createJwtAccessTokenGenerator, exportSigningKeyJwk, generateSigningKey, importSigningKeyJwk, RotatingSigningKeyProvider, type RotatingSigningKeyProviderOptions, type SigningKey, type SigningKeyProvider, signJwt, StaticSigningKeyProvider, verifyJwt, type VerifyJwtOptions, } from "./signing-keys.ts";
@@ -200,6 +200,7 @@ export class AuthorizationServer extends ResourceServer {
200
200
  #subjectOf;
201
201
  #userClaims;
202
202
  #introspectionClaims;
203
+ #onRotatedOutRevocation;
203
204
  #canIntrospectToken;
204
205
  #resolve;
205
206
  /**
@@ -254,6 +255,7 @@ export class AuthorizationServer extends ResourceServer {
254
255
  this.#userClaims = options.userClaims;
255
256
  this.#introspectionClaims = options.introspectionClaims;
256
257
  this.#canIntrospectToken = options.canIntrospectToken;
258
+ this.#onRotatedOutRevocation = options.onRotatedOutRevocation;
257
259
  }
258
260
  #handleEndpointError(error, prepare) {
259
261
  const oauth2Error = toOAuth2Error(error);
@@ -1001,7 +1003,8 @@ export class AuthorizationServer extends ResourceServer {
1001
1003
  * revoked other than by rotation, a token with no family, and a service
1002
1004
  * without `getRevokedRefreshToken` and `revokeFamily` revoke nothing more.
1003
1005
  * The refresh grant's `replayGraceSeconds` does not apply here, and
1004
- * `onTokenReuse` does not fire.
1006
+ * `onTokenReuse` does not fire; the server reports the revocation to
1007
+ * {@linkcode AuthorizationServerOptions.onRotatedOutRevocation} instead.
1005
1008
  *
1006
1009
  * Always returns 200 OK, even if the token was invalid, already revoked, or
1007
1010
  * issued to another client: RFC 7009 Section 2.2 requires that answer so the
@@ -1050,7 +1053,23 @@ export class AuthorizationServer extends ResourceServer {
1050
1053
  if (rotatedOut?.rotatedAt &&
1051
1054
  rotatedOut.familyId &&
1052
1055
  rotatedOut.client.id.toString() === client.id.toString()) {
1053
- await tokenService.revokeFamily(rotatedOut.familyId);
1056
+ const familyRevoked = await tokenService.revokeFamily(rotatedOut.familyId);
1057
+ await this.#notifyRotatedOutRevocation({
1058
+ familyId: rotatedOut.familyId,
1059
+ client: rotatedOut.client,
1060
+ user: rotatedOut.user,
1061
+ familyRevoked,
1062
+ });
1063
+ }
1064
+ }
1065
+ async #notifyRotatedOutRevocation(event) {
1066
+ if (!this.#onRotatedOutRevocation)
1067
+ return;
1068
+ try {
1069
+ await this.#onRotatedOutRevocation(event);
1070
+ }
1071
+ catch (error) {
1072
+ console.error("[@udibo/oauth2] onRotatedOutRevocation hook failed:", error instanceof Error ? error.message : error);
1054
1073
  }
1055
1074
  }
1056
1075
  async #resolveToken(tokenService, value, hint) {
@@ -17,6 +17,27 @@ export interface AuthorizationCodeGrantServices<Client extends ClientInterface,
17
17
  /** Generates, stores, and looks up authorization codes for the exchange. */
18
18
  authorizationCodeService: AuthorizationCodeServiceInterface<Client, User, S>;
19
19
  }
20
+ /**
21
+ * What the authorization code grant observed when a code that already issued
22
+ * tokens was presented again. Passed to
23
+ * {@link AuthorizationCodeGrantOptions.onCodeReuse}.
24
+ */
25
+ export interface CodeReuseEvent<Client extends ClientInterface> {
26
+ /**
27
+ * The SHA-256 digest of the replayed code, as lowercase hex — the value a
28
+ * store that hashes codes keys them on, so it correlates without recording
29
+ * the code itself.
30
+ */
31
+ codeHash: string;
32
+ /**
33
+ * The client that presented the code. It is the code's own client only when
34
+ * the token service's `revokeCode` honors its `client` argument. A public
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.
38
+ */
39
+ client: Client;
40
+ }
20
41
  /**
21
42
  * Options for configuring the authorization code grant.
22
43
  *
@@ -64,6 +85,19 @@ export interface AuthorizationCodeGrantOptions<Client extends ClientInterface, U
64
85
  * @see https://datatracker.ietf.org/doc/html/rfc9700#section-2.1.1
65
86
  */
66
87
  requireClientAuthentication?: boolean;
88
+ /**
89
+ * Called when a code that already issued tokens is presented again and the
90
+ * token service's `revokeCode` revoked those tokens — record it as a
91
+ * security event. A `revokeCode` that honors its `client` argument revokes
92
+ * nothing, and so does not fire this, for a client other than the code's
93
+ * own; {@linkcode CodeReuseEvent.client} says who that excludes.
94
+ *
95
+ * Trigger point `code-reuse` (see `docs/trigger-points.md`).
96
+ * Fire-and-forget: it is awaited, but a throw is caught and logged, never
97
+ * rethrown, so a failing security-logging hook cannot change the
98
+ * client-facing `invalid_grant` a replayed code always yields.
99
+ */
100
+ onCodeReuse?: (event: CodeReuseEvent<Client>) => void | Promise<void>;
67
101
  }
68
102
  /** Client credentials extended with optional PKCE code_verifier. */
69
103
  export interface PKCEClientCredentials extends ClientCredentials {
@@ -103,6 +137,7 @@ export interface GenerateAuthorizationCodeOptions<Client extends ClientInterface
103
137
  * @see https://datatracker.ietf.org/doc/html/draft-ietf-oauth-security-topics#section-2.1.1
104
138
  */
105
139
  export declare class AuthorizationCodeGrant<Client extends ClientInterface, User, S extends AbstractScope = BasicScope> extends AbstractGrant<Client, User, S, AuthorizationCodeGrantServices<Client, User, S>> {
140
+ #private;
106
141
  /** The OAuth2 grant type: `authorization_code`. */
107
142
  readonly grantType = "authorization_code";
108
143
  /** The PKCE challenge methods this grant accepts. Defaults to `S256` only. */
@@ -169,11 +204,27 @@ export declare class AuthorizationCodeGrant<Client extends ClientInterface, User
169
204
  * Exchanges an authorization code for an access token. If PKCE was used
170
205
  * during authorization, the code_verifier must be provided.
171
206
  *
172
- * The code is claimed by revoking it before the client-match, verifier, and
173
- * `redirect_uri` checks, so a failed exchange still consumes the code. Only
174
- * the caller whose revoke wins receives a token, given an atomic
175
- * `authorizationCodeService.revoke`. Presenting a code that already minted
176
- * tokens asks `tokenService.revokeCode` to revoke those tokens and fails with
207
+ * A failed exchange still consumes the code: one that fails the
208
+ * client-match, verifier or `redirect_uri` check, whose token cannot be
209
+ * generated, or that `exchangeCode` refuses with an OAuth2 error, is claimed
210
+ * before the error is thrown. Any other error from `exchangeCode`, such as a
211
+ * lost database connection, leaves the code claimable. Only the caller whose
212
+ * claim wins receives a token, given an atomic
213
+ * `authorizationCodeService.revoke`.
214
+ *
215
+ * When the token service implements
216
+ * {@linkcode TokenServiceInterface.exchangeCode}, the grant claims the code
217
+ * and saves the token in that one call. Otherwise it claims the code with
218
+ * `authorizationCodeService.revoke` before its checks and saves the token
219
+ * with `tokenService.save` after them; a replay that lands between those
220
+ * two calls finds no token to revoke, so the token saved afterwards stays
221
+ * live.
222
+ *
223
+ * A code presented after it was claimed, whether it was gone when the
224
+ * request read it or a concurrent exchange won the claim, even one that
225
+ * then fails its checks, asks
226
+ * `tokenService.revokeCode` to revoke the tokens issued from it on behalf of
227
+ * the presenting client, fires `onCodeReuse` when it did, and fails with
177
228
  * `invalid_grant` (RFC 6819 §4.4.1.1).
178
229
  *
179
230
  * @throws {InvalidRequestError} When `code` is missing, a required `code_verifier` is absent, or the verifier is malformed.