@udibo/oauth2 0.17.0 → 0.18.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.
@@ -79,21 +79,33 @@ export interface DirectClientOptions extends BaseOptions {
79
79
  discoveryCache?: DiscoveryCache;
80
80
  /** Access-token storage. Defaults to {@link MemoryTokenStorage}. */
81
81
  tokenStorage?: TokenStorage;
82
- /** Refresh-token storage. Defaults to {@link MemoryRefreshTokenStorage}. */
83
- refreshTokenStorage?: RefreshTokenStorage;
84
82
  /**
85
- * Auth-request storage.
83
+ * Refresh-token storage.
86
84
  *
87
- * Defaults to {@link SessionStorageAuthRequestStorage} in a browser document
88
- * and {@link MemoryAuthRequestStorage} everywhere else. The browser default
89
- * is what makes the authorize redirect work at all: an in-memory record does
90
- * not survive the navigation away and back, so the callback rejects every
91
- * `state` as unknown. Servers keep the in-memory default so a per-request
92
- * client does not write pending logins into one process-wide store.
85
+ * In a browser document it defaults to {@link MemoryRefreshTokenStorage},
86
+ * which the tab loses on reload; pass `IndexedDBRefreshTokenStorage` to keep
87
+ * it. Outside a browser there is no default: a sign-in or device
88
+ * authorization that would store a refresh token throws a `TypeError` until
89
+ * one is passed. Pass storage that outlives the process, such as a database
90
+ * or the operating system's keychain, or {@link MemoryRefreshTokenStorage}
91
+ * for a single process, tests and local development. Client-credentials
92
+ * use needs none.
93
+ */
94
+ refreshTokenStorage?: RefreshTokenStorage;
95
+ /**
96
+ * Where a pending sign-in (`state` → PKCE verifier, return-to) waits for
97
+ * its callback.
93
98
  *
94
- * Override it when the record must live somewhere else — a per-request
95
- * cookie store on a server, or a store scoped to something other than the
96
- * tab.
99
+ * In a browser document it defaults to
100
+ * {@link SessionStorageAuthRequestStorage}, which survives the navigation
101
+ * to the authorization server and back. Outside a browser there is no
102
+ * default: {@link DirectClient.login} and
103
+ * {@link DirectClient.exchangeAuthorizationCode} throw a `TypeError` unless
104
+ * this or their per-call `authRequestStorage` is given, because the
105
+ * callback can reach a different instance than the one that started the
106
+ * sign-in. Pass storage every instance shares, a per-request store on each
107
+ * call, or {@link MemoryAuthRequestStorage} for a single process, tests and
108
+ * local development. Client-credentials use needs none.
97
109
  */
98
110
  authRequestStorage?: AuthRequestStorage;
99
111
  /**
@@ -362,6 +374,8 @@ export declare class DirectClient extends OAuth2ClientBase {
362
374
  * authorize URL, override `redirect_uri`, or bind the pending request to a
363
375
  * per-request store.
364
376
  * @returns The authorize URL and the `state` the callback will echo back.
377
+ * @throws {TypeError} outside a browser when neither the client nor this
378
+ * call has an `authRequestStorage`.
365
379
  * @throws {Error} when no authorization endpoint is configured or
366
380
  * discoverable.
367
381
  *
@@ -395,7 +409,9 @@ export declare class DirectClient extends OAuth2ClientBase {
395
409
  * `cause` is the transport failure.
396
410
  * @throws {OAuth2Error} when the callback carries `?error=…`, or the token
397
411
  * exchange fails.
398
- * @throws {TypeError} when `code` or `state` is missing.
412
+ * @throws {TypeError} when `code` or `state` is missing, or outside a
413
+ * browser when the client has no `authRequestStorage` or no
414
+ * `refreshTokenStorage`; checked before the pending request is claimed.
399
415
  * @throws {InvalidGrantError} when `state` is unknown or expired.
400
416
  *
401
417
  * @example
@@ -436,6 +452,8 @@ export declare class DirectClient extends OAuth2ClientBase {
436
452
  * which must match what {@link login} was given.
437
453
  * @returns The tokens, the rotated refresh token, and the recorded
438
454
  * `returnTo`, plus the raw response.
455
+ * @throws {TypeError} outside a browser when neither the client nor this
456
+ * call has an `authRequestStorage`.
439
457
  * @throws {InvalidGrantError} when `state` is unknown or older than
440
458
  * `authRequestTtlMs`.
441
459
  * @throws {TemporarilyUnavailableError} when the token endpoint cannot be
@@ -530,6 +548,8 @@ export declare class DirectClient extends OAuth2ClientBase {
530
548
  *
531
549
  * @param options `scope` overrides the client's default scope string.
532
550
  * @returns The server's device authorization response.
551
+ * @throws {TypeError} outside a browser when the client has no
552
+ * `refreshTokenStorage` for the tokens {@link pollDeviceToken} will keep.
533
553
  * @throws {Error} when no device authorization endpoint is configured or
534
554
  * discoverable.
535
555
  * @throws {TemporarilyUnavailableError} when the device authorization
@@ -563,6 +583,8 @@ export declare class DirectClient extends OAuth2ClientBase {
563
583
  * authorization response so polling respects the server's advertised
564
584
  * minimum interval and deadline, and `signal` to cancel.
565
585
  * @returns The persisted token bundle.
586
+ * @throws {TypeError} outside a browser when the client has no
587
+ * `refreshTokenStorage`.
566
588
  * @throws {AccessDeniedError} when `signal` aborts, or the user declines.
567
589
  * @throws {InvalidGrantError} when `expiresAt` passes before approval. A
568
590
  * server that reports the code expired first throws `ExpiredTokenError`
@@ -120,11 +120,11 @@ export class DirectClient extends OAuth2ClientBase {
120
120
  this.#endpoints = { ...options.endpoints };
121
121
  this.#tokenStorage = options.tokenStorage ?? new MemoryTokenStorage();
122
122
  this.#refreshTokenStorage =
123
- options.refreshTokenStorage ?? new MemoryRefreshTokenStorage();
123
+ options.refreshTokenStorage ?? browserRefreshTokenStorage();
124
124
  this.#authRequestTtlMs = options.authRequestTtlMs ?? 10 * 60 * 1000;
125
125
  this.#authRequestStorage =
126
126
  options.authRequestStorage ??
127
- defaultAuthRequestStorage(this.#authRequestTtlMs);
127
+ browserAuthRequestStorage(this.#authRequestTtlMs);
128
128
  }
129
129
  /** True when the client was given a secret and authenticates via Basic. */
130
130
  get isConfidential() {
@@ -322,6 +322,8 @@ export class DirectClient extends OAuth2ClientBase {
322
322
  * authorize URL, override `redirect_uri`, or bind the pending request to a
323
323
  * per-request store.
324
324
  * @returns The authorize URL and the `state` the callback will echo back.
325
+ * @throws {TypeError} outside a browser when neither the client nor this
326
+ * call has an `authRequestStorage`.
325
327
  * @throws {Error} when no authorization endpoint is configured or
326
328
  * discoverable.
327
329
  *
@@ -338,6 +340,7 @@ export class DirectClient extends OAuth2ClientBase {
338
340
  * ```
339
341
  */
340
342
  async login(options = {}) {
343
+ const authRequestStorage = options.authRequestStorage ?? this.#requireAuthRequestStorage();
341
344
  const baseAuthEndpoint = await this.#requireEndpoint("authorization");
342
345
  const authEndpoint = options.origin
343
346
  ? withOrigin(baseAuthEndpoint, options.origin)
@@ -347,7 +350,6 @@ export class DirectClient extends OAuth2ClientBase {
347
350
  const codeVerifier = generateCodeVerifier();
348
351
  const codeChallenge = await challengeMethods.S256(codeVerifier);
349
352
  const scope = options.scope ?? this.#scope;
350
- const authRequestStorage = options.authRequestStorage ?? this.#authRequestStorage;
351
353
  await authRequestStorage.set(state, {
352
354
  codeVerifier,
353
355
  returnTo: options.returnTo,
@@ -388,7 +390,9 @@ export class DirectClient extends OAuth2ClientBase {
388
390
  * `cause` is the transport failure.
389
391
  * @throws {OAuth2Error} when the callback carries `?error=…`, or the token
390
392
  * exchange fails.
391
- * @throws {TypeError} when `code` or `state` is missing.
393
+ * @throws {TypeError} when `code` or `state` is missing, or outside a
394
+ * browser when the client has no `authRequestStorage` or no
395
+ * `refreshTokenStorage`; checked before the pending request is claimed.
392
396
  * @throws {InvalidGrantError} when `state` is unknown or expired.
393
397
  *
394
398
  * @example
@@ -418,6 +422,13 @@ export class DirectClient extends OAuth2ClientBase {
418
422
  this.emit({ type: "error", error: instance });
419
423
  return Promise.reject(instance);
420
424
  }
425
+ try {
426
+ this.#requireAuthRequestStorage();
427
+ this.#requireRefreshTokenStorage();
428
+ }
429
+ catch (error) {
430
+ return Promise.reject(error);
431
+ }
421
432
  const code = params.get("code");
422
433
  const state = params.get("state");
423
434
  if (!code)
@@ -467,6 +478,8 @@ export class DirectClient extends OAuth2ClientBase {
467
478
  * which must match what {@link login} was given.
468
479
  * @returns The tokens, the rotated refresh token, and the recorded
469
480
  * `returnTo`, plus the raw response.
481
+ * @throws {TypeError} outside a browser when neither the client nor this
482
+ * call has an `authRequestStorage`.
470
483
  * @throws {InvalidGrantError} when `state` is unknown or older than
471
484
  * `authRequestTtlMs`.
472
485
  * @throws {TemporarilyUnavailableError} when the token endpoint cannot be
@@ -474,7 +487,7 @@ export class DirectClient extends OAuth2ClientBase {
474
487
  * `cause` is the transport failure.
475
488
  */
476
489
  async exchangeAuthorizationCode(code, state, options = {}) {
477
- const authRequestStorage = options.authRequestStorage ?? this.#authRequestStorage;
490
+ const authRequestStorage = options.authRequestStorage ?? this.#requireAuthRequestStorage();
478
491
  const record = await claimAuthRequest(authRequestStorage, state);
479
492
  if (!record)
480
493
  throw new InvalidGrantError("unknown state parameter");
@@ -656,7 +669,7 @@ export class DirectClient extends OAuth2ClientBase {
656
669
  }
657
670
  async #readRefreshToken(reportTransient) {
658
671
  try {
659
- return await this.#refreshTokenStorage.get();
672
+ return (await this.#refreshTokenStorage?.get()) ?? null;
660
673
  }
661
674
  catch (error) {
662
675
  if (reportTransient)
@@ -671,6 +684,8 @@ export class DirectClient extends OAuth2ClientBase {
671
684
  *
672
685
  * @param options `scope` overrides the client's default scope string.
673
686
  * @returns The server's device authorization response.
687
+ * @throws {TypeError} outside a browser when the client has no
688
+ * `refreshTokenStorage` for the tokens {@link pollDeviceToken} will keep.
674
689
  * @throws {Error} when no device authorization endpoint is configured or
675
690
  * discoverable.
676
691
  * @throws {TemporarilyUnavailableError} when the device authorization
@@ -692,6 +707,7 @@ export class DirectClient extends OAuth2ClientBase {
692
707
  * ```
693
708
  */
694
709
  async startDeviceAuthorization(options = {}) {
710
+ this.#requireRefreshTokenStorage();
695
711
  const endpoint = await this.#requireEndpoint("deviceAuthorization");
696
712
  const body = new URLSearchParams({ client_id: this.#clientId });
697
713
  const scope = options.scope ?? this.#scope;
@@ -717,6 +733,8 @@ export class DirectClient extends OAuth2ClientBase {
717
733
  * authorization response so polling respects the server's advertised
718
734
  * minimum interval and deadline, and `signal` to cancel.
719
735
  * @returns The persisted token bundle.
736
+ * @throws {TypeError} outside a browser when the client has no
737
+ * `refreshTokenStorage`.
720
738
  * @throws {AccessDeniedError} when `signal` aborts, or the user declines.
721
739
  * @throws {InvalidGrantError} when `expiresAt` passes before approval. A
722
740
  * server that reports the code expired first throws `ExpiredTokenError`
@@ -727,6 +745,7 @@ export class DirectClient extends OAuth2ClientBase {
727
745
  * @throws {OAuth2Error} on any other terminal error from the server.
728
746
  */
729
747
  async pollDeviceToken(deviceCode, options = {}) {
748
+ this.#requireRefreshTokenStorage();
730
749
  const version = ++this.#sessionVersion;
731
750
  let interval = Math.max(1, options.interval ?? 5);
732
751
  const deadline = options.expiresAt ?? Infinity;
@@ -1038,7 +1057,7 @@ export class DirectClient extends OAuth2ClientBase {
1038
1057
  ? Math.round((tokens.accessTokenExpiresAt - Date.now()) / 1000)
1039
1058
  : null;
1040
1059
  if (expiresIn !== null && expiresIn <= 0) {
1041
- const revivable = await this.#refreshTokenStorage.get();
1060
+ const revivable = await this.#refreshTokenStorage?.get();
1042
1061
  if (!revivable)
1043
1062
  return SIGNED_OUT;
1044
1063
  }
@@ -1116,6 +1135,18 @@ export class DirectClient extends OAuth2ClientBase {
1116
1135
  }
1117
1136
  return await this.#sharedRefresh(!quiet);
1118
1137
  }
1138
+ #requireAuthRequestStorage() {
1139
+ if (!this.#authRequestStorage) {
1140
+ throw new TypeError(MISSING_AUTH_REQUEST_STORAGE);
1141
+ }
1142
+ return this.#authRequestStorage;
1143
+ }
1144
+ #requireRefreshTokenStorage() {
1145
+ if (!this.#refreshTokenStorage) {
1146
+ throw new TypeError(MISSING_REFRESH_TOKEN_STORAGE);
1147
+ }
1148
+ return this.#refreshTokenStorage;
1149
+ }
1119
1150
  #basicAuth() {
1120
1151
  return encodeBasicAuth(this.#clientId, this.#clientSecret);
1121
1152
  }
@@ -1154,10 +1185,10 @@ export class DirectClient extends OAuth2ClientBase {
1154
1185
  const bundle = responseToBundle(response);
1155
1186
  await this.#tokenStorage.set(bundle);
1156
1187
  if (response.refresh_token) {
1157
- await this.#refreshTokenStorage.set(response.refresh_token);
1188
+ await this.#refreshTokenStorage?.set(response.refresh_token);
1158
1189
  }
1159
1190
  else if (!preserveRefreshToken) {
1160
- await this.#refreshTokenStorage.clear();
1191
+ await this.#refreshTokenStorage?.clear();
1161
1192
  }
1162
1193
  if (version !== this.#sessionVersion) {
1163
1194
  throw new InvalidGrantError("session changed during token exchange");
@@ -1177,8 +1208,8 @@ export class DirectClient extends OAuth2ClientBase {
1177
1208
  this.#sessionVersion++;
1178
1209
  return this.#mutateSession(async () => {
1179
1210
  await this.#tokenStorage.clear();
1180
- await this.#refreshTokenStorage.clear();
1181
- await this.#authRequestStorage.clear();
1211
+ await this.#refreshTokenStorage?.clear();
1212
+ await this.#authRequestStorage?.clear();
1182
1213
  });
1183
1214
  }
1184
1215
  }
@@ -1230,12 +1261,31 @@ function toSearchParams(input) {
1230
1261
  return new URLSearchParams(input.startsWith("?") ? input.slice(1) : input);
1231
1262
  }
1232
1263
  }
1233
- function defaultAuthRequestStorage(ttlMs) {
1234
- const inBrowserDocument = typeof document !== "undefined" && typeof sessionStorage !== "undefined";
1235
- return inBrowserDocument
1264
+ const MISSING_AUTH_REQUEST_STORAGE = "DirectClient requires options.authRequestStorage outside a browser to " +
1265
+ "start or complete a sign-in. Pass an AuthRequestStorage over storage " +
1266
+ "every instance shares, pass one to login() and " +
1267
+ "exchangeAuthorizationCode() for each call, or pass " +
1268
+ "MemoryAuthRequestStorage for a single process, tests and local " +
1269
+ "development.";
1270
+ const MISSING_REFRESH_TOKEN_STORAGE = "DirectClient requires options.refreshTokenStorage outside a browser to " +
1271
+ "keep the refresh token a sign-in returns. Pass a RefreshTokenStorage " +
1272
+ "over storage that outlives the process, such as a database or the " +
1273
+ "operating system's keychain, or MemoryRefreshTokenStorage for a single " +
1274
+ "process, tests and local development. Client-credentials use needs " +
1275
+ "neither.";
1276
+ function inBrowserDocument() {
1277
+ return typeof document !== "undefined";
1278
+ }
1279
+ function browserAuthRequestStorage(ttlMs) {
1280
+ if (!inBrowserDocument())
1281
+ return undefined;
1282
+ return typeof sessionStorage !== "undefined"
1236
1283
  ? new SessionStorageAuthRequestStorage({ ttlMs })
1237
1284
  : new MemoryAuthRequestStorage({ ttlMs });
1238
1285
  }
1286
+ function browserRefreshTokenStorage() {
1287
+ return inBrowserDocument() ? new MemoryRefreshTokenStorage() : undefined;
1288
+ }
1239
1289
  async function claimAuthRequest(storage, state) {
1240
1290
  if (storage.take)
1241
1291
  return await storage.take(state);
@@ -15,11 +15,13 @@
15
15
  * care — the React adapter's `<OAuth2Provider>`, a shared UI helper — should
16
16
  * accept.
17
17
  *
18
- * A {@link DirectClient} keeps its token bundle and refresh token in memory
19
- * by default, so they are lost on reload; a browser app that must survive one
20
- * passes {@link IndexedDBRefreshTokenStorage}. The pending auth request
21
- * defaults to {@link SessionStorageAuthRequestStorage} in a browser document
22
- * and to memory everywhere else.
18
+ * In a browser document a {@link DirectClient} keeps its token bundle and
19
+ * refresh token in memory by default, so they are lost on reload; a browser
20
+ * app that must survive one passes {@link IndexedDBRefreshTokenStorage}. The
21
+ * pending auth request defaults to {@link SessionStorageAuthRequestStorage}
22
+ * there. Outside a browser, a sign-in or device flow requires
23
+ * `authRequestStorage` and `refreshTokenStorage` and throws a `TypeError`
24
+ * without them; client credentials need neither.
23
25
  *
24
26
  * @module
25
27
  */
@@ -15,11 +15,13 @@
15
15
  * care — the React adapter's `<OAuth2Provider>`, a shared UI helper — should
16
16
  * accept.
17
17
  *
18
- * A {@link DirectClient} keeps its token bundle and refresh token in memory
19
- * by default, so they are lost on reload; a browser app that must survive one
20
- * passes {@link IndexedDBRefreshTokenStorage}. The pending auth request
21
- * defaults to {@link SessionStorageAuthRequestStorage} in a browser document
22
- * and to memory everywhere else.
18
+ * In a browser document a {@link DirectClient} keeps its token bundle and
19
+ * refresh token in memory by default, so they are lost on reload; a browser
20
+ * app that must survive one passes {@link IndexedDBRefreshTokenStorage}. The
21
+ * pending auth request defaults to {@link SessionStorageAuthRequestStorage}
22
+ * there. Outside a browser, a sign-in or device flow requires
23
+ * `authRequestStorage` and `refreshTokenStorage` and throws a `TypeError`
24
+ * without them; client credentials need neither.
23
25
  *
24
26
  * @module
25
27
  */
@@ -10,11 +10,14 @@
10
10
  * used to correlate an authorize redirect with its callback.
11
11
  *
12
12
  * The implementations here are in-memory and live only for the process / tab
13
- * lifetime. `DirectClient` uses them by default, except that in a browser
14
- * document its auth-request storage defaults to `sessionStorage`. A browser
15
- * app that must stay signed in across reloads passes
16
- * `IndexedDBRefreshTokenStorage` for the refresh token and keeps the access
17
- * token in memory.
13
+ * lifetime. In a browser document `DirectClient` keeps the token bundle and
14
+ * refresh token in them by default and its auth-request map in
15
+ * `sessionStorage`. A browser app that must stay signed in across reloads
16
+ * passes `IndexedDBRefreshTokenStorage` for the refresh token and keeps the
17
+ * access token in memory. Outside a browser only the token bundle has a
18
+ * default: a sign-in requires the auth-request and refresh-token storage to be
19
+ * passed, these memory ones only for a single process, tests and local
20
+ * development.
18
21
  *
19
22
  * @module
20
23
  */
@@ -116,8 +119,9 @@ export declare class MemoryTokenStorage implements TokenStorage {
116
119
  clear(): void;
117
120
  }
118
121
  /**
119
- * Default in-memory {@link RefreshTokenStorage}. Data is lost when the
120
- * process exits.
122
+ * In-memory {@link RefreshTokenStorage}: `DirectClient`'s default in a browser
123
+ * document, and an explicit choice elsewhere for a single process, tests and
124
+ * local development. Data is lost when the process exits.
121
125
  */
122
126
  export declare class MemoryRefreshTokenStorage implements RefreshTokenStorage {
123
127
  #private;
@@ -145,8 +149,9 @@ export interface MemoryAuthRequestStorageOptions {
145
149
  maxEntries?: number;
146
150
  }
147
151
  /**
148
- * In-memory {@link AuthRequestStorage}, `DirectClient`'s default outside a
149
- * browser document. Data is lost when the process exits. Every write prunes
152
+ * In-memory {@link AuthRequestStorage} for a single process, tests and local
153
+ * development; `DirectClient` falls back to it only in a browser document
154
+ * without `sessionStorage`. Data is lost when the process exits. Every write prunes
150
155
  * records older than {@link MemoryAuthRequestStorageOptions.ttlMs}, then
151
156
  * evicts the oldest-written records beyond
152
157
  * {@link MemoryAuthRequestStorageOptions.maxEntries}, so neither logins that
@@ -10,11 +10,14 @@
10
10
  * used to correlate an authorize redirect with its callback.
11
11
  *
12
12
  * The implementations here are in-memory and live only for the process / tab
13
- * lifetime. `DirectClient` uses them by default, except that in a browser
14
- * document its auth-request storage defaults to `sessionStorage`. A browser
15
- * app that must stay signed in across reloads passes
16
- * `IndexedDBRefreshTokenStorage` for the refresh token and keeps the access
17
- * token in memory.
13
+ * lifetime. In a browser document `DirectClient` keeps the token bundle and
14
+ * refresh token in them by default and its auth-request map in
15
+ * `sessionStorage`. A browser app that must stay signed in across reloads
16
+ * passes `IndexedDBRefreshTokenStorage` for the refresh token and keeps the
17
+ * access token in memory. Outside a browser only the token bundle has a
18
+ * default: a sign-in requires the auth-request and refresh-token storage to be
19
+ * passed, these memory ones only for a single process, tests and local
20
+ * development.
18
21
  *
19
22
  * @module
20
23
  */
@@ -35,8 +38,9 @@ export class MemoryTokenStorage {
35
38
  }
36
39
  }
37
40
  /**
38
- * Default in-memory {@link RefreshTokenStorage}. Data is lost when the
39
- * process exits.
41
+ * In-memory {@link RefreshTokenStorage}: `DirectClient`'s default in a browser
42
+ * document, and an explicit choice elsewhere for a single process, tests and
43
+ * local development. Data is lost when the process exits.
40
44
  */
41
45
  export class MemoryRefreshTokenStorage {
42
46
  #token = null;
@@ -55,8 +59,9 @@ export class MemoryRefreshTokenStorage {
55
59
  }
56
60
  const DEFAULT_MAX_AUTH_REQUESTS = 1000;
57
61
  /**
58
- * In-memory {@link AuthRequestStorage}, `DirectClient`'s default outside a
59
- * browser document. Data is lost when the process exits. Every write prunes
62
+ * In-memory {@link AuthRequestStorage} for a single process, tests and local
63
+ * development; `DirectClient` falls back to it only in a browser document
64
+ * without `sessionStorage`. Data is lost when the process exits. Every write prunes
60
65
  * records older than {@link MemoryAuthRequestStorageOptions.ttlMs}, then
61
66
  * evicts the oldest-written records beyond
62
67
  * {@link MemoryAuthRequestStorageOptions.maxEntries}, so neither logins that
@@ -4,10 +4,11 @@
4
4
  * Distinct from rate limiting: the {@link RateLimiter} throttles *attempts per
5
5
  * window* keyed however you like (identifier, IP), while lockout tracks
6
6
  * *consecutive failures per account* and locks the account for a cooldown once
7
- * a threshold is crossed. Storage is a pluggable {@link LockoutStore}
8
- * (a {@link MemoryLockoutStore} ships for dev/single-process; back it with your
9
- * database to share state across instances — e.g. columns on your users table,
10
- * which also gives admins visibility).
7
+ * a threshold is crossed. Storage is a pluggable {@link LockoutStore} with no
8
+ * default: back it with storage every instance shares — e.g. columns on your
9
+ * users table, which also gives admins visibility — or pass the bounded
10
+ * {@link MemoryLockoutStore} for a single process, tests and local
11
+ * development.
11
12
  *
12
13
  * `AccountLockout` only records state: it never decides whether to block.
13
14
  * `IdentityService` consults it and applies its `protectionMode`
@@ -25,8 +26,12 @@ export interface LockoutRecord {
25
26
  }
26
27
  /** Pluggable per-account state store for {@link AccountLockout}. */
27
28
  export interface LockoutStore {
28
- /** The account's current record, or `undefined` when it has none. */
29
- get(userId: string): Promise<LockoutRecord | undefined>;
29
+ /**
30
+ * The account's current record, or `undefined` when it has none.
31
+ * `AccountLockout` passes the clock it was given as `now`, for a store that
32
+ * expires records; a store that keeps them until cleared can ignore it.
33
+ */
34
+ get(userId: string, now?: number): Promise<LockoutRecord | undefined>;
30
35
  /**
31
36
  * Add one failure and return the new record: an expired lock resets the
32
37
  * count to 1 and drops the lock; otherwise `failures + 1` with any active
@@ -49,8 +54,18 @@ export interface AccountLockoutOptions {
49
54
  maxAttempts?: number;
50
55
  /** How long a lock lasts, in ms. Defaults to 15 minutes. */
51
56
  lockDurationMs?: number;
52
- /** State storage. Defaults to {@link MemoryLockoutStore}. */
53
- store?: LockoutStore;
57
+ /**
58
+ * Where failure counts and locks live. Required, because a lockout only
59
+ * holds if every instance that serves sign-in reads the same records:
60
+ *
61
+ * - A {@link LockoutStore} over storage every instance shares, such as
62
+ * columns on your users table. Its `increment` must be atomic.
63
+ * - {@link MemoryLockoutStore} for a single process, tests and local
64
+ * development. Each instance counts its own failures, so behind a load
65
+ * balancer an attacker gets `maxAttempts` guesses per instance and a lock
66
+ * on one instance does not stop sign-in on another.
67
+ */
68
+ store: LockoutStore;
54
69
  }
55
70
  /** The outcome of {@link AccountLockout.recordFailure}. */
56
71
  export interface LockoutFailureResult {
@@ -123,15 +138,20 @@ export interface AccountLockoutLike {
123
138
  }
124
139
  /**
125
140
  * Consecutive-failure lockout with safe defaults (10 failures → 15-minute
126
- * lock) — the default {@link AccountLockoutLike}. Wire into
141
+ * lock) — the shipped {@link AccountLockoutLike}. Wire into
127
142
  * {@link IdentityService} via its `lockout` option, which resets it on a
128
143
  * successful sign-in, password reset, or {@link IdentityService.unlockAccount};
129
144
  * call {@link reset} yourself only from an unlock flow of your own.
130
145
  */
131
146
  export declare class AccountLockout implements AccountLockoutLike {
132
147
  #private;
133
- /** Fixes thresholds and the backing store (defaults to {@linkcode MemoryLockoutStore}). */
134
- constructor(options?: AccountLockoutOptions);
148
+ /**
149
+ * Fixes the thresholds and the backing store.
150
+ *
151
+ * @throws {TypeError} When {@linkcode AccountLockoutOptions.store} is
152
+ * missing.
153
+ */
154
+ constructor(options: AccountLockoutOptions);
135
155
  /** The account's current lock state. An expired lock reads as not locked. */
136
156
  status(userId: string, now?: number): Promise<LockoutStatus>;
137
157
  /**
@@ -142,14 +162,64 @@ export declare class AccountLockout implements AccountLockoutLike {
142
162
  /** Clear the account's failures and any lock (successful sign-in, unlock). */
143
163
  reset(userId: string): Promise<void>;
144
164
  }
145
- /** In-memory {@link LockoutStore} for dev / single-process use. */
165
+ /** Options for {@link MemoryLockoutStore}. */
166
+ export interface MemoryLockoutStoreOptions {
167
+ /**
168
+ * How long a record is kept after its last failure, in ms. A record past it
169
+ * reads as absent, so the next failure starts a fresh count, but a record is
170
+ * never dropped while its lock is active. Defaults to 24 hours; `Infinity`
171
+ * keeps records until they are cleared or evicted.
172
+ */
173
+ ttlMs?: number;
174
+ /**
175
+ * Most records held at once. Defaults to 10,000; `Infinity` never evicts by
176
+ * count.
177
+ */
178
+ maxEntries?: number;
179
+ }
180
+ /**
181
+ * In-memory {@link LockoutStore} for a single process, tests and local
182
+ * development. Records live in the process that wrote them, so instances do
183
+ * not share failures or locks; pass a store over shared storage when more
184
+ * than one instance serves sign-in.
185
+ *
186
+ * Bounded so it cannot grow without limit: a record expires
187
+ * {@link MemoryLockoutStoreOptions.ttlMs} after its last failure unless its
188
+ * lock is still active, and a write beyond
189
+ * {@link MemoryLockoutStoreOptions.maxEntries} first drops expired records,
190
+ * then evicts one: unlocked records before locked ones, the fewest failures
191
+ * first, then the longest idle; among locked records, the lock that ends
192
+ * soonest. The residual is real: failures against more than `maxEntries`
193
+ * other accounts that each end locked can evict an active lock.
194
+ */
146
195
  export declare class MemoryLockoutStore implements LockoutStore {
147
196
  #private;
148
- /** The stored record for `userId`, if any. */
149
- get(userId: string): Promise<LockoutRecord | undefined>;
150
- /** Add one failure synchronously (atomic in a single-process store). */
197
+ /**
198
+ * Configures the TTL (24 hours by default) and the record cap (10,000 by
199
+ * default).
200
+ *
201
+ * @throws {RangeError} When `ttlMs` is not a positive number or `Infinity`,
202
+ * or `maxEntries` is neither a positive integer nor `Infinity`.
203
+ */
204
+ constructor(options?: MemoryLockoutStoreOptions);
205
+ /**
206
+ * How many records are currently retained — never more than `maxEntries`.
207
+ * Exposed so a test or a dev-time probe can see the bound working; it is not
208
+ * part of the {@link LockoutStore} contract.
209
+ */
210
+ get size(): number;
211
+ /** The stored record for `userId`, or `undefined` once it has expired. */
212
+ get(userId: string, now?: number): Promise<LockoutRecord | undefined>;
213
+ /**
214
+ * Add one failure synchronously (atomic in a single-process store). May
215
+ * drop expired records and evict another account's record to stay under
216
+ * `maxEntries`.
217
+ */
151
218
  increment(userId: string, now: number): Promise<LockoutRecord>;
152
- /** Store `record` for `userId`. */
219
+ /**
220
+ * Store `record` for `userId`, keeping the time of its last failure; a
221
+ * record for an account with none is timed from now.
222
+ */
153
223
  set(userId: string, record: LockoutRecord): Promise<void>;
154
224
  /** Drop `userId`'s record. */
155
225
  clear(userId: string): Promise<void>;
@@ -4,10 +4,11 @@
4
4
  * Distinct from rate limiting: the {@link RateLimiter} throttles *attempts per
5
5
  * window* keyed however you like (identifier, IP), while lockout tracks
6
6
  * *consecutive failures per account* and locks the account for a cooldown once
7
- * a threshold is crossed. Storage is a pluggable {@link LockoutStore}
8
- * (a {@link MemoryLockoutStore} ships for dev/single-process; back it with your
9
- * database to share state across instances — e.g. columns on your users table,
10
- * which also gives admins visibility).
7
+ * a threshold is crossed. Storage is a pluggable {@link LockoutStore} with no
8
+ * default: back it with storage every instance shares — e.g. columns on your
9
+ * users table, which also gives admins visibility — or pass the bounded
10
+ * {@link MemoryLockoutStore} for a single process, tests and local
11
+ * development.
11
12
  *
12
13
  * `AccountLockout` only records state: it never decides whether to block.
13
14
  * `IdentityService` consults it and applies its `protectionMode`
@@ -18,9 +19,12 @@
18
19
  */
19
20
  const DEFAULT_MAX_ATTEMPTS = 10;
20
21
  const DEFAULT_LOCK_DURATION_MS = 15 * 60 * 1000;
22
+ const MISSING_LOCKOUT_STORE = "AccountLockout requires options.store. Pass a LockoutStore over storage " +
23
+ "every instance shares, such as columns on your users table, or " +
24
+ "MemoryLockoutStore for a single process, tests and local development.";
21
25
  /**
22
26
  * Consecutive-failure lockout with safe defaults (10 failures → 15-minute
23
- * lock) — the default {@link AccountLockoutLike}. Wire into
27
+ * lock) — the shipped {@link AccountLockoutLike}. Wire into
24
28
  * {@link IdentityService} via its `lockout` option, which resets it on a
25
29
  * successful sign-in, password reset, or {@link IdentityService.unlockAccount};
26
30
  * call {@link reset} yourself only from an unlock flow of your own.
@@ -29,15 +33,22 @@ export class AccountLockout {
29
33
  #maxAttempts;
30
34
  #lockDurationMs;
31
35
  #store;
32
- /** Fixes thresholds and the backing store (defaults to {@linkcode MemoryLockoutStore}). */
33
- constructor(options = {}) {
36
+ /**
37
+ * Fixes the thresholds and the backing store.
38
+ *
39
+ * @throws {TypeError} When {@linkcode AccountLockoutOptions.store} is
40
+ * missing.
41
+ */
42
+ constructor(options) {
43
+ if (!options?.store)
44
+ throw new TypeError(MISSING_LOCKOUT_STORE);
34
45
  this.#maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
35
46
  this.#lockDurationMs = options.lockDurationMs ?? DEFAULT_LOCK_DURATION_MS;
36
- this.#store = options.store ?? new MemoryLockoutStore();
47
+ this.#store = options.store;
37
48
  }
38
49
  /** The account's current lock state. An expired lock reads as not locked. */
39
50
  async status(userId, now = Date.now()) {
40
- const record = await this.#store.get(userId);
51
+ const record = await this.#store.get(userId, now);
41
52
  if (!record)
42
53
  return { locked: false, failures: 0 };
43
54
  const locked = record.lockedUntil !== undefined && record.lockedUntil > now;
@@ -82,16 +93,74 @@ export class AccountLockout {
82
93
  return this.#store.clear(userId);
83
94
  }
84
95
  }
85
- /** In-memory {@link LockoutStore} for dev / single-process use. */
96
+ const DEFAULT_LOCKOUT_TTL_MS = 24 * 60 * 60 * 1000;
97
+ const DEFAULT_MAX_LOCKOUT_RECORDS = 10_000;
98
+ const LOCKOUT_SWEEP_INTERVAL = 1_000;
99
+ /**
100
+ * In-memory {@link LockoutStore} for a single process, tests and local
101
+ * development. Records live in the process that wrote them, so instances do
102
+ * not share failures or locks; pass a store over shared storage when more
103
+ * than one instance serves sign-in.
104
+ *
105
+ * Bounded so it cannot grow without limit: a record expires
106
+ * {@link MemoryLockoutStoreOptions.ttlMs} after its last failure unless its
107
+ * lock is still active, and a write beyond
108
+ * {@link MemoryLockoutStoreOptions.maxEntries} first drops expired records,
109
+ * then evicts one: unlocked records before locked ones, the fewest failures
110
+ * first, then the longest idle; among locked records, the lock that ends
111
+ * soonest. The residual is real: failures against more than `maxEntries`
112
+ * other accounts that each end locked can evict an active lock.
113
+ */
86
114
  export class MemoryLockoutStore {
87
- #records = new Map();
88
- /** The stored record for `userId`, if any. */
89
- get(userId) {
90
- return Promise.resolve(this.#records.get(userId));
115
+ #entries = new Map();
116
+ #ttlMs;
117
+ #maxEntries;
118
+ #writesSinceSweep = 0;
119
+ /**
120
+ * Configures the TTL (24 hours by default) and the record cap (10,000 by
121
+ * default).
122
+ *
123
+ * @throws {RangeError} When `ttlMs` is not a positive number or `Infinity`,
124
+ * or `maxEntries` is neither a positive integer nor `Infinity`.
125
+ */
126
+ constructor(options = {}) {
127
+ this.#ttlMs = options.ttlMs ?? DEFAULT_LOCKOUT_TTL_MS;
128
+ this.#maxEntries = options.maxEntries ?? DEFAULT_MAX_LOCKOUT_RECORDS;
129
+ if (!(this.#ttlMs > 0)) {
130
+ throw new RangeError("MemoryLockoutStore ttlMs must be a positive number or Infinity, " +
131
+ `got ${this.#ttlMs}.`);
132
+ }
133
+ if (this.#maxEntries !== Infinity &&
134
+ !(Number.isInteger(this.#maxEntries) && this.#maxEntries > 0)) {
135
+ throw new RangeError("MemoryLockoutStore maxEntries must be a positive integer or " +
136
+ `Infinity, got ${this.#maxEntries}.`);
137
+ }
138
+ }
139
+ /**
140
+ * How many records are currently retained — never more than `maxEntries`.
141
+ * Exposed so a test or a dev-time probe can see the bound working; it is not
142
+ * part of the {@link LockoutStore} contract.
143
+ */
144
+ get size() {
145
+ return this.#entries.size;
146
+ }
147
+ /** The stored record for `userId`, or `undefined` once it has expired. */
148
+ get(userId, now = Date.now()) {
149
+ const entry = this.#entries.get(userId);
150
+ if (entry && this.#expired(entry, now)) {
151
+ this.#entries.delete(userId);
152
+ return Promise.resolve(undefined);
153
+ }
154
+ return Promise.resolve(entry?.record);
91
155
  }
92
- /** Add one failure synchronously (atomic in a single-process store). */
156
+ /**
157
+ * Add one failure synchronously (atomic in a single-process store). May
158
+ * drop expired records and evict another account's record to stay under
159
+ * `maxEntries`.
160
+ */
93
161
  increment(userId, now) {
94
- const record = this.#records.get(userId);
162
+ const entry = this.#entries.get(userId);
163
+ const record = entry && !this.#expired(entry, now) ? entry.record : undefined;
95
164
  const lockExpired = record?.lockedUntil !== undefined && record.lockedUntil <= now;
96
165
  const next = record && !lockExpired
97
166
  ? {
@@ -101,17 +170,81 @@ export class MemoryLockoutStore {
101
170
  : {}),
102
171
  }
103
172
  : { failures: 1 };
104
- this.#records.set(userId, next);
173
+ this.#entries.set(userId, { record: next, touchedAt: now });
174
+ this.#collect(userId, now);
105
175
  return Promise.resolve(next);
106
176
  }
107
- /** Store `record` for `userId`. */
177
+ /**
178
+ * Store `record` for `userId`, keeping the time of its last failure; a
179
+ * record for an account with none is timed from now.
180
+ */
108
181
  set(userId, record) {
109
- this.#records.set(userId, record);
182
+ const touchedAt = this.#entries.get(userId)?.touchedAt ?? Date.now();
183
+ this.#entries.set(userId, { record, touchedAt });
184
+ this.#collect(userId, touchedAt);
110
185
  return Promise.resolve();
111
186
  }
112
187
  /** Drop `userId`'s record. */
113
188
  clear(userId) {
114
- this.#records.delete(userId);
189
+ this.#entries.delete(userId);
115
190
  return Promise.resolve();
116
191
  }
192
+ #expired(entry, now) {
193
+ const { lockedUntil } = entry.record;
194
+ if (lockedUntil !== undefined && lockedUntil > now)
195
+ return false;
196
+ return now - entry.touchedAt >= this.#ttlMs;
197
+ }
198
+ #collect(written, now) {
199
+ if (++this.#writesSinceSweep >= LOCKOUT_SWEEP_INTERVAL) {
200
+ this.#dropExpired(now);
201
+ }
202
+ if (this.#entries.size <= this.#maxEntries)
203
+ return;
204
+ this.#dropExpired(now);
205
+ while (this.#entries.size > this.#maxEntries) {
206
+ const victim = this.#evictionCandidate(written, now);
207
+ if (victim === undefined)
208
+ return;
209
+ this.#entries.delete(victim);
210
+ }
211
+ }
212
+ #dropExpired(now) {
213
+ this.#writesSinceSweep = 0;
214
+ for (const [userId, entry] of this.#entries) {
215
+ if (this.#expired(entry, now))
216
+ this.#entries.delete(userId);
217
+ }
218
+ }
219
+ #evictionCandidate(written, now) {
220
+ let victim;
221
+ for (const candidate of this.#entries) {
222
+ if (candidate[0] === written)
223
+ continue;
224
+ if (!victim || evictsBefore(candidate[1], victim[1], now)) {
225
+ victim = candidate;
226
+ }
227
+ }
228
+ return victim?.[0];
229
+ }
230
+ }
231
+ function lockEnd(entry, now) {
232
+ const { lockedUntil } = entry.record;
233
+ return lockedUntil !== undefined && lockedUntil > now
234
+ ? lockedUntil
235
+ : undefined;
236
+ }
237
+ function evictsBefore(a, b, now) {
238
+ const aLock = lockEnd(a, now);
239
+ const bLock = lockEnd(b, now);
240
+ if (aLock === undefined && bLock !== undefined)
241
+ return true;
242
+ if (aLock !== undefined && bLock === undefined)
243
+ return false;
244
+ if (aLock !== undefined && bLock !== undefined)
245
+ return aLock < bLock;
246
+ if (a.record.failures !== b.record.failures) {
247
+ return a.record.failures < b.record.failures;
248
+ }
249
+ return a.touchedAt < b.touchedAt;
117
250
  }
@@ -55,7 +55,7 @@ export { buildResetUrl, buildSignInUrl, buildTokenUrl, buildUnlockUrl, buildVeri
55
55
  export { isRecentlyAuthenticated, type ListableSessionService, type RevocableSessionService, type SessionSummary, supportsSessionListing, } from "./session.ts";
56
56
  export { type ConsumeSignInLinkResult, type IdentityRateLimiters, IdentityService, type IdentityServiceOptions, type IdentityUser, type IdentityUserStore, type UnlockAccountResult, type VerifyEmailResult, type VerifySignInCodeResult, } from "./service.ts";
57
57
  export { EmailOtpService, type EmailOtpServiceOptions, MemoryOtpStore, type OtpRecord, type OtpStore, type RequestedOtp, type RequestOtpOptions, type VerifyOtpOptions, type VerifyOtpResult, } from "./otp.ts";
58
- export { AccountLockout, type AccountLockoutLike, type AccountLockoutOptions, type LockoutFailureResult, type LockoutRecord, type LockoutStatus, type LockoutStore, MemoryLockoutStore, } from "./lockout.ts";
58
+ export { AccountLockout, type AccountLockoutLike, type AccountLockoutOptions, type LockoutFailureResult, type LockoutRecord, type LockoutStatus, type LockoutStore, MemoryLockoutStore, type MemoryLockoutStoreOptions, } from "./lockout.ts";
59
59
  export type { IdentityEvent, IdentityEventHook, SignInCodeFailureReason, SignInFailureReason, } from "./events.ts";
60
60
  export { IdentityError, type IdentityErrorCode, type IdentityErrorOptions, type IdentityErrorStatus, identityErrorStatus, isIdentityError, } from "./errors.ts";
61
61
  export { MemoryRateLimitStore, RateLimiter, type RateLimiterLike, type RateLimiterOptions, type RateLimitResult, type RateLimitStore, } from "./rate-limit.ts";
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Fixed-window rate limiting for auth endpoints — throttle sign-in attempts,
3
3
  * forgot-password requests, etc. Two seams sit here: {@link RateLimitStore}
4
- * swaps the counter storage (a {@link MemoryRateLimitStore} ships for
5
- * dev/single-process; back it with Redis/DB to share limits across instances),
6
- * and {@link RateLimiterLike} swaps the limiter itself when a fixed window is
4
+ * is the counter storage {@link RateLimiter} requires (back it with Redis or a
5
+ * database to share limits across instances, or pass the bounded
6
+ * {@link MemoryRateLimitStore} for a single process, tests and local
7
+ * development), and {@link RateLimiterLike} swaps the limiter itself when a fixed window is
7
8
  * not the algorithm you want. Tenant-agnostic: scope by keying on whatever you
8
9
  * like (IP, identifier, `tenant:ip`).
9
10
  *
@@ -90,16 +91,26 @@ export interface RateLimiterOptions {
90
91
  limit?: number;
91
92
  /** Window length in ms. Defaults to 15 minutes. */
92
93
  windowMs?: number;
93
- /** Counter storage. Defaults to {@link MemoryRateLimitStore}. */
94
- store?: RateLimitStore;
94
+ /**
95
+ * Where the counters live. Required, because a limit only holds if every
96
+ * instance that serves the flow counts in the same place:
97
+ *
98
+ * - A {@link RateLimitStore} over Redis or a database every instance
99
+ * shares. Its `increment` must be atomic.
100
+ * - {@link MemoryRateLimitStore} for a single process, tests and local
101
+ * development. Each instance keeps its own counters, so behind a load
102
+ * balancer a client gets `limit` hits per instance per window.
103
+ */
104
+ store: RateLimitStore;
95
105
  }
96
106
  /**
97
- * Fixed-window limiter — the default {@link RateLimiterLike}. Swap in your own
107
+ * Fixed-window limiter — the shipped {@link RateLimiterLike}. Swap in your own
98
108
  * `RateLimiterLike` for anything the fixed window cannot express.
99
109
  *
100
110
  * @example
101
111
  * ```ts
102
- * const limiter = new RateLimiter({ limit: 5, windowMs: 15 * 60_000 });
112
+ * declare const store: RateLimitStore;
113
+ * const limiter = new RateLimiter({ limit: 5, windowMs: 15 * 60_000, store });
103
114
  * const r = await limiter.check(`signin:${ip}`);
104
115
  * if (!r.allowed) throw new IdentityError("rate_limited", undefined, { retryAfterMs: r.retryAfterMs });
105
116
  * // …on success: await limiter.reset(`signin:${ip}`);
@@ -107,8 +118,12 @@ export interface RateLimiterOptions {
107
118
  */
108
119
  export declare class RateLimiter implements RateLimiterLike {
109
120
  #private;
110
- /** Fixes the `limit`/`windowMs` and the backing store (defaults to {@linkcode MemoryRateLimitStore}). */
111
- constructor(options?: RateLimiterOptions);
121
+ /**
122
+ * Fixes the `limit`/`windowMs` and the backing store.
123
+ *
124
+ * @throws {TypeError} When {@linkcode RateLimiterOptions.store} is missing.
125
+ */
126
+ constructor(options: RateLimiterOptions);
112
127
  /** Record a hit for `key` and report whether it is within the limit. */
113
128
  check(key: string, now?: number): Promise<RateLimitResult>;
114
129
  /** Clear `key`'s counter. */
@@ -152,9 +167,10 @@ export type EnforceRateLimitOptions = {
152
167
  */
153
168
  export declare function enforceRateLimit(options: EnforceRateLimitOptions): Promise<void>;
154
169
  /**
155
- * In-memory {@link RateLimitStore} for dev / single-process use. Counters are
156
- * per-process, so replicas do not share limits — back {@link RateLimiter} with
157
- * Redis or a database in production.
170
+ * In-memory {@link RateLimitStore} for a single process, tests and local
171
+ * development. Counters are per-process, so replicas do not share limits —
172
+ * back {@link RateLimiter} with Redis or a database when more than one
173
+ * instance serves the flow.
158
174
  *
159
175
  * Bounded on purpose: limiter keys are built from unauthenticated request
160
176
  * input, so the store is capped at 10,000 buckets and evicts to stay under it.
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Fixed-window rate limiting for auth endpoints — throttle sign-in attempts,
3
3
  * forgot-password requests, etc. Two seams sit here: {@link RateLimitStore}
4
- * swaps the counter storage (a {@link MemoryRateLimitStore} ships for
5
- * dev/single-process; back it with Redis/DB to share limits across instances),
6
- * and {@link RateLimiterLike} swaps the limiter itself when a fixed window is
4
+ * is the counter storage {@link RateLimiter} requires (back it with Redis or a
5
+ * database to share limits across instances, or pass the bounded
6
+ * {@link MemoryRateLimitStore} for a single process, tests and local
7
+ * development), and {@link RateLimiterLike} swaps the limiter itself when a fixed window is
7
8
  * not the algorithm you want. Tenant-agnostic: scope by keying on whatever you
8
9
  * like (IP, identifier, `tenant:ip`).
9
10
  *
@@ -12,13 +13,17 @@
12
13
  import { IdentityError } from "./errors.js";
13
14
  const DEFAULT_LIMIT = 10;
14
15
  const DEFAULT_WINDOW_MS = 15 * 60 * 1000;
16
+ const MISSING_RATE_LIMIT_STORE = "RateLimiter requires options.store. Pass a RateLimitStore over Redis or " +
17
+ "a database every instance shares, or MemoryRateLimitStore for a single " +
18
+ "process, tests and local development.";
15
19
  /**
16
- * Fixed-window limiter — the default {@link RateLimiterLike}. Swap in your own
20
+ * Fixed-window limiter — the shipped {@link RateLimiterLike}. Swap in your own
17
21
  * `RateLimiterLike` for anything the fixed window cannot express.
18
22
  *
19
23
  * @example
20
24
  * ```ts
21
- * const limiter = new RateLimiter({ limit: 5, windowMs: 15 * 60_000 });
25
+ * declare const store: RateLimitStore;
26
+ * const limiter = new RateLimiter({ limit: 5, windowMs: 15 * 60_000, store });
22
27
  * const r = await limiter.check(`signin:${ip}`);
23
28
  * if (!r.allowed) throw new IdentityError("rate_limited", undefined, { retryAfterMs: r.retryAfterMs });
24
29
  * // …on success: await limiter.reset(`signin:${ip}`);
@@ -28,11 +33,17 @@ export class RateLimiter {
28
33
  #limit;
29
34
  #windowMs;
30
35
  #store;
31
- /** Fixes the `limit`/`windowMs` and the backing store (defaults to {@linkcode MemoryRateLimitStore}). */
32
- constructor(options = {}) {
36
+ /**
37
+ * Fixes the `limit`/`windowMs` and the backing store.
38
+ *
39
+ * @throws {TypeError} When {@linkcode RateLimiterOptions.store} is missing.
40
+ */
41
+ constructor(options) {
42
+ if (!options?.store)
43
+ throw new TypeError(MISSING_RATE_LIMIT_STORE);
33
44
  this.#limit = options.limit ?? DEFAULT_LIMIT;
34
45
  this.#windowMs = options.windowMs ?? DEFAULT_WINDOW_MS;
35
- this.#store = options.store ?? new MemoryRateLimitStore();
46
+ this.#store = options.store;
36
47
  }
37
48
  /** Record a hit for `key` and report whether it is within the limit. */
38
49
  async check(key, now = Date.now()) {
@@ -100,9 +111,10 @@ const MAX_BUCKETS = 10_000;
100
111
  const SWEEP_INTERVAL = 1_000;
101
112
  const EVICTION_TARGET = 9_000;
102
113
  /**
103
- * In-memory {@link RateLimitStore} for dev / single-process use. Counters are
104
- * per-process, so replicas do not share limits — back {@link RateLimiter} with
105
- * Redis or a database in production.
114
+ * In-memory {@link RateLimitStore} for a single process, tests and local
115
+ * development. Counters are per-process, so replicas do not share limits —
116
+ * back {@link RateLimiter} with Redis or a database when more than one
117
+ * instance serves the flow.
106
118
  *
107
119
  * Bounded on purpose: limiter keys are built from unauthenticated request
108
120
  * input, so the store is capped at 10,000 buckets and evicts to stay under it.
@@ -117,8 +117,9 @@ export declare function exportSigningKeyJwk(key: SigningKey): Promise<JsonWebKey
117
117
  }>;
118
118
  /**
119
119
  * Import a signing key previously exported with {@link exportSigningKeyJwk}.
120
- * A JWK without a `kid` is given a random one, so every instance importing it
121
- * would publish and stamp a different `kid` — persist the exported `kid`.
120
+ * A JWK that sets a `kid` keeps it. One without a `kid` gets its RFC 7638 JWK
121
+ * thumbprint (base64url SHA-256 of the public key), so every instance
122
+ * importing the same key publishes and stamps the same `kid`.
122
123
  */
123
124
  export declare function importSigningKeyJwk(jwk: JsonWebKey & {
124
125
  kid?: string;
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import { base64urlDecode, base64urlEncode } from "../utils/crypto.js";
15
15
  import { snapshotAuthenticationContext } from "../utils/authentication-context.js";
16
+ import { jwkThumbprint } from "../utils/_jwk-thumbprint.js";
16
17
  /** A {@link SigningKeyProvider} over one fixed key. */
17
18
  export class StaticSigningKeyProvider {
18
19
  #key;
@@ -130,12 +131,13 @@ export async function exportSigningKeyJwk(key) {
130
131
  }
131
132
  /**
132
133
  * Import a signing key previously exported with {@link exportSigningKeyJwk}.
133
- * A JWK without a `kid` is given a random one, so every instance importing it
134
- * would publish and stamp a different `kid` — persist the exported `kid`.
134
+ * A JWK that sets a `kid` keeps it. One without a `kid` gets its RFC 7638 JWK
135
+ * thumbprint (base64url SHA-256 of the public key), so every instance
136
+ * importing the same key publishes and stamps the same `kid`.
135
137
  */
136
138
  export async function importSigningKeyJwk(jwk) {
137
139
  const privateKey = await crypto.subtle.importKey("jwk", jwk, ES256_PARAMS, true, ["sign"]);
138
- const kid = jwk.kid ?? crypto.randomUUID();
140
+ const kid = jwk.kid ?? (await jwkThumbprint(jwk));
139
141
  return {
140
142
  kid,
141
143
  alg: "ES256",
@@ -0,0 +1,12 @@
1
+ /**
2
+ * RFC 7638 JWK thumbprints.
3
+ *
4
+ * @module
5
+ */
6
+ /**
7
+ * The RFC 7638 SHA-256 thumbprint of a JWK, base64url-encoded. Only the key
8
+ * type's required public members are hashed, so a private JWK and its public
9
+ * half share a thumbprint. Rejects with a `TypeError` for a key type other
10
+ * than `EC`, `OKP` or `RSA`, or when a required member is missing.
11
+ */
12
+ export declare function jwkThumbprint(jwk: JsonWebKey): Promise<string>;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * RFC 7638 JWK thumbprints.
3
+ *
4
+ * @module
5
+ */
6
+ import { encodeBase64Url } from "./_encoding.js";
7
+ const REQUIRED_MEMBERS = {
8
+ EC: ["crv", "kty", "x", "y"],
9
+ OKP: ["crv", "kty", "x"],
10
+ RSA: ["e", "kty", "n"],
11
+ };
12
+ /**
13
+ * The RFC 7638 SHA-256 thumbprint of a JWK, base64url-encoded. Only the key
14
+ * type's required public members are hashed, so a private JWK and its public
15
+ * half share a thumbprint. Rejects with a `TypeError` for a key type other
16
+ * than `EC`, `OKP` or `RSA`, or when a required member is missing.
17
+ */
18
+ export async function jwkThumbprint(jwk) {
19
+ const members = jwk.kty === undefined ? undefined : REQUIRED_MEMBERS[jwk.kty];
20
+ if (!members) {
21
+ throw new TypeError(`Cannot compute a JWK thumbprint for key type ${JSON.stringify(jwk.kty)}`);
22
+ }
23
+ const fields = jwk;
24
+ const canonical = {};
25
+ for (const member of members) {
26
+ const value = fields[member];
27
+ if (typeof value !== "string") {
28
+ throw new TypeError(`Cannot compute a JWK thumbprint without the "${member}" member`);
29
+ }
30
+ canonical[member] = value;
31
+ }
32
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(canonical)));
33
+ return encodeBase64Url(new Uint8Array(digest));
34
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@udibo/oauth2",
3
- "version": "0.17.0",
3
+ "version": "0.18.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",