@udibo/oauth2 0.17.1 → 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.
- package/dist/client/direct-client.d.ts +35 -13
- package/dist/client/direct-client.js +64 -14
- package/dist/client/mod.d.ts +7 -5
- package/dist/client/mod.js +7 -5
- package/dist/client/storage.d.ts +14 -9
- package/dist/client/storage.js +14 -9
- package/dist/identity/lockout.d.ts +86 -16
- package/dist/identity/lockout.js +153 -20
- package/dist/identity/mod.d.ts +1 -1
- package/dist/identity/rate-limit.d.ts +28 -12
- package/dist/identity/rate-limit.js +23 -11
- package/package.json +1 -1
|
@@ -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
|
-
*
|
|
83
|
+
* Refresh-token storage.
|
|
86
84
|
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
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
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
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 ??
|
|
123
|
+
options.refreshTokenStorage ?? browserRefreshTokenStorage();
|
|
124
124
|
this.#authRequestTtlMs = options.authRequestTtlMs ?? 10 * 60 * 1000;
|
|
125
125
|
this.#authRequestStorage =
|
|
126
126
|
options.authRequestStorage ??
|
|
127
|
-
|
|
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.#
|
|
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
|
|
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
|
|
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
|
|
1188
|
+
await this.#refreshTokenStorage?.set(response.refresh_token);
|
|
1158
1189
|
}
|
|
1159
1190
|
else if (!preserveRefreshToken) {
|
|
1160
|
-
await this.#refreshTokenStorage
|
|
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
|
|
1181
|
-
await this.#authRequestStorage
|
|
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
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
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);
|
package/dist/client/mod.d.ts
CHANGED
|
@@ -15,11 +15,13 @@
|
|
|
15
15
|
* care — the React adapter's `<OAuth2Provider>`, a shared UI helper — should
|
|
16
16
|
* accept.
|
|
17
17
|
*
|
|
18
|
-
*
|
|
19
|
-
* by default, so they are lost on reload; a browser
|
|
20
|
-
* passes {@link IndexedDBRefreshTokenStorage}. The
|
|
21
|
-
* defaults to {@link SessionStorageAuthRequestStorage}
|
|
22
|
-
*
|
|
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
|
*/
|
package/dist/client/mod.js
CHANGED
|
@@ -15,11 +15,13 @@
|
|
|
15
15
|
* care — the React adapter's `<OAuth2Provider>`, a shared UI helper — should
|
|
16
16
|
* accept.
|
|
17
17
|
*
|
|
18
|
-
*
|
|
19
|
-
* by default, so they are lost on reload; a browser
|
|
20
|
-
* passes {@link IndexedDBRefreshTokenStorage}. The
|
|
21
|
-
* defaults to {@link SessionStorageAuthRequestStorage}
|
|
22
|
-
*
|
|
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
|
*/
|
package/dist/client/storage.d.ts
CHANGED
|
@@ -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.
|
|
14
|
-
*
|
|
15
|
-
* app that must stay signed in across reloads
|
|
16
|
-
* `IndexedDBRefreshTokenStorage` for the refresh token and keeps the
|
|
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
|
-
*
|
|
120
|
-
* process
|
|
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},
|
|
149
|
-
*
|
|
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
|
package/dist/client/storage.js
CHANGED
|
@@ -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.
|
|
14
|
-
*
|
|
15
|
-
* app that must stay signed in across reloads
|
|
16
|
-
* `IndexedDBRefreshTokenStorage` for the refresh token and keeps the
|
|
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
|
-
*
|
|
39
|
-
* process
|
|
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},
|
|
59
|
-
*
|
|
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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
/**
|
|
29
|
-
|
|
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
|
-
/**
|
|
53
|
-
|
|
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
|
|
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
|
-
/**
|
|
134
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
/**
|
|
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>;
|
package/dist/identity/lockout.js
CHANGED
|
@@ -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
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
33
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
#
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
/**
|
|
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
|
|
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.#
|
|
173
|
+
this.#entries.set(userId, { record: next, touchedAt: now });
|
|
174
|
+
this.#collect(userId, now);
|
|
105
175
|
return Promise.resolve(next);
|
|
106
176
|
}
|
|
107
|
-
/**
|
|
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.#
|
|
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.#
|
|
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
|
}
|
package/dist/identity/mod.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
/**
|
|
94
|
-
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
111
|
-
|
|
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
|
|
156
|
-
* per-process, so replicas do not share limits —
|
|
157
|
-
* Redis or a database
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
32
|
-
|
|
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
|
|
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
|
|
104
|
-
* per-process, so replicas do not share limits —
|
|
105
|
-
* Redis or a database
|
|
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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@udibo/oauth2",
|
|
3
|
-
"version": "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",
|