kinetex 1.3.0 → 1.5.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.
Files changed (123) hide show
  1. package/README.md +413 -52
  2. package/dist/browser/kinetex.esm.js +38 -22
  3. package/dist/browser/kinetex.js +3134 -787
  4. package/dist/browser/kinetex.min.js +38 -22
  5. package/dist/cjs/aws-sigv4.js +140 -33
  6. package/dist/cjs/cache.js +58 -7
  7. package/dist/cjs/circuit-breaker.js +53 -4
  8. package/dist/cjs/client.js +726 -188
  9. package/dist/cjs/cookie-parser.js +131 -6
  10. package/dist/cjs/cookie-store.js +125 -28
  11. package/dist/cjs/core.js +691 -70
  12. package/dist/cjs/dedup.js +142 -21
  13. package/dist/cjs/digest.js +195 -38
  14. package/dist/cjs/graphql.js +166 -35
  15. package/dist/cjs/headers.js +327 -46
  16. package/dist/cjs/interceptors.js +227 -24
  17. package/dist/cjs/lifecycle.js +92 -59
  18. package/dist/cjs/logging.js +160 -24
  19. package/dist/cjs/mod.js +3 -2
  20. package/dist/cjs/pagination.js +247 -22
  21. package/dist/cjs/progress.js +178 -30
  22. package/dist/cjs/proxy.js +429 -0
  23. package/dist/cjs/response.js +506 -110
  24. package/dist/cjs/socks5.js +136 -15
  25. package/dist/cjs/sse.js +173 -43
  26. package/dist/cjs/url.js +191 -45
  27. package/dist/cjs/utils.js +335 -73
  28. package/dist/cjs/ws.js +62 -12
  29. package/dist/esm/aws-sigv4.js +140 -33
  30. package/dist/esm/aws-sigv4.js.map +1 -1
  31. package/dist/esm/cache.js +58 -7
  32. package/dist/esm/cache.js.map +1 -1
  33. package/dist/esm/circuit-breaker.js +53 -4
  34. package/dist/esm/circuit-breaker.js.map +1 -1
  35. package/dist/esm/client.js +726 -188
  36. package/dist/esm/client.js.map +1 -1
  37. package/dist/esm/cookie-parser.js +131 -6
  38. package/dist/esm/cookie-parser.js.map +1 -1
  39. package/dist/esm/cookie-store.js +125 -28
  40. package/dist/esm/cookie-store.js.map +1 -1
  41. package/dist/esm/core.js +691 -70
  42. package/dist/esm/core.js.map +1 -1
  43. package/dist/esm/dedup.js +142 -21
  44. package/dist/esm/dedup.js.map +1 -1
  45. package/dist/esm/digest.js +195 -38
  46. package/dist/esm/digest.js.map +1 -1
  47. package/dist/esm/graphql.js +166 -35
  48. package/dist/esm/graphql.js.map +1 -1
  49. package/dist/esm/headers.js +327 -46
  50. package/dist/esm/headers.js.map +1 -1
  51. package/dist/esm/interceptors.js +227 -24
  52. package/dist/esm/interceptors.js.map +1 -1
  53. package/dist/esm/lifecycle.js +92 -59
  54. package/dist/esm/lifecycle.js.map +1 -1
  55. package/dist/esm/logging.js +160 -24
  56. package/dist/esm/logging.js.map +1 -1
  57. package/dist/esm/mod.js +3 -2
  58. package/dist/esm/mod.js.map +1 -1
  59. package/dist/esm/pagination.js +247 -22
  60. package/dist/esm/pagination.js.map +1 -1
  61. package/dist/esm/progress.js +178 -30
  62. package/dist/esm/progress.js.map +1 -1
  63. package/dist/esm/proxy.js +430 -0
  64. package/dist/esm/proxy.js.map +1 -0
  65. package/dist/esm/response.js +506 -110
  66. package/dist/esm/response.js.map +1 -1
  67. package/dist/esm/socks5.js +136 -15
  68. package/dist/esm/socks5.js.map +1 -1
  69. package/dist/esm/sse.js +173 -43
  70. package/dist/esm/sse.js.map +1 -1
  71. package/dist/esm/types.js.map +1 -1
  72. package/dist/esm/url.js +191 -45
  73. package/dist/esm/url.js.map +1 -1
  74. package/dist/esm/utils.js +335 -73
  75. package/dist/esm/utils.js.map +1 -1
  76. package/dist/esm/ws.js +62 -12
  77. package/dist/esm/ws.js.map +1 -1
  78. package/dist/types/aws-sigv4.d.ts.map +1 -1
  79. package/dist/types/cache.d.ts +19 -1
  80. package/dist/types/cache.d.ts.map +1 -1
  81. package/dist/types/circuit-breaker.d.ts +15 -2
  82. package/dist/types/circuit-breaker.d.ts.map +1 -1
  83. package/dist/types/client.d.ts +69 -11
  84. package/dist/types/client.d.ts.map +1 -1
  85. package/dist/types/cookie-parser.d.ts +0 -17
  86. package/dist/types/cookie-parser.d.ts.map +1 -1
  87. package/dist/types/cookie-store.d.ts.map +1 -1
  88. package/dist/types/core.d.ts +216 -25
  89. package/dist/types/core.d.ts.map +1 -1
  90. package/dist/types/dedup.d.ts +38 -3
  91. package/dist/types/dedup.d.ts.map +1 -1
  92. package/dist/types/digest.d.ts +17 -37
  93. package/dist/types/digest.d.ts.map +1 -1
  94. package/dist/types/graphql.d.ts.map +1 -1
  95. package/dist/types/headers.d.ts +68 -31
  96. package/dist/types/headers.d.ts.map +1 -1
  97. package/dist/types/interceptors.d.ts +126 -38
  98. package/dist/types/interceptors.d.ts.map +1 -1
  99. package/dist/types/lifecycle.d.ts +19 -2
  100. package/dist/types/lifecycle.d.ts.map +1 -1
  101. package/dist/types/logging.d.ts +22 -3
  102. package/dist/types/logging.d.ts.map +1 -1
  103. package/dist/types/mod.d.ts +5 -3
  104. package/dist/types/mod.d.ts.map +1 -1
  105. package/dist/types/pagination.d.ts +0 -25
  106. package/dist/types/pagination.d.ts.map +1 -1
  107. package/dist/types/progress.d.ts +1 -1
  108. package/dist/types/progress.d.ts.map +1 -1
  109. package/dist/types/proxy.d.ts +50 -0
  110. package/dist/types/proxy.d.ts.map +1 -0
  111. package/dist/types/response.d.ts +49 -2
  112. package/dist/types/response.d.ts.map +1 -1
  113. package/dist/types/socks5.d.ts.map +1 -1
  114. package/dist/types/sse.d.ts.map +1 -1
  115. package/dist/types/types.d.ts +219 -26
  116. package/dist/types/types.d.ts.map +1 -1
  117. package/dist/types/url.d.ts +0 -25
  118. package/dist/types/url.d.ts.map +1 -1
  119. package/dist/types/utils.d.ts +69 -1
  120. package/dist/types/utils.d.ts.map +1 -1
  121. package/dist/types/ws.d.ts +0 -1
  122. package/dist/types/ws.d.ts.map +1 -1
  123. package/package.json +10 -4
