@aooth/auth 0.1.33 → 0.1.34
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/index.cjs +191 -0
- package/dist/index.d.cts +107 -1
- package/dist/index.d.mts +107 -1
- package/dist/index.mjs +185 -1
- package/dist/redis.cjs +30 -0
- package/dist/redis.d.cts +54 -10
- package/dist/redis.d.mts +54 -10
- package/dist/redis.mjs +30 -1
- package/dist/store-N9daSmHS.d.cts +39 -0
- package/dist/store-jFgby4bK.d.mts +39 -0
- package/package.json +2 -2
package/dist/index.cjs
CHANGED
|
@@ -951,6 +951,190 @@ var AuthCredential = class {
|
|
|
951
951
|
}
|
|
952
952
|
};
|
|
953
953
|
//#endregion
|
|
954
|
+
//#region src/rate-limit/rules.ts
|
|
955
|
+
const DEFAULT_RATE_LIMIT_MESSAGE = "Too many requests. Please try again in {{delta}}.";
|
|
956
|
+
const DURATION_UNITS = {
|
|
957
|
+
ms: 1,
|
|
958
|
+
s: 1e3,
|
|
959
|
+
m: 6e4,
|
|
960
|
+
h: 36e5,
|
|
961
|
+
d: 864e5
|
|
962
|
+
};
|
|
963
|
+
/** `'5m'` / `'90s'` / `'500ms'` / `'1h'` / `'2d'` → ms. Throws on anything else. */
|
|
964
|
+
function parseDurationMs(input) {
|
|
965
|
+
const m = /^(\d+)(ms|s|m|h|d)$/.exec(input.trim());
|
|
966
|
+
if (!m) throw new Error(`Invalid rate-limit window "${input}" — expected "<int><unit>" with unit ms|s|m|h|d (e.g. "5m")`);
|
|
967
|
+
return Number(m[1]) * DURATION_UNITS[m[2]];
|
|
968
|
+
}
|
|
969
|
+
/**
|
|
970
|
+
* Humanize a ms duration for `{{window}}` / `{{delta}}` placeholders:
|
|
971
|
+
* `"5 minutes"`, `"4 minutes 32 seconds"`, `"1 hour"`. Sub-second values
|
|
972
|
+
* round UP to `"1 second"` — a retry hint of "0 seconds" reads as broken.
|
|
973
|
+
* At most the two largest units are shown.
|
|
974
|
+
*/
|
|
975
|
+
function formatDurationMs(ms) {
|
|
976
|
+
const totalSeconds = Math.max(1, Math.ceil(ms / 1e3));
|
|
977
|
+
const units = [
|
|
978
|
+
["day", 86400],
|
|
979
|
+
["hour", 3600],
|
|
980
|
+
["minute", 60],
|
|
981
|
+
["second", 1]
|
|
982
|
+
];
|
|
983
|
+
const parts = [];
|
|
984
|
+
let rest = totalSeconds;
|
|
985
|
+
for (const [name, size] of units) {
|
|
986
|
+
if (parts.length === 2) break;
|
|
987
|
+
const n = Math.floor(rest / size);
|
|
988
|
+
if (n === 0) continue;
|
|
989
|
+
rest -= n * size;
|
|
990
|
+
parts.push(`${n} ${name}${n === 1 ? "" : "s"}`);
|
|
991
|
+
}
|
|
992
|
+
return parts.join(" ");
|
|
993
|
+
}
|
|
994
|
+
function parseRuleString(input) {
|
|
995
|
+
const pipe = input.indexOf("|");
|
|
996
|
+
const message = pipe >= 0 ? input.slice(pipe + 1).trim() : void 0;
|
|
997
|
+
const head = (pipe >= 0 ? input.slice(0, pipe) : input).trim();
|
|
998
|
+
const m = /^(\d+)\s*(?:\/|\s+per\s+)\s*(\d+(?:ms|s|m|h|d))$/i.exec(head);
|
|
999
|
+
if (!m) throw new Error(`Invalid rate-limit rule "${input}" — expected "<limit>/<window>" or "<limit> per <window>" (e.g. "6/5m", "30 per 1m"), optionally followed by " | <message>"`);
|
|
1000
|
+
return {
|
|
1001
|
+
limit: Number(m[1]),
|
|
1002
|
+
window: parseDurationMs(m[2]),
|
|
1003
|
+
...message !== void 0 && message !== "" && { message }
|
|
1004
|
+
};
|
|
1005
|
+
}
|
|
1006
|
+
function assertValidRule(rule) {
|
|
1007
|
+
if (!Number.isInteger(rule.limit) || rule.limit <= 0) throw new Error(`Invalid rate-limit rule: limit must be a positive integer (got ${rule.limit})`);
|
|
1008
|
+
if (!Number.isFinite(rule.window) || rule.window <= 0) throw new Error(`Invalid rate-limit rule: window must be a positive ms value (got ${rule.window})`);
|
|
1009
|
+
}
|
|
1010
|
+
/** Normalize any {@link RateLimitRuleInput}. Throws on invalid grammar or non-positive values. */
|
|
1011
|
+
function parseRateLimitRule(input) {
|
|
1012
|
+
if (typeof input !== "string" && typeof input.window === "number") {
|
|
1013
|
+
assertValidRule(input);
|
|
1014
|
+
return input;
|
|
1015
|
+
}
|
|
1016
|
+
const rule = typeof input === "string" ? parseRuleString(input) : {
|
|
1017
|
+
limit: input.limit,
|
|
1018
|
+
window: parseDurationMs(input.window),
|
|
1019
|
+
...input.message !== void 0 && { message: input.message }
|
|
1020
|
+
};
|
|
1021
|
+
assertValidRule(rule);
|
|
1022
|
+
return rule;
|
|
1023
|
+
}
|
|
1024
|
+
/**
|
|
1025
|
+
* Render a 429 message template. Placeholders: `{{limit}}` (alias `{{max}}`),
|
|
1026
|
+
* `{{window}}` and `{{delta}}` — both humanized via {@link formatDurationMs}.
|
|
1027
|
+
*/
|
|
1028
|
+
function renderRateLimitMessage(template, rule, retryAfterMs) {
|
|
1029
|
+
return template.replaceAll("{{limit}}", String(rule.limit)).replaceAll("{{max}}", String(rule.limit)).replaceAll("{{window}}", formatDurationMs(rule.window)).replaceAll("{{delta}}", formatDurationMs(retryAfterMs));
|
|
1030
|
+
}
|
|
1031
|
+
//#endregion
|
|
1032
|
+
//#region src/rate-limit/store.ts
|
|
1033
|
+
/**
|
|
1034
|
+
* In-memory `RateLimitStore` — the zero-config default (single process only).
|
|
1035
|
+
* Lazy-evicts expired counters on access; `cleanup()` sweeps the whole map for
|
|
1036
|
+
* periodic compaction in long-lived processes (same pattern as
|
|
1037
|
+
* `DenylistStoreMemory`).
|
|
1038
|
+
*/
|
|
1039
|
+
var RateLimitStoreMemory = class {
|
|
1040
|
+
entries = /* @__PURE__ */ new Map();
|
|
1041
|
+
clock;
|
|
1042
|
+
constructor(opts) {
|
|
1043
|
+
this.clock = opts?.clock ?? require_clock.defaultClock;
|
|
1044
|
+
}
|
|
1045
|
+
async hit(key, ttlMs) {
|
|
1046
|
+
const now = this.clock.now();
|
|
1047
|
+
const entry = this.entries.get(key);
|
|
1048
|
+
if (!entry || entry.expiresAt <= now) {
|
|
1049
|
+
this.entries.set(key, {
|
|
1050
|
+
count: 1,
|
|
1051
|
+
expiresAt: now + ttlMs
|
|
1052
|
+
});
|
|
1053
|
+
return 1;
|
|
1054
|
+
}
|
|
1055
|
+
entry.count += 1;
|
|
1056
|
+
return entry.count;
|
|
1057
|
+
}
|
|
1058
|
+
async reset(key) {
|
|
1059
|
+
this.entries.delete(key);
|
|
1060
|
+
}
|
|
1061
|
+
/** Drop every counter — test-harness reset between cases. */
|
|
1062
|
+
clear() {
|
|
1063
|
+
this.entries.clear();
|
|
1064
|
+
}
|
|
1065
|
+
async cleanup() {
|
|
1066
|
+
const now = this.clock.now();
|
|
1067
|
+
let removed = 0;
|
|
1068
|
+
for (const [key, entry] of this.entries) if (entry.expiresAt <= now) {
|
|
1069
|
+
this.entries.delete(key);
|
|
1070
|
+
removed++;
|
|
1071
|
+
}
|
|
1072
|
+
return removed;
|
|
1073
|
+
}
|
|
1074
|
+
};
|
|
1075
|
+
//#endregion
|
|
1076
|
+
//#region src/rate-limit/rate-limiter.ts
|
|
1077
|
+
/**
|
|
1078
|
+
* Fixed-window rate limiter with window-aligned keys (RL.spec.md §4.2).
|
|
1079
|
+
*
|
|
1080
|
+
* For each rule the current window starts at `floor(now / window) * window`;
|
|
1081
|
+
* the counter key embeds that start, so a new window is automatically a new
|
|
1082
|
+
* key and one atomic store increment decides. All rules are hit on every
|
|
1083
|
+
* check (rejected requests count too — they cannot extend a fixed window).
|
|
1084
|
+
*/
|
|
1085
|
+
var RateLimiter = class {
|
|
1086
|
+
store;
|
|
1087
|
+
clock;
|
|
1088
|
+
constructor(opts) {
|
|
1089
|
+
this.store = opts?.store ?? new RateLimitStoreMemory({ clock: opts?.clock });
|
|
1090
|
+
this.clock = opts?.clock ?? require_clock.defaultClock;
|
|
1091
|
+
}
|
|
1092
|
+
/**
|
|
1093
|
+
* Evaluate `rules` for `scope` (handler/bucket id) + `subject` (caller
|
|
1094
|
+
* identity). Store keys are `<scope>:<subject>:<ruleIndex>:<windowStart>`
|
|
1095
|
+
* with scope/subject each URI-encoded — raw components would collide
|
|
1096
|
+
* (IPv6 addresses, route paths, and custom subjects all contain `:`).
|
|
1097
|
+
*
|
|
1098
|
+
* When several rules are violated at once, the governing rule is the one
|
|
1099
|
+
* whose window frees capacity LAST — the request only becomes allowed once
|
|
1100
|
+
* every rule passes, so a shorter hint would invite guaranteed-rejected
|
|
1101
|
+
* retries.
|
|
1102
|
+
*/
|
|
1103
|
+
async check(scope, subject, rules, opts) {
|
|
1104
|
+
if (rules.length === 0) throw new Error("RateLimiter.check: at least one rule is required");
|
|
1105
|
+
const parsed = rules.map((r) => parseRateLimitRule(r));
|
|
1106
|
+
const now = this.clock.now();
|
|
1107
|
+
const keyBase = `${encodeURIComponent(scope)}:${encodeURIComponent(subject)}`;
|
|
1108
|
+
const evaluated = await Promise.all(parsed.map(async (rule, i) => {
|
|
1109
|
+
const windowStart = Math.floor(now / rule.window) * rule.window;
|
|
1110
|
+
const key = `${keyBase}:${i}:${windowStart}`;
|
|
1111
|
+
const count = await this.store.hit(key, rule.window * 2);
|
|
1112
|
+
return {
|
|
1113
|
+
rule,
|
|
1114
|
+
remaining: Math.max(0, rule.limit - count),
|
|
1115
|
+
resetAt: windowStart + rule.window,
|
|
1116
|
+
violated: count > rule.limit
|
|
1117
|
+
};
|
|
1118
|
+
}));
|
|
1119
|
+
const violated = evaluated.filter((e) => e.violated);
|
|
1120
|
+
const rejected = violated.length > 0;
|
|
1121
|
+
const governing = rejected ? violated.reduce((a, b) => b.resetAt > a.resetAt ? b : a) : evaluated.reduce((a, b) => b.remaining < a.remaining ? b : a);
|
|
1122
|
+
const resetAfterMs = governing.resetAt - now;
|
|
1123
|
+
return {
|
|
1124
|
+
allowed: !rejected,
|
|
1125
|
+
limit: governing.rule.limit,
|
|
1126
|
+
remaining: governing.remaining,
|
|
1127
|
+
resetAt: governing.resetAt,
|
|
1128
|
+
resetAfterMs,
|
|
1129
|
+
policies: parsed,
|
|
1130
|
+
...rejected && {
|
|
1131
|
+
retryAfterMs: resetAfterMs,
|
|
1132
|
+
message: renderRateLimitMessage(governing.rule.message ?? opts?.defaultMessage ?? "Too many requests. Please try again in {{delta}}.", governing.rule, resetAfterMs)
|
|
1133
|
+
}
|
|
1134
|
+
};
|
|
1135
|
+
}
|
|
1136
|
+
};
|
|
1137
|
+
//#endregion
|
|
954
1138
|
//#region src/magic-link.ts
|
|
955
1139
|
/**
|
|
956
1140
|
* 32 bytes of CSPRNG entropy (256 bits) encoded as base64url — 43 chars,
|
|
@@ -965,6 +1149,13 @@ exports.AuthError = AuthError;
|
|
|
965
1149
|
exports.CredentialStoreEncapsulated = CredentialStoreEncapsulated;
|
|
966
1150
|
exports.CredentialStoreJwt = CredentialStoreJwt;
|
|
967
1151
|
exports.CredentialStoreMemory = CredentialStoreMemory;
|
|
1152
|
+
exports.DEFAULT_RATE_LIMIT_MESSAGE = DEFAULT_RATE_LIMIT_MESSAGE;
|
|
968
1153
|
exports.DenylistStoreMemory = DenylistStoreMemory;
|
|
1154
|
+
exports.RateLimitStoreMemory = RateLimitStoreMemory;
|
|
1155
|
+
exports.RateLimiter = RateLimiter;
|
|
969
1156
|
exports.defaultClock = require_clock.defaultClock;
|
|
1157
|
+
exports.formatDurationMs = formatDurationMs;
|
|
970
1158
|
exports.generateMagicLinkToken = generateMagicLinkToken;
|
|
1159
|
+
exports.parseDurationMs = parseDurationMs;
|
|
1160
|
+
exports.parseRateLimitRule = parseRateLimitRule;
|
|
1161
|
+
exports.renderRateLimitMessage = renderRateLimitMessage;
|
package/dist/index.d.cts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { a as CredentialState, c as RefreshConfig, i as CredentialMetadata, l as SessionEnricher, n as DenylistStore, o as EnrichedSession, r as AuthContext, s as IssueResult, t as CredentialStore, u as SessionInfo } from "./store-BG6m6oSJ.cjs";
|
|
2
2
|
import { n as defaultClock, t as Clock } from "./clock-BjXa0LXb.cjs";
|
|
3
|
+
import { n as RateLimitStoreMemory, t as RateLimitStore } from "./store-N9daSmHS.cjs";
|
|
3
4
|
import { CryptoKey } from "jose";
|
|
4
5
|
|
|
5
6
|
//#region src/errors.d.ts
|
|
@@ -414,6 +415,111 @@ declare class AuthCredential<TPayload extends object = object> {
|
|
|
414
415
|
private respondToRefreshReuse;
|
|
415
416
|
}
|
|
416
417
|
//#endregion
|
|
418
|
+
//#region src/rate-limit/rules.d.ts
|
|
419
|
+
/**
|
|
420
|
+
* Rate-limit rule model + the compact string grammar (RL.spec.md §4.1).
|
|
421
|
+
*
|
|
422
|
+
* Input rules and normalized rules are distinct types: inputs accept the
|
|
423
|
+
* ergonomic forms (`'6/5m | message'`, `{ limit, window: '5m' }`),
|
|
424
|
+
* `parseRateLimitRule` normalizes everything to ms. Parsing happens eagerly
|
|
425
|
+
* (decoration/config time), so a bad rule string fails at boot, not per
|
|
426
|
+
* request.
|
|
427
|
+
*/
|
|
428
|
+
/** Normalized rule — `window` always in ms. */
|
|
429
|
+
interface RateLimitRule {
|
|
430
|
+
/** Max hits per window. */
|
|
431
|
+
limit: number;
|
|
432
|
+
/** Window length, ms. */
|
|
433
|
+
window: number;
|
|
434
|
+
/** 429 message template for THIS rule (see {@link renderRateLimitMessage}). */
|
|
435
|
+
message?: string;
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* What decorators / options accept:
|
|
439
|
+
* - `'<limit>/<window>'` — `'6/5m'`
|
|
440
|
+
* - `'<limit> per <window>'` — `'6 per 5m'`
|
|
441
|
+
* - either form + `' | <message>'` — `'6/5m | Wait {{delta}}'`
|
|
442
|
+
* - `{ limit, window, message? }` — `window` in ms or `'5m'` form
|
|
443
|
+
*/
|
|
444
|
+
type RateLimitRuleInput = string | {
|
|
445
|
+
limit: number;
|
|
446
|
+
window: number | string;
|
|
447
|
+
message?: string;
|
|
448
|
+
};
|
|
449
|
+
declare const DEFAULT_RATE_LIMIT_MESSAGE = "Too many requests. Please try again in {{delta}}.";
|
|
450
|
+
/** `'5m'` / `'90s'` / `'500ms'` / `'1h'` / `'2d'` → ms. Throws on anything else. */
|
|
451
|
+
declare function parseDurationMs(input: string): number;
|
|
452
|
+
/**
|
|
453
|
+
* Humanize a ms duration for `{{window}}` / `{{delta}}` placeholders:
|
|
454
|
+
* `"5 minutes"`, `"4 minutes 32 seconds"`, `"1 hour"`. Sub-second values
|
|
455
|
+
* round UP to `"1 second"` — a retry hint of "0 seconds" reads as broken.
|
|
456
|
+
* At most the two largest units are shown.
|
|
457
|
+
*/
|
|
458
|
+
declare function formatDurationMs(ms: number): string;
|
|
459
|
+
/** Normalize any {@link RateLimitRuleInput}. Throws on invalid grammar or non-positive values. */
|
|
460
|
+
declare function parseRateLimitRule(input: RateLimitRuleInput): RateLimitRule;
|
|
461
|
+
/**
|
|
462
|
+
* Render a 429 message template. Placeholders: `{{limit}}` (alias `{{max}}`),
|
|
463
|
+
* `{{window}}` and `{{delta}}` — both humanized via {@link formatDurationMs}.
|
|
464
|
+
*/
|
|
465
|
+
declare function renderRateLimitMessage(template: string, rule: RateLimitRule, retryAfterMs: number): string;
|
|
466
|
+
//#endregion
|
|
467
|
+
//#region src/rate-limit/rate-limiter.d.ts
|
|
468
|
+
interface RateLimiterOptions {
|
|
469
|
+
/** Counter storage. Default: a fresh `RateLimitStoreMemory` (single process only). */
|
|
470
|
+
store?: RateLimitStore;
|
|
471
|
+
/** Injectable time source for deterministic tests. */
|
|
472
|
+
clock?: Clock;
|
|
473
|
+
}
|
|
474
|
+
/** The outcome of one `check()` — everything a transport needs for headers + 429. */
|
|
475
|
+
interface RateLimitDecision {
|
|
476
|
+
allowed: boolean;
|
|
477
|
+
/** Limit of the governing rule (the violated one, else the lowest-remaining one). */
|
|
478
|
+
limit: number;
|
|
479
|
+
remaining: number;
|
|
480
|
+
/** ms epoch when the governing window resets. */
|
|
481
|
+
resetAt: number;
|
|
482
|
+
/**
|
|
483
|
+
* ms until the governing window resets, measured with the limiter's own
|
|
484
|
+
* clock — transports derive the `RateLimit-Reset` header from this instead
|
|
485
|
+
* of re-subtracting `Date.now()` (which would drift under injected clocks).
|
|
486
|
+
*/
|
|
487
|
+
resetAfterMs: number;
|
|
488
|
+
/** Present when `!allowed`: ms until the governing window frees capacity. */
|
|
489
|
+
retryAfterMs?: number;
|
|
490
|
+
/** Present when `!allowed`: rendered from the violated rule's message template. */
|
|
491
|
+
message?: string;
|
|
492
|
+
/** Every evaluated rule (for the `RateLimit-Policy` header). */
|
|
493
|
+
policies: RateLimitRule[];
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Fixed-window rate limiter with window-aligned keys (RL.spec.md §4.2).
|
|
497
|
+
*
|
|
498
|
+
* For each rule the current window starts at `floor(now / window) * window`;
|
|
499
|
+
* the counter key embeds that start, so a new window is automatically a new
|
|
500
|
+
* key and one atomic store increment decides. All rules are hit on every
|
|
501
|
+
* check (rejected requests count too — they cannot extend a fixed window).
|
|
502
|
+
*/
|
|
503
|
+
declare class RateLimiter {
|
|
504
|
+
private readonly store;
|
|
505
|
+
private readonly clock;
|
|
506
|
+
constructor(opts?: RateLimiterOptions);
|
|
507
|
+
/**
|
|
508
|
+
* Evaluate `rules` for `scope` (handler/bucket id) + `subject` (caller
|
|
509
|
+
* identity). Store keys are `<scope>:<subject>:<ruleIndex>:<windowStart>`
|
|
510
|
+
* with scope/subject each URI-encoded — raw components would collide
|
|
511
|
+
* (IPv6 addresses, route paths, and custom subjects all contain `:`).
|
|
512
|
+
*
|
|
513
|
+
* When several rules are violated at once, the governing rule is the one
|
|
514
|
+
* whose window frees capacity LAST — the request only becomes allowed once
|
|
515
|
+
* every rule passes, so a shorter hint would invite guaranteed-rejected
|
|
516
|
+
* retries.
|
|
517
|
+
*/
|
|
518
|
+
check(scope: string, subject: string, rules: RateLimitRuleInput[], opts?: {
|
|
519
|
+
defaultMessage?: string;
|
|
520
|
+
}): Promise<RateLimitDecision>;
|
|
521
|
+
}
|
|
522
|
+
//#endregion
|
|
417
523
|
//#region src/email.d.ts
|
|
418
524
|
/**
|
|
419
525
|
* Discriminator for auth-related email events emitted by the workflow stack.
|
|
@@ -515,4 +621,4 @@ type BuildMagicLinkUrl = (kind: AuthEmailKind, token: string, ctx?: {
|
|
|
515
621
|
*/
|
|
516
622
|
declare function generateMagicLinkToken(): string;
|
|
517
623
|
//#endregion
|
|
518
|
-
export { type AuthContext, AuthCredential, type AuthCredentialOptions, type AuthEmailEvent, type AuthEmailKind, AuthError, type AuthErrorType, type AuthSmsEvent, type AuthSmsKind, type BuildMagicLinkUrl, type Clock, type CredentialMetadata, type CredentialState, type CredentialStore, CredentialStoreEncapsulated, type CredentialStoreEncapsulatedOptions, CredentialStoreJwt, type CredentialStoreJwtOptions, CredentialStoreMemory, type DenylistStore, DenylistStoreMemory, type EmailSender, type EnrichedSession, type IssueOptions, type IssueResult, type JwtAlgorithm, type RefreshConfig, type SessionEnricher, type SessionInfo, type SmsSender, defaultClock, generateMagicLinkToken };
|
|
624
|
+
export { type AuthContext, AuthCredential, type AuthCredentialOptions, type AuthEmailEvent, type AuthEmailKind, AuthError, type AuthErrorType, type AuthSmsEvent, type AuthSmsKind, type BuildMagicLinkUrl, type Clock, type CredentialMetadata, type CredentialState, type CredentialStore, CredentialStoreEncapsulated, type CredentialStoreEncapsulatedOptions, CredentialStoreJwt, type CredentialStoreJwtOptions, CredentialStoreMemory, DEFAULT_RATE_LIMIT_MESSAGE, type DenylistStore, DenylistStoreMemory, type EmailSender, type EnrichedSession, type IssueOptions, type IssueResult, type JwtAlgorithm, type RateLimitDecision, type RateLimitRule, type RateLimitRuleInput, type RateLimitStore, RateLimitStoreMemory, RateLimiter, type RateLimiterOptions, type RefreshConfig, type SessionEnricher, type SessionInfo, type SmsSender, defaultClock, formatDurationMs, generateMagicLinkToken, parseDurationMs, parseRateLimitRule, renderRateLimitMessage };
|
package/dist/index.d.mts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { a as CredentialState, c as RefreshConfig, i as CredentialMetadata, l as SessionEnricher, n as DenylistStore, o as EnrichedSession, r as AuthContext, s as IssueResult, t as CredentialStore, u as SessionInfo } from "./store-BG6m6oSJ.mjs";
|
|
2
2
|
import { n as defaultClock, t as Clock } from "./clock-BjXa0LXb.mjs";
|
|
3
|
+
import { n as RateLimitStoreMemory, t as RateLimitStore } from "./store-jFgby4bK.mjs";
|
|
3
4
|
import { CryptoKey } from "jose";
|
|
4
5
|
|
|
5
6
|
//#region src/errors.d.ts
|
|
@@ -414,6 +415,111 @@ declare class AuthCredential<TPayload extends object = object> {
|
|
|
414
415
|
private respondToRefreshReuse;
|
|
415
416
|
}
|
|
416
417
|
//#endregion
|
|
418
|
+
//#region src/rate-limit/rules.d.ts
|
|
419
|
+
/**
|
|
420
|
+
* Rate-limit rule model + the compact string grammar (RL.spec.md §4.1).
|
|
421
|
+
*
|
|
422
|
+
* Input rules and normalized rules are distinct types: inputs accept the
|
|
423
|
+
* ergonomic forms (`'6/5m | message'`, `{ limit, window: '5m' }`),
|
|
424
|
+
* `parseRateLimitRule` normalizes everything to ms. Parsing happens eagerly
|
|
425
|
+
* (decoration/config time), so a bad rule string fails at boot, not per
|
|
426
|
+
* request.
|
|
427
|
+
*/
|
|
428
|
+
/** Normalized rule — `window` always in ms. */
|
|
429
|
+
interface RateLimitRule {
|
|
430
|
+
/** Max hits per window. */
|
|
431
|
+
limit: number;
|
|
432
|
+
/** Window length, ms. */
|
|
433
|
+
window: number;
|
|
434
|
+
/** 429 message template for THIS rule (see {@link renderRateLimitMessage}). */
|
|
435
|
+
message?: string;
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* What decorators / options accept:
|
|
439
|
+
* - `'<limit>/<window>'` — `'6/5m'`
|
|
440
|
+
* - `'<limit> per <window>'` — `'6 per 5m'`
|
|
441
|
+
* - either form + `' | <message>'` — `'6/5m | Wait {{delta}}'`
|
|
442
|
+
* - `{ limit, window, message? }` — `window` in ms or `'5m'` form
|
|
443
|
+
*/
|
|
444
|
+
type RateLimitRuleInput = string | {
|
|
445
|
+
limit: number;
|
|
446
|
+
window: number | string;
|
|
447
|
+
message?: string;
|
|
448
|
+
};
|
|
449
|
+
declare const DEFAULT_RATE_LIMIT_MESSAGE = "Too many requests. Please try again in {{delta}}.";
|
|
450
|
+
/** `'5m'` / `'90s'` / `'500ms'` / `'1h'` / `'2d'` → ms. Throws on anything else. */
|
|
451
|
+
declare function parseDurationMs(input: string): number;
|
|
452
|
+
/**
|
|
453
|
+
* Humanize a ms duration for `{{window}}` / `{{delta}}` placeholders:
|
|
454
|
+
* `"5 minutes"`, `"4 minutes 32 seconds"`, `"1 hour"`. Sub-second values
|
|
455
|
+
* round UP to `"1 second"` — a retry hint of "0 seconds" reads as broken.
|
|
456
|
+
* At most the two largest units are shown.
|
|
457
|
+
*/
|
|
458
|
+
declare function formatDurationMs(ms: number): string;
|
|
459
|
+
/** Normalize any {@link RateLimitRuleInput}. Throws on invalid grammar or non-positive values. */
|
|
460
|
+
declare function parseRateLimitRule(input: RateLimitRuleInput): RateLimitRule;
|
|
461
|
+
/**
|
|
462
|
+
* Render a 429 message template. Placeholders: `{{limit}}` (alias `{{max}}`),
|
|
463
|
+
* `{{window}}` and `{{delta}}` — both humanized via {@link formatDurationMs}.
|
|
464
|
+
*/
|
|
465
|
+
declare function renderRateLimitMessage(template: string, rule: RateLimitRule, retryAfterMs: number): string;
|
|
466
|
+
//#endregion
|
|
467
|
+
//#region src/rate-limit/rate-limiter.d.ts
|
|
468
|
+
interface RateLimiterOptions {
|
|
469
|
+
/** Counter storage. Default: a fresh `RateLimitStoreMemory` (single process only). */
|
|
470
|
+
store?: RateLimitStore;
|
|
471
|
+
/** Injectable time source for deterministic tests. */
|
|
472
|
+
clock?: Clock;
|
|
473
|
+
}
|
|
474
|
+
/** The outcome of one `check()` — everything a transport needs for headers + 429. */
|
|
475
|
+
interface RateLimitDecision {
|
|
476
|
+
allowed: boolean;
|
|
477
|
+
/** Limit of the governing rule (the violated one, else the lowest-remaining one). */
|
|
478
|
+
limit: number;
|
|
479
|
+
remaining: number;
|
|
480
|
+
/** ms epoch when the governing window resets. */
|
|
481
|
+
resetAt: number;
|
|
482
|
+
/**
|
|
483
|
+
* ms until the governing window resets, measured with the limiter's own
|
|
484
|
+
* clock — transports derive the `RateLimit-Reset` header from this instead
|
|
485
|
+
* of re-subtracting `Date.now()` (which would drift under injected clocks).
|
|
486
|
+
*/
|
|
487
|
+
resetAfterMs: number;
|
|
488
|
+
/** Present when `!allowed`: ms until the governing window frees capacity. */
|
|
489
|
+
retryAfterMs?: number;
|
|
490
|
+
/** Present when `!allowed`: rendered from the violated rule's message template. */
|
|
491
|
+
message?: string;
|
|
492
|
+
/** Every evaluated rule (for the `RateLimit-Policy` header). */
|
|
493
|
+
policies: RateLimitRule[];
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Fixed-window rate limiter with window-aligned keys (RL.spec.md §4.2).
|
|
497
|
+
*
|
|
498
|
+
* For each rule the current window starts at `floor(now / window) * window`;
|
|
499
|
+
* the counter key embeds that start, so a new window is automatically a new
|
|
500
|
+
* key and one atomic store increment decides. All rules are hit on every
|
|
501
|
+
* check (rejected requests count too — they cannot extend a fixed window).
|
|
502
|
+
*/
|
|
503
|
+
declare class RateLimiter {
|
|
504
|
+
private readonly store;
|
|
505
|
+
private readonly clock;
|
|
506
|
+
constructor(opts?: RateLimiterOptions);
|
|
507
|
+
/**
|
|
508
|
+
* Evaluate `rules` for `scope` (handler/bucket id) + `subject` (caller
|
|
509
|
+
* identity). Store keys are `<scope>:<subject>:<ruleIndex>:<windowStart>`
|
|
510
|
+
* with scope/subject each URI-encoded — raw components would collide
|
|
511
|
+
* (IPv6 addresses, route paths, and custom subjects all contain `:`).
|
|
512
|
+
*
|
|
513
|
+
* When several rules are violated at once, the governing rule is the one
|
|
514
|
+
* whose window frees capacity LAST — the request only becomes allowed once
|
|
515
|
+
* every rule passes, so a shorter hint would invite guaranteed-rejected
|
|
516
|
+
* retries.
|
|
517
|
+
*/
|
|
518
|
+
check(scope: string, subject: string, rules: RateLimitRuleInput[], opts?: {
|
|
519
|
+
defaultMessage?: string;
|
|
520
|
+
}): Promise<RateLimitDecision>;
|
|
521
|
+
}
|
|
522
|
+
//#endregion
|
|
417
523
|
//#region src/email.d.ts
|
|
418
524
|
/**
|
|
419
525
|
* Discriminator for auth-related email events emitted by the workflow stack.
|
|
@@ -515,4 +621,4 @@ type BuildMagicLinkUrl = (kind: AuthEmailKind, token: string, ctx?: {
|
|
|
515
621
|
*/
|
|
516
622
|
declare function generateMagicLinkToken(): string;
|
|
517
623
|
//#endregion
|
|
518
|
-
export { type AuthContext, AuthCredential, type AuthCredentialOptions, type AuthEmailEvent, type AuthEmailKind, AuthError, type AuthErrorType, type AuthSmsEvent, type AuthSmsKind, type BuildMagicLinkUrl, type Clock, type CredentialMetadata, type CredentialState, type CredentialStore, CredentialStoreEncapsulated, type CredentialStoreEncapsulatedOptions, CredentialStoreJwt, type CredentialStoreJwtOptions, CredentialStoreMemory, type DenylistStore, DenylistStoreMemory, type EmailSender, type EnrichedSession, type IssueOptions, type IssueResult, type JwtAlgorithm, type RefreshConfig, type SessionEnricher, type SessionInfo, type SmsSender, defaultClock, generateMagicLinkToken };
|
|
624
|
+
export { type AuthContext, AuthCredential, type AuthCredentialOptions, type AuthEmailEvent, type AuthEmailKind, AuthError, type AuthErrorType, type AuthSmsEvent, type AuthSmsKind, type BuildMagicLinkUrl, type Clock, type CredentialMetadata, type CredentialState, type CredentialStore, CredentialStoreEncapsulated, type CredentialStoreEncapsulatedOptions, CredentialStoreJwt, type CredentialStoreJwtOptions, CredentialStoreMemory, DEFAULT_RATE_LIMIT_MESSAGE, type DenylistStore, DenylistStoreMemory, type EmailSender, type EnrichedSession, type IssueOptions, type IssueResult, type JwtAlgorithm, type RateLimitDecision, type RateLimitRule, type RateLimitRuleInput, type RateLimitStore, RateLimitStoreMemory, RateLimiter, type RateLimiterOptions, type RefreshConfig, type SessionEnricher, type SessionInfo, type SmsSender, defaultClock, formatDurationMs, generateMagicLinkToken, parseDurationMs, parseRateLimitRule, renderRateLimitMessage };
|
package/dist/index.mjs
CHANGED
|
@@ -950,6 +950,190 @@ var AuthCredential = class {
|
|
|
950
950
|
}
|
|
951
951
|
};
|
|
952
952
|
//#endregion
|
|
953
|
+
//#region src/rate-limit/rules.ts
|
|
954
|
+
const DEFAULT_RATE_LIMIT_MESSAGE = "Too many requests. Please try again in {{delta}}.";
|
|
955
|
+
const DURATION_UNITS = {
|
|
956
|
+
ms: 1,
|
|
957
|
+
s: 1e3,
|
|
958
|
+
m: 6e4,
|
|
959
|
+
h: 36e5,
|
|
960
|
+
d: 864e5
|
|
961
|
+
};
|
|
962
|
+
/** `'5m'` / `'90s'` / `'500ms'` / `'1h'` / `'2d'` → ms. Throws on anything else. */
|
|
963
|
+
function parseDurationMs(input) {
|
|
964
|
+
const m = /^(\d+)(ms|s|m|h|d)$/.exec(input.trim());
|
|
965
|
+
if (!m) throw new Error(`Invalid rate-limit window "${input}" — expected "<int><unit>" with unit ms|s|m|h|d (e.g. "5m")`);
|
|
966
|
+
return Number(m[1]) * DURATION_UNITS[m[2]];
|
|
967
|
+
}
|
|
968
|
+
/**
|
|
969
|
+
* Humanize a ms duration for `{{window}}` / `{{delta}}` placeholders:
|
|
970
|
+
* `"5 minutes"`, `"4 minutes 32 seconds"`, `"1 hour"`. Sub-second values
|
|
971
|
+
* round UP to `"1 second"` — a retry hint of "0 seconds" reads as broken.
|
|
972
|
+
* At most the two largest units are shown.
|
|
973
|
+
*/
|
|
974
|
+
function formatDurationMs(ms) {
|
|
975
|
+
const totalSeconds = Math.max(1, Math.ceil(ms / 1e3));
|
|
976
|
+
const units = [
|
|
977
|
+
["day", 86400],
|
|
978
|
+
["hour", 3600],
|
|
979
|
+
["minute", 60],
|
|
980
|
+
["second", 1]
|
|
981
|
+
];
|
|
982
|
+
const parts = [];
|
|
983
|
+
let rest = totalSeconds;
|
|
984
|
+
for (const [name, size] of units) {
|
|
985
|
+
if (parts.length === 2) break;
|
|
986
|
+
const n = Math.floor(rest / size);
|
|
987
|
+
if (n === 0) continue;
|
|
988
|
+
rest -= n * size;
|
|
989
|
+
parts.push(`${n} ${name}${n === 1 ? "" : "s"}`);
|
|
990
|
+
}
|
|
991
|
+
return parts.join(" ");
|
|
992
|
+
}
|
|
993
|
+
function parseRuleString(input) {
|
|
994
|
+
const pipe = input.indexOf("|");
|
|
995
|
+
const message = pipe >= 0 ? input.slice(pipe + 1).trim() : void 0;
|
|
996
|
+
const head = (pipe >= 0 ? input.slice(0, pipe) : input).trim();
|
|
997
|
+
const m = /^(\d+)\s*(?:\/|\s+per\s+)\s*(\d+(?:ms|s|m|h|d))$/i.exec(head);
|
|
998
|
+
if (!m) throw new Error(`Invalid rate-limit rule "${input}" — expected "<limit>/<window>" or "<limit> per <window>" (e.g. "6/5m", "30 per 1m"), optionally followed by " | <message>"`);
|
|
999
|
+
return {
|
|
1000
|
+
limit: Number(m[1]),
|
|
1001
|
+
window: parseDurationMs(m[2]),
|
|
1002
|
+
...message !== void 0 && message !== "" && { message }
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
function assertValidRule(rule) {
|
|
1006
|
+
if (!Number.isInteger(rule.limit) || rule.limit <= 0) throw new Error(`Invalid rate-limit rule: limit must be a positive integer (got ${rule.limit})`);
|
|
1007
|
+
if (!Number.isFinite(rule.window) || rule.window <= 0) throw new Error(`Invalid rate-limit rule: window must be a positive ms value (got ${rule.window})`);
|
|
1008
|
+
}
|
|
1009
|
+
/** Normalize any {@link RateLimitRuleInput}. Throws on invalid grammar or non-positive values. */
|
|
1010
|
+
function parseRateLimitRule(input) {
|
|
1011
|
+
if (typeof input !== "string" && typeof input.window === "number") {
|
|
1012
|
+
assertValidRule(input);
|
|
1013
|
+
return input;
|
|
1014
|
+
}
|
|
1015
|
+
const rule = typeof input === "string" ? parseRuleString(input) : {
|
|
1016
|
+
limit: input.limit,
|
|
1017
|
+
window: parseDurationMs(input.window),
|
|
1018
|
+
...input.message !== void 0 && { message: input.message }
|
|
1019
|
+
};
|
|
1020
|
+
assertValidRule(rule);
|
|
1021
|
+
return rule;
|
|
1022
|
+
}
|
|
1023
|
+
/**
|
|
1024
|
+
* Render a 429 message template. Placeholders: `{{limit}}` (alias `{{max}}`),
|
|
1025
|
+
* `{{window}}` and `{{delta}}` — both humanized via {@link formatDurationMs}.
|
|
1026
|
+
*/
|
|
1027
|
+
function renderRateLimitMessage(template, rule, retryAfterMs) {
|
|
1028
|
+
return template.replaceAll("{{limit}}", String(rule.limit)).replaceAll("{{max}}", String(rule.limit)).replaceAll("{{window}}", formatDurationMs(rule.window)).replaceAll("{{delta}}", formatDurationMs(retryAfterMs));
|
|
1029
|
+
}
|
|
1030
|
+
//#endregion
|
|
1031
|
+
//#region src/rate-limit/store.ts
|
|
1032
|
+
/**
|
|
1033
|
+
* In-memory `RateLimitStore` — the zero-config default (single process only).
|
|
1034
|
+
* Lazy-evicts expired counters on access; `cleanup()` sweeps the whole map for
|
|
1035
|
+
* periodic compaction in long-lived processes (same pattern as
|
|
1036
|
+
* `DenylistStoreMemory`).
|
|
1037
|
+
*/
|
|
1038
|
+
var RateLimitStoreMemory = class {
|
|
1039
|
+
entries = /* @__PURE__ */ new Map();
|
|
1040
|
+
clock;
|
|
1041
|
+
constructor(opts) {
|
|
1042
|
+
this.clock = opts?.clock ?? defaultClock;
|
|
1043
|
+
}
|
|
1044
|
+
async hit(key, ttlMs) {
|
|
1045
|
+
const now = this.clock.now();
|
|
1046
|
+
const entry = this.entries.get(key);
|
|
1047
|
+
if (!entry || entry.expiresAt <= now) {
|
|
1048
|
+
this.entries.set(key, {
|
|
1049
|
+
count: 1,
|
|
1050
|
+
expiresAt: now + ttlMs
|
|
1051
|
+
});
|
|
1052
|
+
return 1;
|
|
1053
|
+
}
|
|
1054
|
+
entry.count += 1;
|
|
1055
|
+
return entry.count;
|
|
1056
|
+
}
|
|
1057
|
+
async reset(key) {
|
|
1058
|
+
this.entries.delete(key);
|
|
1059
|
+
}
|
|
1060
|
+
/** Drop every counter — test-harness reset between cases. */
|
|
1061
|
+
clear() {
|
|
1062
|
+
this.entries.clear();
|
|
1063
|
+
}
|
|
1064
|
+
async cleanup() {
|
|
1065
|
+
const now = this.clock.now();
|
|
1066
|
+
let removed = 0;
|
|
1067
|
+
for (const [key, entry] of this.entries) if (entry.expiresAt <= now) {
|
|
1068
|
+
this.entries.delete(key);
|
|
1069
|
+
removed++;
|
|
1070
|
+
}
|
|
1071
|
+
return removed;
|
|
1072
|
+
}
|
|
1073
|
+
};
|
|
1074
|
+
//#endregion
|
|
1075
|
+
//#region src/rate-limit/rate-limiter.ts
|
|
1076
|
+
/**
|
|
1077
|
+
* Fixed-window rate limiter with window-aligned keys (RL.spec.md §4.2).
|
|
1078
|
+
*
|
|
1079
|
+
* For each rule the current window starts at `floor(now / window) * window`;
|
|
1080
|
+
* the counter key embeds that start, so a new window is automatically a new
|
|
1081
|
+
* key and one atomic store increment decides. All rules are hit on every
|
|
1082
|
+
* check (rejected requests count too — they cannot extend a fixed window).
|
|
1083
|
+
*/
|
|
1084
|
+
var RateLimiter = class {
|
|
1085
|
+
store;
|
|
1086
|
+
clock;
|
|
1087
|
+
constructor(opts) {
|
|
1088
|
+
this.store = opts?.store ?? new RateLimitStoreMemory({ clock: opts?.clock });
|
|
1089
|
+
this.clock = opts?.clock ?? defaultClock;
|
|
1090
|
+
}
|
|
1091
|
+
/**
|
|
1092
|
+
* Evaluate `rules` for `scope` (handler/bucket id) + `subject` (caller
|
|
1093
|
+
* identity). Store keys are `<scope>:<subject>:<ruleIndex>:<windowStart>`
|
|
1094
|
+
* with scope/subject each URI-encoded — raw components would collide
|
|
1095
|
+
* (IPv6 addresses, route paths, and custom subjects all contain `:`).
|
|
1096
|
+
*
|
|
1097
|
+
* When several rules are violated at once, the governing rule is the one
|
|
1098
|
+
* whose window frees capacity LAST — the request only becomes allowed once
|
|
1099
|
+
* every rule passes, so a shorter hint would invite guaranteed-rejected
|
|
1100
|
+
* retries.
|
|
1101
|
+
*/
|
|
1102
|
+
async check(scope, subject, rules, opts) {
|
|
1103
|
+
if (rules.length === 0) throw new Error("RateLimiter.check: at least one rule is required");
|
|
1104
|
+
const parsed = rules.map((r) => parseRateLimitRule(r));
|
|
1105
|
+
const now = this.clock.now();
|
|
1106
|
+
const keyBase = `${encodeURIComponent(scope)}:${encodeURIComponent(subject)}`;
|
|
1107
|
+
const evaluated = await Promise.all(parsed.map(async (rule, i) => {
|
|
1108
|
+
const windowStart = Math.floor(now / rule.window) * rule.window;
|
|
1109
|
+
const key = `${keyBase}:${i}:${windowStart}`;
|
|
1110
|
+
const count = await this.store.hit(key, rule.window * 2);
|
|
1111
|
+
return {
|
|
1112
|
+
rule,
|
|
1113
|
+
remaining: Math.max(0, rule.limit - count),
|
|
1114
|
+
resetAt: windowStart + rule.window,
|
|
1115
|
+
violated: count > rule.limit
|
|
1116
|
+
};
|
|
1117
|
+
}));
|
|
1118
|
+
const violated = evaluated.filter((e) => e.violated);
|
|
1119
|
+
const rejected = violated.length > 0;
|
|
1120
|
+
const governing = rejected ? violated.reduce((a, b) => b.resetAt > a.resetAt ? b : a) : evaluated.reduce((a, b) => b.remaining < a.remaining ? b : a);
|
|
1121
|
+
const resetAfterMs = governing.resetAt - now;
|
|
1122
|
+
return {
|
|
1123
|
+
allowed: !rejected,
|
|
1124
|
+
limit: governing.rule.limit,
|
|
1125
|
+
remaining: governing.remaining,
|
|
1126
|
+
resetAt: governing.resetAt,
|
|
1127
|
+
resetAfterMs,
|
|
1128
|
+
policies: parsed,
|
|
1129
|
+
...rejected && {
|
|
1130
|
+
retryAfterMs: resetAfterMs,
|
|
1131
|
+
message: renderRateLimitMessage(governing.rule.message ?? opts?.defaultMessage ?? "Too many requests. Please try again in {{delta}}.", governing.rule, resetAfterMs)
|
|
1132
|
+
}
|
|
1133
|
+
};
|
|
1134
|
+
}
|
|
1135
|
+
};
|
|
1136
|
+
//#endregion
|
|
953
1137
|
//#region src/magic-link.ts
|
|
954
1138
|
/**
|
|
955
1139
|
* 32 bytes of CSPRNG entropy (256 bits) encoded as base64url — 43 chars,
|
|
@@ -959,4 +1143,4 @@ function generateMagicLinkToken() {
|
|
|
959
1143
|
return randomBytes(32).toString("base64url");
|
|
960
1144
|
}
|
|
961
1145
|
//#endregion
|
|
962
|
-
export { AuthCredential, AuthError, CredentialStoreEncapsulated, CredentialStoreJwt, CredentialStoreMemory, DenylistStoreMemory, defaultClock, generateMagicLinkToken };
|
|
1146
|
+
export { AuthCredential, AuthError, CredentialStoreEncapsulated, CredentialStoreJwt, CredentialStoreMemory, DEFAULT_RATE_LIMIT_MESSAGE, DenylistStoreMemory, RateLimitStoreMemory, RateLimiter, defaultClock, formatDurationMs, generateMagicLinkToken, parseDurationMs, parseRateLimitRule, renderRateLimitMessage };
|
package/dist/redis.cjs
CHANGED
|
@@ -146,6 +146,36 @@ var DenylistStoreRedis = class {
|
|
|
146
146
|
return 0;
|
|
147
147
|
}
|
|
148
148
|
};
|
|
149
|
+
/**
|
|
150
|
+
* Redis-backed `RateLimitStore` (RL.spec.md §4.3) — one atomic `INCR` per
|
|
151
|
+
* hit, `PEXPIRE` on first increment for garbage collection.
|
|
152
|
+
*
|
|
153
|
+
* Correctness never depends on the TTL: the limiter embeds the window start
|
|
154
|
+
* in the key, so a new window is a new key. If the process dies between
|
|
155
|
+
* `incr` and `pexpire`, the TTL-less key leaks a few bytes but is never
|
|
156
|
+
* counted again.
|
|
157
|
+
*/
|
|
158
|
+
var RateLimitStoreRedis = class {
|
|
159
|
+
redis;
|
|
160
|
+
prefix;
|
|
161
|
+
constructor(opts) {
|
|
162
|
+
this.redis = opts.redis;
|
|
163
|
+
this.prefix = opts.prefix ?? "aooth:rl";
|
|
164
|
+
}
|
|
165
|
+
key(key) {
|
|
166
|
+
return `${this.prefix}:${key}`;
|
|
167
|
+
}
|
|
168
|
+
async hit(key, ttlMs) {
|
|
169
|
+
const k = this.key(key);
|
|
170
|
+
const count = await this.redis.incr(k);
|
|
171
|
+
if (count === 1) await this.redis.pexpire(k, ttlMs);
|
|
172
|
+
return count;
|
|
173
|
+
}
|
|
174
|
+
async reset(key) {
|
|
175
|
+
await this.redis.del(this.key(key));
|
|
176
|
+
}
|
|
177
|
+
};
|
|
149
178
|
//#endregion
|
|
150
179
|
exports.CredentialStoreRedis = CredentialStoreRedis;
|
|
151
180
|
exports.DenylistStoreRedis = DenylistStoreRedis;
|
|
181
|
+
exports.RateLimitStoreRedis = RateLimitStoreRedis;
|
package/dist/redis.d.cts
CHANGED
|
@@ -1,22 +1,42 @@
|
|
|
1
1
|
import { a as CredentialState, n as DenylistStore, t as CredentialStore } from "./store-BG6m6oSJ.cjs";
|
|
2
|
+
import { t as RateLimitStore } from "./store-N9daSmHS.cjs";
|
|
2
3
|
|
|
3
4
|
//#region src/redis/index.d.ts
|
|
4
5
|
/**
|
|
5
6
|
* Structural Redis client. Covers the exact set of commands used by
|
|
6
|
-
* `CredentialStoreRedis` and `
|
|
7
|
+
* `CredentialStoreRedis`, `DenylistStoreRedis`, and `RateLimitStoreRedis` —
|
|
8
|
+
* no more.
|
|
7
9
|
*
|
|
8
|
-
* Compatible by-shape with `ioredis
|
|
9
|
-
*
|
|
10
|
-
*
|
|
10
|
+
* Compatible by-shape with `ioredis` and ad-hoc test doubles. Consumers are
|
|
11
|
+
* free to wrap whatever client they ship; we deliberately do not declare a
|
|
12
|
+
* peer dep on any specific package.
|
|
13
|
+
*
|
|
14
|
+
* ALL TTLs are MILLISECONDS. That is why the interface exposes `pexpire`
|
|
15
|
+
* rather than `expire` — redis's `EXPIRE` takes seconds, and a raw client
|
|
16
|
+
* passed by shape would otherwise satisfy the type while silently setting
|
|
17
|
+
* a 1000× TTL (a 5-minute counter becomes a ~3.5-day one). With `pexpire`
|
|
18
|
+
* a seconds-based double fails to typecheck instead of misbehaving.
|
|
19
|
+
* `node-redis` (`redis@4+`) users adapt the camelCase methods, e.g.:
|
|
20
|
+
*
|
|
21
|
+
* ```ts
|
|
22
|
+
* const redisLike: RedisLike = {
|
|
23
|
+
* set: (k, v, _px, ttlMs) => client.set(k, v, ttlMs !== undefined ? { PX: ttlMs } : {}),
|
|
24
|
+
* get: (k) => client.get(k),
|
|
25
|
+
* del: (...k) => client.del(k),
|
|
26
|
+
* exists: (k) => client.exists(k),
|
|
27
|
+
* pexpire: (k, ms) => client.pExpire(k, ms).then((ok) => (ok ? 1 : 0)),
|
|
28
|
+
* incr: (k) => client.incr(k),
|
|
29
|
+
* sadd: (k, ...m) => client.sAdd(k, m),
|
|
30
|
+
* srem: (k, ...m) => client.sRem(k, m),
|
|
31
|
+
* smembers: (k) => client.sMembers(k),
|
|
32
|
+
* }
|
|
33
|
+
* ```
|
|
11
34
|
*
|
|
12
35
|
* Return types are widened to the union of what real clients return:
|
|
13
36
|
* - `set` returns `'OK' | string | null` (null on conditional sets that fail)
|
|
14
|
-
* - `del` / `
|
|
37
|
+
* - `del` / `pexpire` / `exists` / `incr` / `sadd` / `srem` return `number`
|
|
15
38
|
* - `get` returns `string | null`
|
|
16
39
|
* - `smembers` returns `string[]`
|
|
17
|
-
*
|
|
18
|
-
* The `ttl` arg passed to `set` is in MILLISECONDS — we always call with
|
|
19
|
-
* `PX`, never `EX`, so callers don't have to translate.
|
|
20
40
|
*/
|
|
21
41
|
interface RedisLike {
|
|
22
42
|
/** `SET key value [PX ms]` — accepts an optional ms TTL. */
|
|
@@ -25,7 +45,9 @@ interface RedisLike {
|
|
|
25
45
|
del(...keys: string[]): Promise<number>;
|
|
26
46
|
exists(key: string): Promise<number>;
|
|
27
47
|
/** `PEXPIRE key ttlMs` — ms TTL on an existing key. */
|
|
28
|
-
|
|
48
|
+
pexpire(key: string, ttlMs: number): Promise<number>;
|
|
49
|
+
/** `INCR key` — atomic increment-and-get; creates the key at 1. */
|
|
50
|
+
incr(key: string): Promise<number>;
|
|
29
51
|
sadd(key: string, ...members: string[]): Promise<number>;
|
|
30
52
|
srem(key: string, ...members: string[]): Promise<number>;
|
|
31
53
|
smembers(key: string): Promise<string[]>;
|
|
@@ -88,5 +110,27 @@ declare class DenylistStoreRedis implements DenylistStore {
|
|
|
88
110
|
has(jti: string): Promise<boolean>;
|
|
89
111
|
cleanup(): Promise<number>;
|
|
90
112
|
}
|
|
113
|
+
interface RedisRateLimitStoreOptions {
|
|
114
|
+
redis: RedisLike;
|
|
115
|
+
/** Key prefix. Defaults to `aooth:rl`. Combined as `<prefix>:<limiter key>`. */
|
|
116
|
+
prefix?: string;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Redis-backed `RateLimitStore` (RL.spec.md §4.3) — one atomic `INCR` per
|
|
120
|
+
* hit, `PEXPIRE` on first increment for garbage collection.
|
|
121
|
+
*
|
|
122
|
+
* Correctness never depends on the TTL: the limiter embeds the window start
|
|
123
|
+
* in the key, so a new window is a new key. If the process dies between
|
|
124
|
+
* `incr` and `pexpire`, the TTL-less key leaks a few bytes but is never
|
|
125
|
+
* counted again.
|
|
126
|
+
*/
|
|
127
|
+
declare class RateLimitStoreRedis implements RateLimitStore {
|
|
128
|
+
private readonly redis;
|
|
129
|
+
private readonly prefix;
|
|
130
|
+
constructor(opts: RedisRateLimitStoreOptions);
|
|
131
|
+
private key;
|
|
132
|
+
hit(key: string, ttlMs: number): Promise<number>;
|
|
133
|
+
reset(key: string): Promise<void>;
|
|
134
|
+
}
|
|
91
135
|
//#endregion
|
|
92
|
-
export { CredentialStoreRedis, DenylistStoreRedis, RedisLike };
|
|
136
|
+
export { CredentialStoreRedis, DenylistStoreRedis, RateLimitStoreRedis, RedisLike };
|
package/dist/redis.d.mts
CHANGED
|
@@ -1,22 +1,42 @@
|
|
|
1
1
|
import { a as CredentialState, n as DenylistStore, t as CredentialStore } from "./store-BG6m6oSJ.mjs";
|
|
2
|
+
import { t as RateLimitStore } from "./store-jFgby4bK.mjs";
|
|
2
3
|
|
|
3
4
|
//#region src/redis/index.d.ts
|
|
4
5
|
/**
|
|
5
6
|
* Structural Redis client. Covers the exact set of commands used by
|
|
6
|
-
* `CredentialStoreRedis` and `
|
|
7
|
+
* `CredentialStoreRedis`, `DenylistStoreRedis`, and `RateLimitStoreRedis` —
|
|
8
|
+
* no more.
|
|
7
9
|
*
|
|
8
|
-
* Compatible by-shape with `ioredis
|
|
9
|
-
*
|
|
10
|
-
*
|
|
10
|
+
* Compatible by-shape with `ioredis` and ad-hoc test doubles. Consumers are
|
|
11
|
+
* free to wrap whatever client they ship; we deliberately do not declare a
|
|
12
|
+
* peer dep on any specific package.
|
|
13
|
+
*
|
|
14
|
+
* ALL TTLs are MILLISECONDS. That is why the interface exposes `pexpire`
|
|
15
|
+
* rather than `expire` — redis's `EXPIRE` takes seconds, and a raw client
|
|
16
|
+
* passed by shape would otherwise satisfy the type while silently setting
|
|
17
|
+
* a 1000× TTL (a 5-minute counter becomes a ~3.5-day one). With `pexpire`
|
|
18
|
+
* a seconds-based double fails to typecheck instead of misbehaving.
|
|
19
|
+
* `node-redis` (`redis@4+`) users adapt the camelCase methods, e.g.:
|
|
20
|
+
*
|
|
21
|
+
* ```ts
|
|
22
|
+
* const redisLike: RedisLike = {
|
|
23
|
+
* set: (k, v, _px, ttlMs) => client.set(k, v, ttlMs !== undefined ? { PX: ttlMs } : {}),
|
|
24
|
+
* get: (k) => client.get(k),
|
|
25
|
+
* del: (...k) => client.del(k),
|
|
26
|
+
* exists: (k) => client.exists(k),
|
|
27
|
+
* pexpire: (k, ms) => client.pExpire(k, ms).then((ok) => (ok ? 1 : 0)),
|
|
28
|
+
* incr: (k) => client.incr(k),
|
|
29
|
+
* sadd: (k, ...m) => client.sAdd(k, m),
|
|
30
|
+
* srem: (k, ...m) => client.sRem(k, m),
|
|
31
|
+
* smembers: (k) => client.sMembers(k),
|
|
32
|
+
* }
|
|
33
|
+
* ```
|
|
11
34
|
*
|
|
12
35
|
* Return types are widened to the union of what real clients return:
|
|
13
36
|
* - `set` returns `'OK' | string | null` (null on conditional sets that fail)
|
|
14
|
-
* - `del` / `
|
|
37
|
+
* - `del` / `pexpire` / `exists` / `incr` / `sadd` / `srem` return `number`
|
|
15
38
|
* - `get` returns `string | null`
|
|
16
39
|
* - `smembers` returns `string[]`
|
|
17
|
-
*
|
|
18
|
-
* The `ttl` arg passed to `set` is in MILLISECONDS — we always call with
|
|
19
|
-
* `PX`, never `EX`, so callers don't have to translate.
|
|
20
40
|
*/
|
|
21
41
|
interface RedisLike {
|
|
22
42
|
/** `SET key value [PX ms]` — accepts an optional ms TTL. */
|
|
@@ -25,7 +45,9 @@ interface RedisLike {
|
|
|
25
45
|
del(...keys: string[]): Promise<number>;
|
|
26
46
|
exists(key: string): Promise<number>;
|
|
27
47
|
/** `PEXPIRE key ttlMs` — ms TTL on an existing key. */
|
|
28
|
-
|
|
48
|
+
pexpire(key: string, ttlMs: number): Promise<number>;
|
|
49
|
+
/** `INCR key` — atomic increment-and-get; creates the key at 1. */
|
|
50
|
+
incr(key: string): Promise<number>;
|
|
29
51
|
sadd(key: string, ...members: string[]): Promise<number>;
|
|
30
52
|
srem(key: string, ...members: string[]): Promise<number>;
|
|
31
53
|
smembers(key: string): Promise<string[]>;
|
|
@@ -88,5 +110,27 @@ declare class DenylistStoreRedis implements DenylistStore {
|
|
|
88
110
|
has(jti: string): Promise<boolean>;
|
|
89
111
|
cleanup(): Promise<number>;
|
|
90
112
|
}
|
|
113
|
+
interface RedisRateLimitStoreOptions {
|
|
114
|
+
redis: RedisLike;
|
|
115
|
+
/** Key prefix. Defaults to `aooth:rl`. Combined as `<prefix>:<limiter key>`. */
|
|
116
|
+
prefix?: string;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Redis-backed `RateLimitStore` (RL.spec.md §4.3) — one atomic `INCR` per
|
|
120
|
+
* hit, `PEXPIRE` on first increment for garbage collection.
|
|
121
|
+
*
|
|
122
|
+
* Correctness never depends on the TTL: the limiter embeds the window start
|
|
123
|
+
* in the key, so a new window is a new key. If the process dies between
|
|
124
|
+
* `incr` and `pexpire`, the TTL-less key leaks a few bytes but is never
|
|
125
|
+
* counted again.
|
|
126
|
+
*/
|
|
127
|
+
declare class RateLimitStoreRedis implements RateLimitStore {
|
|
128
|
+
private readonly redis;
|
|
129
|
+
private readonly prefix;
|
|
130
|
+
constructor(opts: RedisRateLimitStoreOptions);
|
|
131
|
+
private key;
|
|
132
|
+
hit(key: string, ttlMs: number): Promise<number>;
|
|
133
|
+
reset(key: string): Promise<void>;
|
|
134
|
+
}
|
|
91
135
|
//#endregion
|
|
92
|
-
export { CredentialStoreRedis, DenylistStoreRedis, RedisLike };
|
|
136
|
+
export { CredentialStoreRedis, DenylistStoreRedis, RateLimitStoreRedis, RedisLike };
|
package/dist/redis.mjs
CHANGED
|
@@ -145,5 +145,34 @@ var DenylistStoreRedis = class {
|
|
|
145
145
|
return 0;
|
|
146
146
|
}
|
|
147
147
|
};
|
|
148
|
+
/**
|
|
149
|
+
* Redis-backed `RateLimitStore` (RL.spec.md §4.3) — one atomic `INCR` per
|
|
150
|
+
* hit, `PEXPIRE` on first increment for garbage collection.
|
|
151
|
+
*
|
|
152
|
+
* Correctness never depends on the TTL: the limiter embeds the window start
|
|
153
|
+
* in the key, so a new window is a new key. If the process dies between
|
|
154
|
+
* `incr` and `pexpire`, the TTL-less key leaks a few bytes but is never
|
|
155
|
+
* counted again.
|
|
156
|
+
*/
|
|
157
|
+
var RateLimitStoreRedis = class {
|
|
158
|
+
redis;
|
|
159
|
+
prefix;
|
|
160
|
+
constructor(opts) {
|
|
161
|
+
this.redis = opts.redis;
|
|
162
|
+
this.prefix = opts.prefix ?? "aooth:rl";
|
|
163
|
+
}
|
|
164
|
+
key(key) {
|
|
165
|
+
return `${this.prefix}:${key}`;
|
|
166
|
+
}
|
|
167
|
+
async hit(key, ttlMs) {
|
|
168
|
+
const k = this.key(key);
|
|
169
|
+
const count = await this.redis.incr(k);
|
|
170
|
+
if (count === 1) await this.redis.pexpire(k, ttlMs);
|
|
171
|
+
return count;
|
|
172
|
+
}
|
|
173
|
+
async reset(key) {
|
|
174
|
+
await this.redis.del(this.key(key));
|
|
175
|
+
}
|
|
176
|
+
};
|
|
148
177
|
//#endregion
|
|
149
|
-
export { CredentialStoreRedis, DenylistStoreRedis };
|
|
178
|
+
export { CredentialStoreRedis, DenylistStoreRedis, RateLimitStoreRedis };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { t as Clock } from "./clock-BjXa0LXb.cjs";
|
|
2
|
+
|
|
3
|
+
//#region src/rate-limit/store.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Storage contract for the fixed-window limiter (RL.spec.md §4.3).
|
|
6
|
+
*
|
|
7
|
+
* The limiter embeds the window start in the key, so a new window is a new
|
|
8
|
+
* key and correctness never depends on the TTL — the TTL only garbage-collects
|
|
9
|
+
* dead counters.
|
|
10
|
+
*/
|
|
11
|
+
interface RateLimitStore {
|
|
12
|
+
/**
|
|
13
|
+
* Atomically increment `key`, setting `ttlMs` expiry when the key is
|
|
14
|
+
* created; returns the new count.
|
|
15
|
+
*/
|
|
16
|
+
hit(key: string, ttlMs: number): Promise<number>;
|
|
17
|
+
/** Optional: drop a counter (tests / admin unblock). */
|
|
18
|
+
reset?(key: string): Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* In-memory `RateLimitStore` — the zero-config default (single process only).
|
|
22
|
+
* Lazy-evicts expired counters on access; `cleanup()` sweeps the whole map for
|
|
23
|
+
* periodic compaction in long-lived processes (same pattern as
|
|
24
|
+
* `DenylistStoreMemory`).
|
|
25
|
+
*/
|
|
26
|
+
declare class RateLimitStoreMemory implements RateLimitStore {
|
|
27
|
+
private readonly entries;
|
|
28
|
+
private readonly clock;
|
|
29
|
+
constructor(opts?: {
|
|
30
|
+
clock?: Clock;
|
|
31
|
+
});
|
|
32
|
+
hit(key: string, ttlMs: number): Promise<number>;
|
|
33
|
+
reset(key: string): Promise<void>;
|
|
34
|
+
/** Drop every counter — test-harness reset between cases. */
|
|
35
|
+
clear(): void;
|
|
36
|
+
cleanup(): Promise<number>;
|
|
37
|
+
}
|
|
38
|
+
//#endregion
|
|
39
|
+
export { RateLimitStoreMemory as n, RateLimitStore as t };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { t as Clock } from "./clock-BjXa0LXb.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/rate-limit/store.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Storage contract for the fixed-window limiter (RL.spec.md §4.3).
|
|
6
|
+
*
|
|
7
|
+
* The limiter embeds the window start in the key, so a new window is a new
|
|
8
|
+
* key and correctness never depends on the TTL — the TTL only garbage-collects
|
|
9
|
+
* dead counters.
|
|
10
|
+
*/
|
|
11
|
+
interface RateLimitStore {
|
|
12
|
+
/**
|
|
13
|
+
* Atomically increment `key`, setting `ttlMs` expiry when the key is
|
|
14
|
+
* created; returns the new count.
|
|
15
|
+
*/
|
|
16
|
+
hit(key: string, ttlMs: number): Promise<number>;
|
|
17
|
+
/** Optional: drop a counter (tests / admin unblock). */
|
|
18
|
+
reset?(key: string): Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* In-memory `RateLimitStore` — the zero-config default (single process only).
|
|
22
|
+
* Lazy-evicts expired counters on access; `cleanup()` sweeps the whole map for
|
|
23
|
+
* periodic compaction in long-lived processes (same pattern as
|
|
24
|
+
* `DenylistStoreMemory`).
|
|
25
|
+
*/
|
|
26
|
+
declare class RateLimitStoreMemory implements RateLimitStore {
|
|
27
|
+
private readonly entries;
|
|
28
|
+
private readonly clock;
|
|
29
|
+
constructor(opts?: {
|
|
30
|
+
clock?: Clock;
|
|
31
|
+
});
|
|
32
|
+
hit(key: string, ttlMs: number): Promise<number>;
|
|
33
|
+
reset(key: string): Promise<void>;
|
|
34
|
+
/** Drop every counter — test-harness reset between cases. */
|
|
35
|
+
clear(): void;
|
|
36
|
+
cleanup(): Promise<number>;
|
|
37
|
+
}
|
|
38
|
+
//#endregion
|
|
39
|
+
export { RateLimitStoreMemory as n, RateLimitStore as t };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aooth/auth",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.34",
|
|
4
4
|
"description": "Auth method layer for aoothjs (sessions, tokens, password reset, MFA primitives)",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"aoothjs",
|
|
@@ -104,7 +104,7 @@
|
|
|
104
104
|
},
|
|
105
105
|
"dependencies": {
|
|
106
106
|
"jose": "^6.2.3",
|
|
107
|
-
"@aooth/user": "0.1.
|
|
107
|
+
"@aooth/user": "0.1.34"
|
|
108
108
|
},
|
|
109
109
|
"devDependencies": {
|
|
110
110
|
"@atscript/core": "^0.1.78",
|