package/dist/cjs/dedup.js CHANGED
@@ -19,6 +19,81 @@
19
19
  // ============================================================================
20
20
  // §2 DEDUP MAP
21
21
  // ============================================================================
22
+ /**
23
+ * Headers that are per-request noise rather than part of a request's identity.
24
+ *
25
+ * Two callers sending different trace ids are the same request as far as the
26
+ * response is concerned, so including them would only cost the coalescing that
27
+ * dedup exists to provide. Everything else — `Authorization`, `Cookie`,
28
+ * `Accept`, any custom header a gateway keys on — participates, so a request is
29
+ * only ever coalesced with one that would have received the same response.
30
+ */
31
+ /**
32
+ * The default dedup key: method, URL, and the identity-bearing headers.
33
+ *
34
+ * Sorting makes the key order-independent, since two requests that differ only
35
+ * in header order are the same request. Values are included verbatim: a caller
36
+ * that wants coarser or finer granularity supplies a `keyFn`.
37
+ */
38
+ /**
39
+ * Headers excluded from a request's identity, as with {@link defaultDedupKey}.
40
+ * Duplicated rather than imported so `dedup.ts` stays dependency-free; the two
41
+ * lists must be kept in step.
42
+ */
43
+ const NON_IDENTITY_HEADERS = [
44
+ "traceparent",
45
+ "tracestate",
46
+ "baggage",
47
+ "x-request-id",
48
+ "x-correlation-id",
49
+ "x-trace-id",
50
+ ];
51
+ /**
52
+ * A stable, opaque digest of everything that makes a request distinct.
53
+ *
54
+ * Hashing rather than concatenating keeps credentials out of the key: a dedup
55
+ * map is a long-lived map of strings, and its keys should not be a place where
56
+ * an `Authorization` header sits in plain text. Returns `""` when no header
57
+ * distinguishes the request, so the common unauthenticated case keys on method
58
+ * and URL alone.
59
+ */
60
+ export async function identityFingerprint(headers) {
61
+ if (headers === undefined)
62
+ return "";
63
+ const lowered = new Map();
64
+ for (const [name, value] of Object.entries(headers)) {
65
+ lowered.set(name.toLowerCase(), value);
66
+ }
67
+ const parts = [];
68
+ for (const name of NON_IDENTITY_HEADERS)
69
+ lowered.delete(name);
70
+ for (const name of [...lowered.keys()].sort()) {
71
+ parts.push(`${name}=${lowered.get(name)}`);
72
+ }
73
+ if (parts.length === 0)
74
+ return "";
75
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(parts.join("|")));
76
+ return Array.from(new Uint8Array(digest))
77
+ .map((b) => b.toString(16).padStart(2, "0"))
78
+ .join("");
79
+ }
80
+ /**
81
+ * Build a dedup key from a method, URL and identity digest.
82
+ */
83
+ export function dedupKeyFrom(method, url, fingerprint) {
84
+ return fingerprint ? `${method}:${url}:${fingerprint}` : `${method}:${url}`;
85
+ }
86
+ export function defaultDedupKey(method, url, headers) {
87
+ if (headers === undefined)
88
+ return `${method}::${url}`;
89
+ const identity = Object.entries(headers)
90
+ .filter(([name]) => !NON_IDENTITY_HEADERS.includes(name.toLowerCase()))
91
+ .map(([name, value]) => `${name.toLowerCase()}=${value}`)
92
+ .sort();
93
+ // No distinguishing header means the key stays method + URL, so an empty
94
+ // header set does not silently become a different key shape.
95
+ return identity.length === 0 ? `${method}::${url}` : `${method}::${url}::${identity.join("&")}`;
96
+ }
22
97
  /**
23
98
  * `DedupMap` manages in-flight promises keyed by a request fingerprint.
24
99
  * It is transport-agnostic — pass any async factory function.
@@ -26,6 +101,9 @@
26
101
  export class DedupMap {
27
102
  inflight = new Map();
28
103
  timeouts = new Map();
104
+ /** Window timers, overridable for tests. See {@link DedupOptions.timers}. */
105
+ _setTimeout;
106
+ _clearTimeout;
29
107
  windowMs;
30
108
  methods;
31
109
  keyFn;
@@ -39,8 +117,10 @@ export class DedupMap {
39
117
  constructor(options = {}) {
40
118
  this.windowMs = options.windowMs ?? 0;
41
119
  this.methods = new Set((options.methods ?? ["GET", "HEAD"]).map((m) => m.toUpperCase()));
42
- this.keyFn = options.keyFn ?? ((m, url) => `${m}::${url}`);
120
+ this.keyFn = options.keyFn ?? defaultDedupKey;
43
121
  this._globalSignal = options.signal ?? null;
122
+ this._setTimeout = options.timers?.setTimeout ?? ((fn, ms) => setTimeout(fn, ms));
123
+ this._clearTimeout = options.timers?.clearTimeout ?? ((h) => clearTimeout(h));
44
124
  }
45
125
  /**
46
126
  * Execute `factory` for the given method/url, or return the in-flight
@@ -64,9 +144,12 @@ export class DedupMap {
64
144
  * entry and creating a new one. The first caller becomes the "leader" and all
65
145
  * subsequent callers within the same tick share the same promise.
66
146
  *
67
- * Note: The default `keyFn` does NOT include headers in the deduplication key.
68
- * Two requests with different auth tokens to the same URL will NOT be deduplicated
69
- * unless you provide a custom `keyFn` that includes headers.
147
+ * Note: the default key includes the request headers, so two requests to the
148
+ * same URL carrying different `Authorization` or `Cookie` are separate
149
+ * requests and are never coalesced. A key of method + URL alone was the
150
+ * default once, and it handed one caller's response to another: 50 callers
151
+ * aside, even two callers for different tenants shared a single response.
152
+ * See {@link defaultDedupKey}.
70
153
  */
71
154
  execute(method, url, factory, headers, windowMs, signal) {
72
155
  // Use per-key TTL if provided, otherwise fall back to global windowMs (F-3)
@@ -75,29 +158,54 @@ export class DedupMap {
75
158
  const effectiveSignal = signal ?? this._globalSignal;
76
159
  // Handle abort signal (F-2): if signal is already aborted, don't dedupe
77
160
  if (effectiveSignal?.aborted) {
161
+ // Still a real network call. It used to return before the counters were
162
+ // touched, so a client whose method filter excluded everything reported
163
+ // totalRequests 0 and a hitRate of 0 after any number of real requests
164
+ // — and `misses` is documented as "requests that triggered a real
165
+ // network call". Both bypasses below now count.
166
+ this._misses++;
78
167
  return factory();
79
168
  }
80
169
  if (!this.methods.has(method.toUpperCase())) {
170
+ this._misses++;
81
171
  return factory();
82
172
  }
83
- const key = this.keyFn(method, url, headers);
173
+ // The method is canonicalised before it reaches the key function. The
174
+ // methods filter has always been case-insensitive, but the key was built
175
+ // from the caller's spelling, so `execute("get", u)` and
176
+ // `execute("GET", u)` were two different keys and never coalesced — the
177
+ // one case a caller gets wrong most often, and one the library otherwise
178
+ // treats as equal everywhere.
179
+ const canonicalMethod = method.toUpperCase();
180
+ const key = this.keyFn(canonicalMethod, url, headers);
84
181
  const existing = this.inflight.get(key);
85
182
  if (existing) {
86
- // Within window: return cached result
87
- if (existing.resolvedAt !== null &&
88
- effectiveWindowMs > 0 &&
89
- Date.now() - existing.resolvedAt <= effectiveWindowMs) {
183
+ // In-flight: share the promise. This is coalescing, not caching, so it
184
+ // is not gated on the window.
185
+ if (existing.resolvedAt === null) {
90
186
  this._hits++;
91
- // Return the typed promise directly - preserves error type
92
187
  return existing.promise;
93
188
  }
94
- // In-flight: share the promise
95
- if (existing.resolvedAt === null) {
189
+ // Within window: return cached result
190
+ if (effectiveWindowMs > 0 && Date.now() - existing.resolvedAt <= effectiveWindowMs) {
96
191
  this._hits++;
192
+ // Return the typed promise directly - preserves error type
97
193
  return existing.promise;
98
194
  }
99
- // Expired window — fall through to new request
100
- this.inflight.delete(key);
195
+ if (effectiveWindowMs > 0) {
196
+ // This caller's own window has lapsed — drop the stale entry and lead.
197
+ this.inflight.delete(key);
198
+ }
199
+ else {
200
+ // This caller explicitly asked for no window, so it gets its own
201
+ // request. It used to fall through to `inflight.delete(key)` and lead
202
+ // as well, which destroyed a window some *other* caller had
203
+ // established and was still relying on: one `windowMs: 0` call was
204
+ // enough to empty the cache for everyone, and the next windowed caller
205
+ // had to go to the network for a result that was still in hand.
206
+ this._misses++;
207
+ return factory();
208
+ }
101
209
  }
102
210
  this._misses++;
103
211
  // Create entry and register it in the inflight map BEFORE starting the async work
@@ -113,7 +221,7 @@ export class DedupMap {
113
221
  this.inflight.delete(key);
114
222
  const existingTimeout = this.timeouts.get(key);
115
223
  if (existingTimeout !== undefined) {
116
- clearTimeout(existingTimeout);
224
+ this._clearTimeout(existingTimeout);
117
225
  this.timeouts.delete(key);
118
226
  }
119
227
  }
@@ -132,6 +240,16 @@ export class DedupMap {
132
240
  })().then((result) => {
133
241
  entry.resolvedAt = Date.now();
134
242
  entry.result = result;
243
+ // This entry may have lost the key while the factory was running: an
244
+ // abort, a `clear()`, or an `invalidate()` all drop it, and a later
245
+ // caller for the same URL then becomes the leader. Registering a
246
+ // window timer here regardless re-inserted a timer for a key someone
247
+ // else now owns, and when it fired it deleted their still-in-flight
248
+ // entry — so the request after that started a third copy. The
249
+ // identity check is what makes "who owns this key" decidable.
250
+ if (this.inflight.get(key) !== entry) {
251
+ return result;
252
+ }
135
253
  if (effectiveWindowMs <= 0) {
136
254
  this.inflight.delete(key);
137
255
  }
@@ -142,10 +260,10 @@ export class DedupMap {
142
260
  // out from under callers that were sharing it.
143
261
  const stale = this.timeouts.get(key);
144
262
  if (stale !== undefined) {
145
- clearTimeout(stale);
263
+ this._clearTimeout(stale);
146
264
  this.timeouts.delete(key);
147
265
  }
148
- const timeoutId = setTimeout(() => {
266
+ const timeoutId = this._setTimeout(() => {
149
267
  this.inflight.delete(key);
150
268
  this.timeouts.delete(key);
151
269
  }, effectiveWindowMs);
@@ -155,10 +273,13 @@ export class DedupMap {
155
273
  }, (err) => {
156
274
  entry.resolvedAt = Date.now();
157
275
  entry.error = err;
158
- this.inflight.delete(key);
276
+ // Same ownership rule as the success path: an entry that has already
277
+ // been dropped must not delete the entry that replaced it.
278
+ if (this.inflight.get(key) === entry)
279
+ this.inflight.delete(key);
159
280
  const existingTimeout = this.timeouts.get(key);
160
281
  if (existingTimeout !== undefined) {
161
- clearTimeout(existingTimeout);
282
+ this._clearTimeout(existingTimeout);
162
283
  this.timeouts.delete(key);
163
284
  }
164
285
  throw err;
@@ -216,7 +337,7 @@ export class DedupMap {
216
337
  this.inflight.clear();
217
338
  // Clear pending timeouts (B-1)
218
339
  for (const timeoutId of this.timeouts.values()) {
219
- clearTimeout(timeoutId);
340
+ this._clearTimeout(timeoutId);
220
341
  }
221
342
  this.timeouts.clear();
222
343
  }
@@ -230,7 +351,7 @@ export class DedupMap {
230
351
  invalidate(key) {
231
352
  const timeoutId = this.timeouts.get(key);
232
353
  if (timeoutId) {
233
- clearTimeout(timeoutId);
354
+ this._clearTimeout(timeoutId);
234
355
  this.timeouts.delete(key);
235
356
  }
236
357
  return this.inflight.delete(key);
@@ -8,7 +8,8 @@
8
8
  * WWW-Authenticate challenge, compute the response hash, and format the
9
9
  * Authorization header in one call.
10
10
  */
11
- import { randomBytes } from "./utils.js";
11
+ import { randomBytes, uint8ArrayToBase64 } from "./utils.js";
12
+ import { sha256Hex } from "./utils.js";
12
13
  // ============================================================================
13
14
  // §1 MD5 — Pure JS (no Web Crypto dependency)
14
15
  // ============================================================================
@@ -69,11 +70,14 @@ function md5(input) {
69
70
  for (let offset = 0; offset < bytes.length; offset += 64) {
70
71
  const M = [];
71
72
  for (let i = 0; i < 16; i++) {
72
- const b0 = bytes[offset + i * 4];
73
- const b1 = bytes[offset + i * 4 + 1];
74
- const b2 = bytes[offset + i * 4 + 2];
75
- const b3 = bytes[offset + i * 4 + 3];
76
- M[i] = (b0 | (b1 << 8) | (b2 << 16) | (b3 << 24)) >>> 0;
73
+ // Named `w0..w3`, not `b0..b3`: `b0` shadowed the outer MD5 state variable
74
+ // of the same name, so the running state and the little-endian word read
75
+ // out of the block were indistinguishable at a glance.
76
+ const w0 = bytes[offset + i * 4];
77
+ const w1 = bytes[offset + i * 4 + 1];
78
+ const w2 = bytes[offset + i * 4 + 2];
79
+ const w3 = bytes[offset + i * 4 + 3];
80
+ M[i] = (w0 | (w1 << 8) | (w2 << 16) | (w3 << 24)) >>> 0;
77
81
  }
78
82
  let A = a0, B = b0, C = c0, D = d0;
79
83
  for (let i = 0; i < 64; i++) {
@@ -133,16 +137,37 @@ function md5(input) {
133
137
  * console.log(challenge.realm); // "testrealm@host.com"
134
138
  * ```
135
139
  */
140
+ /** Decode a hex digest string into its raw bytes. */
141
+ function hexToBytes(hex) {
142
+ const out = new Uint8Array(hex.length / 2);
143
+ for (let i = 0; i < out.length; i++) {
144
+ out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
145
+ }
146
+ return out;
147
+ }
148
+ /** Decode `quoted-pair` escapes (RFC 7230 §3.2.6) in a quoted-string body. */
149
+ function unescapeQuoted(value) {
150
+ return value.replace(/\\(.)/g, "$1");
151
+ }
136
152
  export function parseDigestChallenge(header) {
137
153
  const challenge = {
138
154
  algorithm: "MD5",
139
155
  };
140
156
  const cleaned = header.replace(/^Digest\s+/i, "").trim();
141
- const regex = /(\w+)\s*=\s*(?:"([^"]*)"|([^,\s]+))/g;
157
+ // A quoted-string may contain a `quoted-pair` (RFC 7230 §3.2.6): `"` and
158
+ // `\\` are literal characters, not delimiters. The previous `"([^"]*)"`
159
+ // stopped at the first quote regardless of a preceding backslash, so
160
+ // `realm="a\"b"` parsed as `a\` and left `b"` to be re-scanned as if it
161
+ // were another parameter — a realm containing a quote silently truncated
162
+ // the challenge, and `realm="x\", qop="auth-int"` did the same to `qop`.
163
+ // Round-tripping a header produced by `formatDigestAuth` was therefore
164
+ // lossy for exactly the values that need escaping.
165
+ const quotedString = '"((?:[^"\\\\]|\\\\.)*)"';
166
+ const regex = new RegExp(`(\\w+)\\s*=\\s*(?:${quotedString}|([^,\\s]+))`, "g");
142
167
  let match;
143
168
  while ((match = regex.exec(cleaned)) !== null) {
144
169
  const key = match[1].toLowerCase();
145
- const value = (match[2] ?? match[3]);
170
+ const value = match[2] !== undefined ? unescapeQuoted(match[2]) : match[3];
146
171
  switch (key) {
147
172
  case "realm":
148
173
  challenge.realm = value;
@@ -188,14 +213,6 @@ export function parseDigestChallenge(header) {
188
213
  * @param data - String to hash
189
214
  * @returns 64-char hex-encoded SHA-256 digest
190
215
  */
191
- async function sha256Hex(data) {
192
- const enc = new TextEncoder();
193
- const buf = await crypto.subtle.digest("SHA-256", enc.encode(data));
194
- const bytes = new Uint8Array(buf);
195
- return Array.from(bytes)
196
- .map((b) => b.toString(16).padStart(2, "0"))
197
- .join("");
198
- }
199
216
  // ============================================================================
200
217
  // §3c SHA-512/256 — Pure-JS (FIPS 180-4)
201
218
  // ============================================================================
@@ -428,7 +445,11 @@ export async function computeDigestResponse(challenge, username, password, metho
428
445
  const realm = challenge.realm;
429
446
  const _cnonce = cnonce || randomBytes(5);
430
447
  const _nc = nc || "00000001";
431
- const algo = resolveHashAlgo(challenge.algorithm);
448
+ const resolved = resolveHashAlgo(challenge.algorithm);
449
+ if (resolved === null) {
450
+ throw new Error(`Unsupported digest algorithm: ${challenge.algorithm}`);
451
+ }
452
+ const { algo, sess } = resolved;
432
453
  async function hash(s) {
433
454
  if (algo === "MD5")
434
455
  return md5(s);
@@ -436,12 +457,44 @@ export async function computeDigestResponse(challenge, username, password, metho
436
457
  return sha512256Hex(s);
437
458
  return await sha256Hex(s);
438
459
  }
439
- const ha1 = await hash(`${username}:${realm}:${password}`);
460
+ // `-sess` binds the session (RFC 7616 §3.4.4/§3.4.5): the client nonce and
461
+ // the server nonce are folded into HA1, so the same credentials cannot be
462
+ // replayed under a different nonce. It requires `qop` to be present,
463
+ // because without one there is no cnonce to fold in.
464
+ // With `userhash=true`, RFC 7616 §3.4.4 substitutes the hashed username
465
+ // for the plaintext one everywhere it appears — including inside HA1.
466
+ const user = challenge.userhash
467
+ ? uint8ArrayToBase64(hexToBytes(await hash(`${username}:${realm}`)))
468
+ : username;
469
+ let ha1 = await hash(`${user}:${realm}:${password}`);
470
+ if (sess) {
471
+ if (!challenge.qop) {
472
+ throw new Error(`Digest algorithm ${challenge.algorithm} requires a qop parameter`);
473
+ }
474
+ ha1 = await hash(`${ha1}:${nonce}:${_cnonce}`);
475
+ }
440
476
  const ha2 = await hash(`${method}:${uri}`);
441
477
  let response;
442
478
  const qopList = qop.split(/\s*,\s*/).filter(Boolean);
443
- if (qopList.includes("auth") || qopList.includes("auth-int")) {
444
- response = await hash(`${ha1}:${nonce}:${_nc}:${_cnonce}:${qopList[0]}:${ha2}`);
479
+ if (qopList.length > 0) {
480
+ // RFC 7616 §3.4.3: with `qop=auth-int`, HA2 is
481
+ // H(method:uri:H(entity-body))
482
+ // and the entity-body hash is part of the response. This API is not given
483
+ // the body, so that hash cannot be produced — and the old code took
484
+ // `qopList[0]` and used it in the response while still hashing
485
+ // H(method:uri). A server offering `qop="auth-int,auth"`, which RFC 7616
486
+ // §3.4.4 explicitly says a client may see, was therefore answered with a
487
+ // `qop=auth-int` header carrying a response computed as if it were `auth`:
488
+ // a client that claims a protection it does not implement, and fails
489
+ // against every conforming server.
490
+ //
491
+ // So: prefer `auth`, which is the only one implementable here, and refuse
492
+ // rather than answer wrongly when only `auth-int` is offered.
493
+ if (!qopList.includes("auth")) {
494
+ throw new Error(`Digest challenge offers only unsupported qop values: ${qopList.join(", ")}. ` +
495
+ `"auth-int" requires the entity body, which this API does not receive.`);
496
+ }
497
+ response = await hash(`${ha1}:${nonce}:${_nc}:${_cnonce}:auth:${ha2}`);
445
498
  }
446
499
  else {
447
500
  response = await hash(`${ha1}:${nonce}:${ha2}`);
@@ -455,13 +508,64 @@ export async function computeDigestResponse(challenge, username, password, metho
455
508
  * @param algorithm - Algorithm string from the challenge (e.g. "MD5", "SHA-256")
456
509
  * @returns The resolved algorithm identifier
457
510
  */
511
+ /**
512
+ * Compute the `username*` value for a challenge offering `userhash=true`
513
+ * (RFC 7616 §3.4.4): `base64(H(username : realm))`.
514
+ *
515
+ * A server that sends `userhash=true` is telling the client *not* to send the
516
+ * username in the clear. The previous code parsed the flag, exposed it on the
517
+ * public {@link DigestChallenge} type with a JSDoc promising support, and then
518
+ * ignored it — sending a plain `username="admin"` back to a server that had
519
+ * explicitly asked for it to be hashed. That is a privacy regression, and the
520
+ * server rejects the request too, because HA1 was built from the unhashed name.
521
+ *
522
+ * @returns the base64 hashed username, or `null` when `userhash` is not offered
523
+ */
524
+ export async function computeUsernameStar(challenge, username) {
525
+ if (!challenge.userhash)
526
+ return null;
527
+ const resolved = resolveHashAlgo(challenge.algorithm);
528
+ if (resolved === null) {
529
+ throw new Error(`Unsupported digest algorithm: ${challenge.algorithm}`);
530
+ }
531
+ const { algo } = resolved;
532
+ const digest = algo === "MD5"
533
+ ? md5(`${username}:${challenge.realm}`)
534
+ : algo === "SHA-512-256"
535
+ ? sha512256Hex(`${username}:${challenge.realm}`)
536
+ : await sha256Hex(`${username}:${challenge.realm}`);
537
+ return uint8ArrayToBase64(hexToBytes(digest));
538
+ }
539
+ /**
540
+ * Resolve the challenge's `algorithm` field to a base hash plus the
541
+ * `-sess` session-binding flag (RFC 7616 §3.4.4/§3.4.5).
542
+ *
543
+ * Two separate defects lived here. First, "anything unrecognised is MD5" was
544
+ * a silent downgrade: `SCRAM-SHA-256` and `garbage` were answered with a
545
+ * 32-hex MD5 under a header naming a different algorithm, so the server's
546
+ * rejection blamed an algorithm the caller never chose. Second, the
547
+ * `-sess` forms stripped to `MD5SESS`/`SHA256SESS`, matched nothing, and then
548
+ * fell through the `hash` dispatch to its SHA-256 default — so `MD5-sess`
549
+ * was computed with SHA-256, and `SHA-256-sess` happened to work only by
550
+ * accident. An unrecognised algorithm is now an error.
551
+ *
552
+ * @returns the base algorithm and whether HA1 is session-bound, or `null`
553
+ * when the algorithm is not one we can compute
554
+ */
458
555
  function resolveHashAlgo(algorithm) {
459
- const upper = algorithm.toUpperCase().replace(/-/g, "");
460
- if (upper === "SHA256")
461
- return "SHA-256";
462
- if (upper === "SHA512256")
463
- return "SHA-512-256";
464
- return "MD5";
556
+ const trimmed = algorithm.trim();
557
+ const sess = /-sess$/i.test(trimmed);
558
+ const base = trimmed
559
+ .replace(/-sess$/i, "")
560
+ .toUpperCase()
561
+ .replace(/-/g, "");
562
+ if (base === "MD5")
563
+ return { algo: "MD5", sess };
564
+ if (base === "SHA256")
565
+ return { algo: "SHA-256", sess };
566
+ if (base === "SHA512256")
567
+ return { algo: "SHA-512-256", sess };
568
+ return null;
465
569
  }
466
570
  // ============================================================================
467
571
  // §5 DIGEST AUTHORIZATION HEADER FORMATTING
@@ -491,23 +595,49 @@ function resolveHashAlgo(algorithm) {
491
595
  * "/dir/index.html", "f2/wE", "00000001");
492
596
  * ```
493
597
  */
494
- export function formatDigestAuth(challenge, username, response, uri, cnonce, nc) {
598
+ /**
599
+ * Escape a value for a `quoted-string` (RFC 7230 §3.2.6): backslash and double
600
+ * quote are escaped, everything else passes through.
601
+ *
602
+ * A username or URI containing `"` previously terminated the quoted string
603
+ * early and everything after it was parsed as further auth-params — a
604
+ * username of `evil", qop=auth, x="` produced a header carrying an attacker-
605
+ * chosen `qop` and an extra parameter. `realm` and `opaque` come from the
606
+ * *server*, so a hostile or compromised one could inject into the client's own
607
+ * outgoing Authorization header.
608
+ */
609
+ function quoted(value) {
610
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
611
+ }
612
+ export function formatDigestAuth(challenge, username, response, uri, cnonce, nc, usernameStar) {
495
613
  const parts = [];
496
614
  const _cnonce = cnonce || randomBytes(5);
497
615
  const _nc = nc || "00000001";
498
- parts.push(`username="${username}"`);
499
- parts.push(`realm="${challenge.realm}"`);
500
- parts.push(`nonce="${challenge.nonce}"`);
501
- parts.push(`uri="${uri}"`);
502
- parts.push(`response="${response}"`);
616
+ if (challenge.userhash) {
617
+ if (usernameStar === undefined) {
618
+ throw new Error("challenge offers userhash=true: pass the value from computeUsernameStar() " +
619
+ "as `usernameStar` — sending a plaintext username would defeat it");
620
+ }
621
+ parts.push(`username*="${quoted(usernameStar)}"`);
622
+ }
623
+ else {
624
+ if (usernameStar !== undefined) {
625
+ throw new Error("usernameStar was given but the challenge does not offer userhash");
626
+ }
627
+ parts.push(`username="${quoted(username)}"`);
628
+ }
629
+ parts.push(`realm="${quoted(challenge.realm)}"`);
630
+ parts.push(`nonce="${quoted(challenge.nonce)}"`);
631
+ parts.push(`uri="${quoted(uri)}"`);
632
+ parts.push(`response="${quoted(response)}"`);
503
633
  if (challenge.opaque)
504
- parts.push(`opaque="${challenge.opaque}"`);
634
+ parts.push(`opaque="${quoted(challenge.opaque)}"`);
505
635
  if (challenge.algorithm && challenge.algorithm !== "MD5")
506
636
  parts.push(`algorithm=${challenge.algorithm}`);
507
637
  if (challenge.qop) {
508
- parts.push(`qop=${challenge.qop.split(/\s*,\s*/)[0]}`);
638
+ parts.push(`qop=auth`);
509
639
  parts.push(`nc=${_nc}`);
510
- parts.push(`cnonce="${_cnonce}"`);
640
+ parts.push(`cnonce="${quoted(_cnonce)}"`);
511
641
  }
512
642
  return `Digest ${parts.join(", ")}`;
513
643
  }
@@ -548,7 +678,8 @@ export async function createDigestAuthorization(wwwAuth, username, password, met
548
678
  const cnonce = randomBytes(5);
549
679
  const nc = "00000001";
550
680
  const response = await computeDigestResponse(challenge, username, password, method, uri, cnonce, nc);
551
- return formatDigestAuth(challenge, username, response, uri, cnonce, nc);
681
+ const usernameStar = await computeUsernameStar(challenge, username);
682
+ return formatDigestAuth(challenge, username, response, uri, cnonce, nc, usernameStar ?? undefined);
552
683
  }
553
684
  /**
554
685
  * Stateful Digest-auth authorizer.
@@ -565,14 +696,40 @@ export async function createDigestAuthorization(wwwAuth, username, password, met
565
696
  */
566
697
  export function createDigestAuthorizer() {
567
698
  const counters = new Map();
699
+ // The server chooses the nonce, and nonce expiry is a routine server-side
700
+ // control, so a long-lived client sees a fresh `realm\u0000nonce` key on every
701
+ // challenge. Entries were never evicted, so the map grew by one key per
702
+ // challenge for the life of the process — keyed on strings an
703
+ // attacker-influenced server supplies. `Map` preserves insertion order, so
704
+ // dropping the first key evicts the oldest challenge.
705
+ //
706
+ // ponytail: bounded LRU-by-insertion-order rather than a TTL timer. Raise
707
+ // MAX_TRACKED_NONCES if a deployment is ever seen cycling nonces faster
708
+ // than this; a timer per key would be the alternative.
709
+ const MAX_TRACKED_NONCES = 64;
710
+ const rememberNonce = (scope, value) => {
711
+ if (!counters.has(scope)) {
712
+ counters.set(scope, value);
713
+ if (counters.size > MAX_TRACKED_NONCES) {
714
+ const oldest = counters.keys().next();
715
+ if (!oldest.done)
716
+ counters.delete(oldest.value);
717
+ }
718
+ return;
719
+ }
720
+ // Re-insert so a hot challenge counts as recently used.
721
+ counters.delete(scope);
722
+ counters.set(scope, value);
723
+ };
568
724
  return async (wwwAuth, username, password, method, uri) => {
569
725
  const challenge = parseDigestChallenge(wwwAuth);
570
726
  const cnonce = randomBytes(5);
571
727
  const scope = `${challenge.realm}\u0000${challenge.nonce}`;
572
728
  const next = (counters.get(scope) ?? 0) + 1;
573
- counters.set(scope, next);
729
+ rememberNonce(scope, next);
574
730
  const nc = next.toString(16).padStart(8, "0");
575
731
  const response = await computeDigestResponse(challenge, username, password, method, uri, cnonce, nc);
576
- return formatDigestAuth(challenge, username, response, uri, cnonce, nc);
732
+ const usernameStar = await computeUsernameStar(challenge, username);
733
+ return formatDigestAuth(challenge, username, response, uri, cnonce, nc, usernameStar ?? undefined);
577
734
  };
578
735
  }