@telecrypt-io/storage 0.5.28 → 0.5.29

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/core/oidc.js CHANGED
@@ -6,10 +6,9 @@
6
6
  * this module under Node is safe; the CLI uses the device-code functions,
7
7
  * which are plain fetch calls.
8
8
  */
9
- import { createClient } from "matrix-js-sdk";
10
9
  import { generateScope, isValidDeviceAccessTokenResponse, normalizeBearerTokenResponseTokenType, validateBearerTokenResponse, validateDeviceAuthorizationResponse, validateRegistrationResponse, OAuthGrantType, OAuth2, } from "matrix-js-sdk/lib/oauth/index.js";
11
10
  import { StorageError } from "./errors.js";
12
- import { raceWithAbort, readBoundedResponseBody } from "./http.js";
11
+ import { cancelResponseBody, raceWithAbort, readResponseBody, ResponseBodyReadError, } from "./http.js";
13
12
  import { validateCanonicalMatrixUserId, validateMatrixDeviceId, } from "./constants.js";
14
13
  const AUTHORIZATION_CONTEXT_PREFIX = "telecrypt:oauth2:pkce:v1:";
15
14
  const AUTHORIZATION_CONTEXT_VERSION = 1;
@@ -22,8 +21,6 @@ const MAX_AUTHORIZATION_CONTEXT_LENGTH = 64 * 1024;
22
21
  const PKCE_VERIFIER_PATTERN = /^[A-Za-z0-9._~-]{43,128}$/;
23
22
  const DEVICE_ID_PATTERN = /^[A-Za-z0-9._~-]{1,128}$/;
24
23
  const STATE_PATTERN = /^[A-Za-z0-9._~-]{32,128}$/;
25
- const MAX_OAUTH_ERROR_BODY_BYTES = 16 * 1024;
26
- const MAX_OAUTH_SUCCESS_BODY_BYTES = 32 * 1024;
27
24
  const OIDC_REQUEST_TIMEOUT_MS = 30_000;
28
25
  const OIDC_REFRESH_TIMEOUT_MS = 30_000;
29
26
  const OIDC_CLEANUP_TIMEOUT_MS = 30_000;
@@ -85,6 +82,225 @@ class OidcValidationError extends StorageError {
85
82
  this.name = "OidcValidationError";
86
83
  }
87
84
  }
85
+ const DIAGNOSTIC_SECRET_FIELD = /^(?:access[_-]?token|refresh[_-]?token|id[_-]?token|client[_-]?id|client[_-]?secret|user[_-]?id|device[_-]?id|authorization(?:[_-]?code)?|device[_-]?code|user[_-]?code|code[_-]?verifier|token|credential[s]?|private[_-]?key|(?:[A-Za-z0-9]+[_-])?(?:encryption|signing|password|secret|api[_-]?key|recovery[_-]?key|cookie|session)(?:[_-]?token|[_-]?key)?)$/iu;
86
+ const DIAGNOSTIC_EMAIL = /[A-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Z0-9-]+(?:\.[A-Z0-9-]+)+/giu;
87
+ const DIAGNOSTIC_MXID = /@[A-Z0-9._=+\-/]+:[A-Z0-9.-]+/giu;
88
+ const DIAGNOSTIC_ULID = /\b[0-9A-HJKMNP-TV-Z]{26}\b/giu;
89
+ /** Keep provider diagnostics useful without allowing credentials, customer
90
+ * identifiers, or control characters from an untrusted response to cross the
91
+ * SDK boundary. */
92
+ export function sanitizeDiagnosticText(value) {
93
+ const quotedValue = `"(?:\\\\.|[^"\\\\])*"|'(?:\\\\.|[^'\\\\])*'|[^\\s,};\\]]+`;
94
+ return value
95
+ .replace(/(Bearer\s+)(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|[^\s"',}]+)/giu, "$1<redacted>")
96
+ .replace(new RegExp(`(["'])([^"']+)\\1(\\s*[:=]\\s*)(${quotedValue})`, "giu"), (whole, quote, key, separator) => DIAGNOSTIC_SECRET_FIELD.test(key.replace(/\\(["'])/gu, "$1"))
97
+ ? `${quote}${key}${quote}${separator}"<redacted>"`
98
+ : whole)
99
+ .replace(new RegExp(`\\b([A-Za-z][A-Za-z0-9_-]*)\\b(\\s*[:=]\\s*)(${quotedValue})`, "giu"), (whole, key, separator) => DIAGNOSTIC_SECRET_FIELD.test(key) ? `${key}${separator}"<redacted>"` : whole)
100
+ .replace(DIAGNOSTIC_EMAIL, "<redacted>")
101
+ .replace(DIAGNOSTIC_MXID, "<redacted>")
102
+ .replace(DIAGNOSTIC_ULID, "<redacted>")
103
+ .replace(/[\r\n\u0000-\u001f\u007f-\u009f\u2028\u2029]/gu, " ")
104
+ .trim();
105
+ }
106
+ class OidcResponseError extends Error {
107
+ status;
108
+ constructor(operation, status, body, cause) {
109
+ const detail = sanitizeDiagnosticText(body);
110
+ super(`OIDC ${operation} provider response (${status})${detail === "" ? "" : `: ${detail}`}`, cause === undefined ? undefined : { cause });
111
+ this.name = "OidcResponseError";
112
+ this.status = status;
113
+ }
114
+ }
115
+ function diagnosticKey(key) {
116
+ return String(key);
117
+ }
118
+ function diagnosticDisplayKey(key) {
119
+ return sanitizeDiagnosticText(diagnosticKey(key)) || "<empty property>";
120
+ }
121
+ function diagnosticPropertyIsSecret(key) {
122
+ return DIAGNOSTIC_SECRET_FIELD.test(diagnosticKey(key));
123
+ }
124
+ function readDiagnosticProperties(value, seen) {
125
+ const properties = [];
126
+ const failures = [];
127
+ let keys;
128
+ try {
129
+ keys = Reflect.ownKeys(value);
130
+ }
131
+ catch (error) {
132
+ failures.push(`own properties unavailable: ${diagnosticFailureText(error, seen)}`);
133
+ return { properties, failures };
134
+ }
135
+ for (const key of keys) {
136
+ let descriptor;
137
+ try {
138
+ descriptor = Object.getOwnPropertyDescriptor(value, key);
139
+ }
140
+ catch (error) {
141
+ failures.push(`${diagnosticDisplayKey(key)} unavailable: ${diagnosticPropertyIsSecret(key) ? "<redacted>" : diagnosticFailureText(error, seen)}`);
142
+ continue;
143
+ }
144
+ if (!descriptor)
145
+ continue;
146
+ try {
147
+ if ("value" in descriptor) {
148
+ properties.push({ key, value: descriptor.value });
149
+ }
150
+ else if (typeof descriptor.get === "function") {
151
+ properties.push({ key, value: Reflect.get(value, key) });
152
+ }
153
+ else {
154
+ failures.push(`${diagnosticDisplayKey(key)} unavailable: ${diagnosticPropertyIsSecret(key) ? "<redacted>" : "accessor has no getter"}`);
155
+ }
156
+ }
157
+ catch (error) {
158
+ failures.push(`${diagnosticDisplayKey(key)} unavailable: ${diagnosticPropertyIsSecret(key) ? "<redacted>" : diagnosticFailureText(error, seen)}`);
159
+ }
160
+ }
161
+ return { properties, failures };
162
+ }
163
+ function diagnosticObjectText(value, seen) {
164
+ const inspection = readDiagnosticProperties(value, seen);
165
+ const parts = inspection.failures.slice();
166
+ for (const property of inspection.properties) {
167
+ const key = diagnosticDisplayKey(property.key);
168
+ parts.push(`${key}: ${diagnosticPropertyIsSecret(property.key) ? "<redacted>" : diagnosticValueText(property.value, seen)}`);
169
+ }
170
+ if (parts.length === 0) {
171
+ return "object { }";
172
+ }
173
+ return `object { ${parts.join(", ")} }`;
174
+ }
175
+ function diagnosticValueText(value, seen) {
176
+ if (value !== null && (typeof value === "object" || typeof value === "function")) {
177
+ if (seen.has(value))
178
+ return "[cyclic diagnostic]";
179
+ seen.add(value);
180
+ }
181
+ if (value instanceof AggregateError) {
182
+ const parts = [`${sanitizeDiagnosticText(value.name)}: ${sanitizeDiagnosticText(value.message)}`];
183
+ if (typeof value.stack === "string")
184
+ parts.push(`stack: ${sanitizeDiagnosticText(value.stack)}`);
185
+ try {
186
+ parts.push(`children: ${value.errors.map((child) => diagnosticValueText(child, seen)).join(", ")}`);
187
+ }
188
+ catch (error) {
189
+ parts.push(`children unavailable: ${diagnosticFailureText(error, seen)}`);
190
+ }
191
+ const inspection = readDiagnosticProperties(value, seen);
192
+ for (const property of inspection.properties) {
193
+ if (["name", "message", "stack", "cause", "errors"].includes(diagnosticKey(property.key)))
194
+ continue;
195
+ const key = diagnosticDisplayKey(property.key);
196
+ parts.push(`${key}: ${diagnosticPropertyIsSecret(property.key) ? "<redacted>" : diagnosticValueText(property.value, seen)}`);
197
+ }
198
+ parts.push(...inspection.failures);
199
+ return parts.join("; ");
200
+ }
201
+ if (value instanceof Error) {
202
+ const parts = [`${sanitizeDiagnosticText(value.name)}: ${sanitizeDiagnosticText(value.message)}`];
203
+ if (typeof value.stack === "string")
204
+ parts.push(`stack: ${sanitizeDiagnosticText(value.stack)}`);
205
+ try {
206
+ if (value.cause !== undefined)
207
+ parts.push(`cause: ${diagnosticValueText(value.cause, seen)}`);
208
+ }
209
+ catch (error) {
210
+ parts.push(`cause unavailable: ${diagnosticFailureText(error, seen)}`);
211
+ }
212
+ const inspection = readDiagnosticProperties(value, seen);
213
+ for (const property of inspection.properties) {
214
+ if (["name", "message", "stack", "cause", "bytes"].includes(diagnosticKey(property.key)))
215
+ continue;
216
+ const key = diagnosticDisplayKey(property.key);
217
+ parts.push(`${key}: ${diagnosticPropertyIsSecret(property.key) ? "<redacted>" : diagnosticValueText(property.value, seen)}`);
218
+ }
219
+ parts.push(...inspection.failures);
220
+ return parts.join("; ");
221
+ }
222
+ if (value !== null && (typeof value === "object" || typeof value === "function")) {
223
+ return diagnosticObjectText(value, seen);
224
+ }
225
+ try {
226
+ return sanitizeDiagnosticText(String(value));
227
+ }
228
+ catch {
229
+ return "unknown failure";
230
+ }
231
+ }
232
+ function diagnosticFailureText(error, seen) {
233
+ try {
234
+ return diagnosticValueText(error, seen);
235
+ }
236
+ catch {
237
+ return "unknown property failure";
238
+ }
239
+ }
240
+ /** Copy a transport error's useful details across the SDK boundary after the
241
+ * same secret/control-character sanitization used for provider responses. */
242
+ export function diagnosticCause(error, seen = new WeakSet()) {
243
+ if (error !== null && (typeof error === "object" || typeof error === "function")) {
244
+ if (seen.has(error))
245
+ return new Error("[cyclic diagnostic]");
246
+ seen.add(error);
247
+ }
248
+ if (error instanceof AggregateError) {
249
+ let children = [];
250
+ try {
251
+ if (Array.isArray(error.errors))
252
+ children = error.errors;
253
+ }
254
+ catch {
255
+ children = [];
256
+ }
257
+ const copy = new AggregateError(children.map((child) => diagnosticCause(child, seen)), diagnosticValueText(error, new WeakSet()));
258
+ copy.name = sanitizeDiagnosticText(error.name) || "AggregateError";
259
+ if (typeof error.stack === "string")
260
+ copy.stack = sanitizeDiagnosticText(error.stack);
261
+ let cause;
262
+ try {
263
+ cause = error.cause;
264
+ }
265
+ catch {
266
+ cause = undefined;
267
+ }
268
+ if (cause !== undefined) {
269
+ Object.defineProperty(copy, "cause", {
270
+ configurable: true,
271
+ enumerable: false,
272
+ value: diagnosticCause(cause, seen),
273
+ writable: true,
274
+ });
275
+ }
276
+ return copy;
277
+ }
278
+ if (error instanceof Error) {
279
+ const copy = new Error(diagnosticValueText(error, new WeakSet()));
280
+ copy.name = sanitizeDiagnosticText(error.name) || "Error";
281
+ if (typeof error.stack === "string")
282
+ copy.stack = sanitizeDiagnosticText(error.stack);
283
+ let cause;
284
+ try {
285
+ cause = error.cause;
286
+ }
287
+ catch {
288
+ cause = undefined;
289
+ }
290
+ if (cause !== undefined) {
291
+ Object.defineProperty(copy, "cause", {
292
+ configurable: true,
293
+ enumerable: false,
294
+ value: diagnosticCause(cause, seen),
295
+ writable: true,
296
+ });
297
+ }
298
+ return copy;
299
+ }
300
+ const copy = new Error(diagnosticValueText(error, new WeakSet()));
301
+ copy.name = "ThrownObject";
302
+ return copy;
303
+ }
88
304
  function isRecord(value) {
89
305
  return typeof value === "object" && value !== null && !Array.isArray(value);
90
306
  }
@@ -139,8 +355,8 @@ function parseSafeRedirectUri(value, name) {
139
355
  try {
140
356
  parsed = new URL(text);
141
357
  }
142
- catch {
143
- throw new StorageError(`OIDC authorization context has an invalid ${name}`);
358
+ catch (error) {
359
+ throw new StorageError(`OIDC authorization context has an invalid ${name}`, { cause: error });
144
360
  }
145
361
  const isRootWithoutSlash = parsed.pathname === "/" && parsed.toString() === `${text}/`;
146
362
  if ((parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
@@ -200,24 +416,32 @@ function validateRegistrationMetadata(metadata) {
200
416
  });
201
417
  return { clientUri, redirectUris };
202
418
  }
203
- async function readBoundedResponseText(response, maxBytes = MAX_OAUTH_ERROR_BODY_BYTES, signal) {
204
- const body = await readBoundedResponseBody(response, maxBytes, signal);
205
- return { text: new TextDecoder().decode(body.bytes), truncated: body.truncated };
206
- }
207
- async function readBoundedJsonResponse(response, operation, signal) {
208
- const maxBytes = response.ok ? MAX_OAUTH_SUCCESS_BODY_BYTES : MAX_OAUTH_ERROR_BODY_BYTES;
209
- const body = await readBoundedResponseText(response, maxBytes, signal);
210
- if (body.truncated) {
211
- throw new StorageError(`OIDC ${operation} returned an oversized response`);
419
+ async function readResponseText(response, signal, operation = "OIDC response") {
420
+ try {
421
+ const body = await readResponseBody(response, signal);
422
+ return { text: new TextDecoder().decode(body.bytes) };
212
423
  }
424
+ catch (error) {
425
+ if (!(error instanceof ResponseBodyReadError))
426
+ throw error;
427
+ const partial = sanitizeDiagnosticText(new TextDecoder().decode(error.bytes));
428
+ throw new StorageError(`${operation} response body could not be read${partial === "" ? "" : `: ${partial}`}`, {
429
+ cause: new OidcResponseError(operation, response.status, partial, error.cause),
430
+ });
431
+ }
432
+ }
433
+ async function readJsonResponse(response, operation, signal) {
434
+ const body = await readResponseText(response, signal, operation);
213
435
  try {
214
- return JSON.parse(body.text);
436
+ return { value: JSON.parse(body.text), text: body.text };
215
437
  }
216
- catch {
217
- throw new StorageError(`OIDC ${operation} returned an invalid response`);
438
+ catch (error) {
439
+ throw new StorageError(`OIDC ${operation} returned an invalid response`, {
440
+ cause: new OidcResponseError(operation, response.status, body.text, error),
441
+ });
218
442
  }
219
443
  }
220
- function assertNoRedirect(response, endpoint, operation) {
444
+ async function assertNoRedirect(response, endpoint, operation) {
221
445
  // OAuth request bodies contain bearer credentials, refresh tokens, or
222
446
  // authorization codes. Redirects must never be followed with those bodies.
223
447
  // `redirect: "manual"` makes this a local rejection rather than a network
@@ -226,8 +450,9 @@ function assertNoRedirect(response, endpoint, operation) {
226
450
  response.type === "opaqueredirect" ||
227
451
  (response.url !== "" && response.url !== endpoint) ||
228
452
  response.redirected) {
229
- void response.body?.cancel().catch(() => undefined);
230
- throw new StorageError(`OIDC ${operation} rejected an untrusted redirect`);
453
+ const failure = new StorageError(`OIDC ${operation} rejected an untrusted redirect`);
454
+ await cancelResponseBody(response, `OIDC ${operation} redirect`, failure);
455
+ throw failure;
231
456
  }
232
457
  }
233
458
  function exactEndpoint(value, name) {
@@ -247,14 +472,13 @@ function parseSafeOAuthError(body) {
247
472
  return { code: parsed.error };
248
473
  }
249
474
  function formatTokenRefreshFailure(status, body) {
250
- if (body.truncated) {
251
- return `OIDC token refresh failed (${status}): provider error response was too large`;
252
- }
253
475
  const oauthError = parseSafeOAuthError(body.text);
254
476
  if (!oauthError) {
255
- return `OIDC token refresh failed (${status}): provider returned an invalid OAuth error response`;
477
+ return new StorageError(`OIDC token refresh failed (${status}): provider returned an invalid OAuth error response`, { cause: new OidcResponseError("token refresh", status, body.text) });
256
478
  }
257
- return `OIDC token refresh failed (${status}): OAuth error ${oauthError.code}`;
479
+ return new StorageError(`OIDC token refresh failed (${status}): OAuth error ${oauthError.code}`, {
480
+ cause: new OidcResponseError("token refresh", status, body.text),
481
+ });
258
482
  }
259
483
  function providerErrorStatus(error) {
260
484
  if (!isRecord(error) || typeof error.httpStatus !== "number")
@@ -264,12 +488,29 @@ function providerErrorStatus(error) {
264
488
  : undefined;
265
489
  }
266
490
  function formatProviderFailure(operation, error) {
267
- if (error instanceof OidcRequestTimeoutError)
268
- return error.message;
269
- if (error instanceof OidcRequestCancelledError)
270
- return error.message;
491
+ if (error instanceof StorageError)
492
+ return error;
493
+ if (error instanceof OidcRequestTimeoutError) {
494
+ return new StorageError(error.message, { cause: diagnosticCause(error) });
495
+ }
496
+ if (error instanceof OidcRequestCancelledError) {
497
+ return new StorageError(error.message, { cause: diagnosticCause(error) });
498
+ }
271
499
  const status = providerErrorStatus(error);
272
- return `OIDC ${operation} failed${status === undefined ? "" : ` (${status})`}`;
500
+ return new StorageError(`OIDC ${operation} failed${status === undefined ? "" : ` (${status})`}`, { cause: diagnosticCause(error) });
501
+ }
502
+ function providerResponseFailure(operation, response, body, message) {
503
+ return new StorageError(message, {
504
+ cause: new OidcResponseError(operation, response.status, body),
505
+ });
506
+ }
507
+ function responseValidationFailure(operation, response, body, error) {
508
+ const message = error instanceof Error
509
+ ? sanitizeDiagnosticText(error.message) || `OIDC ${operation} returned an invalid response`
510
+ : `OIDC ${operation} returned an invalid response`;
511
+ return new StorageError(message, {
512
+ cause: new OidcResponseError(operation, response.status, body, diagnosticCause(error)),
513
+ });
273
514
  }
274
515
  function ensureOidcNotCancelled(signal, operation) {
275
516
  if (signal?.aborted)
@@ -291,15 +532,22 @@ async function requestWithTimeout(input, init, operation, consume, timeoutMs = O
291
532
  signal.addEventListener("abort", abort, { once: true });
292
533
  }
293
534
  try {
294
- const response = await raceWithAbort(fetch(input, { ...init, signal: controller.signal }), controller.signal, () => undefined, () => timedOut
535
+ const response = await raceWithAbort(fetch(input, { ...init, signal: controller.signal }), controller.signal, () => timedOut
295
536
  ? new OidcRequestTimeoutError(operation)
296
537
  : new OidcRequestCancelledError(operation));
297
538
  return await consume(response, controller.signal);
298
539
  }
299
540
  catch (error) {
300
- if (timedOut)
541
+ if (error instanceof AggregateError)
542
+ throw error;
543
+ if (timedOut) {
544
+ if (error instanceof OidcRequestTimeoutError)
545
+ throw error;
301
546
  throw new OidcRequestTimeoutError(operation);
547
+ }
302
548
  if (signals.some((signal) => signal.aborted)) {
549
+ if (error instanceof OidcRequestCancelledError)
550
+ throw error;
303
551
  throw new OidcRequestCancelledError(operation);
304
552
  }
305
553
  throw error;
@@ -327,50 +575,14 @@ function abortableDelay(milliseconds, signal) {
327
575
  signal?.addEventListener("abort", abort, { once: true });
328
576
  });
329
577
  }
330
- function requestUrl(input) {
331
- if (typeof input === "string")
332
- return new URL(input).toString();
333
- if (input instanceof URL)
334
- return input.toString();
335
- return input.url;
336
- }
337
- /**
338
- * MatrixClient's OAuth helpers use the SDK HTTP layer for discovery and
339
- * whoami. Supply a bounded/manual-redirect fetch implementation so those
340
- * calls have the same body and credential boundary as the direct OAuth calls.
341
- */
342
- function boundedMatrixFetch(operation, externalSignal) {
343
- return async (input, init) => {
344
- const endpoint = requestUrl(input);
345
- return requestWithTimeout(input, { ...init, redirect: "manual" }, operation, async (response, requestSignal) => {
346
- assertNoRedirect(response, endpoint, operation);
347
- const maxBytes = response.ok ? MAX_OAUTH_SUCCESS_BODY_BYTES : MAX_OAUTH_ERROR_BODY_BYTES;
348
- const body = await readBoundedResponseText(response, maxBytes, requestSignal);
349
- if (body.truncated) {
350
- if (response.ok) {
351
- throw new StorageError(`OIDC ${operation} returned an oversized response`);
352
- }
353
- return new Response(null, {
354
- status: response.status,
355
- statusText: response.statusText,
356
- });
357
- }
358
- return new Response(body.text, {
359
- status: response.status,
360
- statusText: response.statusText,
361
- headers: response.headers,
362
- });
363
- }, OIDC_REQUEST_TIMEOUT_MS, externalSignal);
364
- };
365
- }
366
578
  function parseHttpUrl(value, name) {
367
579
  const text = requireBoundedString(value, name, MAX_OIDC_URL_LENGTH);
368
580
  let parsed;
369
581
  try {
370
582
  parsed = new URL(text);
371
583
  }
372
- catch {
373
- throw new StorageError(`OIDC authorization context has an invalid ${name}`);
584
+ catch (error) {
585
+ throw new StorageError(`OIDC authorization context has an invalid ${name}`, { cause: error });
374
586
  }
375
587
  const isRootWithoutSlash = parsed.pathname === "/" && parsed.toString() === `${text}/`;
376
588
  if ((parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
@@ -394,6 +606,10 @@ function matrixAuthMetadataEndpoint(homeserver) {
394
606
  const basePath = homeserver.pathname.replace(/\/+$/u, "");
395
607
  return new URL(`${basePath}/_matrix/client/v1/auth_metadata`, homeserver.origin).toString();
396
608
  }
609
+ function matrixWhoAmIEndpoint(homeserver) {
610
+ const basePath = homeserver.pathname.replace(/\/+$/u, "");
611
+ return new URL(`${basePath}/_matrix/client/v3/account/whoami`, homeserver.origin).toString();
612
+ }
397
613
  function validateTokenEndpointMetadata(metadata) {
398
614
  const issuer = parseHttpUrl(metadata.issuer, "issuer");
399
615
  const tokenEndpoint = parseHttpUrl(metadata.token_endpoint, "token endpoint");
@@ -504,8 +720,8 @@ function validateDeviceAuthorizationSession(value) {
504
720
  try {
505
721
  validateDeviceAuthorizationResponse(value);
506
722
  }
507
- catch {
508
- throw new StorageError("OIDC device authorization returned an invalid response");
723
+ catch (error) {
724
+ throw new StorageError("OIDC device authorization returned an invalid response", { cause: error });
509
725
  }
510
726
  const session = value;
511
727
  requireBoundedString(session.device_code, "device code", MAX_OIDC_CODE_LENGTH);
@@ -547,8 +763,8 @@ function validateTokenBounds(value, operation) {
547
763
  if (value.scope !== undefined)
548
764
  requireScope(value.scope, "scope");
549
765
  }
550
- catch {
551
- throw new StorageError(`OIDC ${operation} returned an invalid token response`);
766
+ catch (error) {
767
+ throw new StorageError(`OIDC ${operation} returned an invalid token response`, { cause: error });
552
768
  }
553
769
  if (value.expires_in !== undefined &&
554
770
  (!Number.isInteger(value.expires_in) || value.expires_in < 0 || value.expires_in > 86400)) {
@@ -618,22 +834,21 @@ export async function discoverOidcIssuer(homeserverBaseUrl, signal) {
618
834
  try {
619
835
  const homeserver = parseHttpUrl(homeserverBaseUrl, "homeserver URL");
620
836
  const endpoint = matrixAuthMetadataEndpoint(homeserver);
621
- const { response, body } = await requestWithTimeout(endpoint, { method: "GET", headers: { Accept: "application/json" }, redirect: "manual" }, "discovery", async (response, requestSignal) => {
622
- assertNoRedirect(response, endpoint, "discovery");
837
+ const { response, body, responseText } = await requestWithTimeout(endpoint, { method: "GET", headers: { Accept: "application/json" }, redirect: "manual" }, "discovery", async (response, requestSignal) => {
838
+ await assertNoRedirect(response, endpoint, "discovery");
623
839
  if (!response.ok) {
624
- // Discovery only consumes metadata on success. Drain a bounded
625
- // error body without parsing or surfacing provider-controlled text;
626
- // the status remains the only caller-visible diagnostic.
627
- await readBoundedResponseBody(response, MAX_OAUTH_ERROR_BODY_BYTES, requestSignal);
628
- return { response, body: undefined };
840
+ const body = await readResponseText(response, requestSignal, "discovery");
841
+ return { response, body: undefined, responseText: body.text };
629
842
  }
843
+ const body = await readJsonResponse(response, "discovery", requestSignal);
630
844
  return {
631
845
  response,
632
- body: await readBoundedJsonResponse(response, "discovery", requestSignal),
846
+ body: body.value,
847
+ responseText: body.text,
633
848
  };
634
849
  }, OIDC_REQUEST_TIMEOUT_MS, signal);
635
850
  if (!response.ok) {
636
- const failure = new StorageError("OIDC discovery failed");
851
+ const failure = providerResponseFailure("discovery", response, responseText ?? "", `OIDC discovery failed (${response.status})`);
637
852
  failure.httpStatus = response.status;
638
853
  throw failure;
639
854
  }
@@ -641,7 +856,9 @@ export async function discoverOidcIssuer(homeserverBaseUrl, signal) {
641
856
  return validateTrustedAuthMetadata(body);
642
857
  }
643
858
  catch (err) {
644
- throw new StorageError(formatProviderFailure("discovery", err));
859
+ if (err instanceof AggregateError)
860
+ throw err;
861
+ throw formatProviderFailure("discovery", err);
645
862
  }
646
863
  }
647
864
  /**
@@ -677,26 +894,38 @@ export async function registerClient(authMetadata, metadata, signal) {
677
894
  policy_uri: metadata.policyUri,
678
895
  };
679
896
  const endpoint = exactEndpoint(safeAuthMetadata.registration_endpoint, "registration endpoint");
680
- const { response, body } = await requestWithTimeout(endpoint, {
897
+ const { response, body, responseText } = await requestWithTimeout(endpoint, {
681
898
  method: "POST",
682
899
  headers: { Accept: "application/json", "Content-Type": "application/json" },
683
900
  body: JSON.stringify(registration),
684
901
  redirect: "manual",
685
902
  }, "dynamic client registration", async (response, requestSignal) => {
686
- assertNoRedirect(response, endpoint, "dynamic client registration");
903
+ await assertNoRedirect(response, endpoint, "dynamic client registration");
904
+ const body = await readJsonResponse(response, "dynamic client registration", requestSignal);
687
905
  return {
688
906
  response,
689
- body: await readBoundedJsonResponse(response, "dynamic client registration", requestSignal),
907
+ body: body.value,
908
+ responseText: body.text,
690
909
  };
691
910
  }, OIDC_REQUEST_TIMEOUT_MS, signal);
692
- if (!response.ok || !validateRegistrationResponse(body)) {
693
- throw new StorageError("OIDC dynamic client registration failed");
911
+ if (!response.ok) {
912
+ throw providerResponseFailure("dynamic client registration", response, responseText ?? "", "OIDC dynamic client registration failed");
913
+ }
914
+ if (!validateRegistrationResponse(body)) {
915
+ throw providerResponseFailure("dynamic client registration", response, responseText ?? "", "OIDC dynamic client registration returned an invalid response");
694
916
  }
695
917
  ensureOidcNotCancelled(signal, "dynamic client registration");
696
- return requireClientId(body.client_id);
918
+ try {
919
+ return requireClientId(body.client_id);
920
+ }
921
+ catch (error) {
922
+ throw responseValidationFailure("dynamic client registration", response, responseText ?? "", error);
923
+ }
697
924
  }
698
925
  catch (err) {
699
- throw new StorageError(formatProviderFailure("dynamic client registration", err));
926
+ if (err instanceof AggregateError)
927
+ throw err;
928
+ throw formatProviderFailure("dynamic client registration", err);
700
929
  }
701
930
  }
702
931
  // ---------------------------------------------------------------------------
@@ -721,21 +950,30 @@ export async function startDeviceCodeLogin(authMetadata, clientId, deviceId, sig
721
950
  if (!endpoint)
722
951
  throw new StorageError("OIDC device authorization is not supported");
723
952
  const trustedEndpoint = exactEndpoint(endpoint, "device authorization endpoint");
724
- const { response, body } = await requestWithTimeout(trustedEndpoint, {
953
+ const { response, body, responseText } = await requestWithTimeout(trustedEndpoint, {
725
954
  method: "POST",
726
955
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
727
956
  body: new URLSearchParams({ client_id: safeClientId, scope }).toString(),
728
957
  redirect: "manual",
729
958
  }, "device authorization", async (response, requestSignal) => {
730
- assertNoRedirect(response, trustedEndpoint, "device authorization");
959
+ await assertNoRedirect(response, trustedEndpoint, "device authorization");
960
+ const body = await readJsonResponse(response, "device authorization", requestSignal);
731
961
  return {
732
962
  response,
733
- body: await readBoundedJsonResponse(response, "device authorization", requestSignal),
963
+ body: body.value,
964
+ responseText: body.text,
734
965
  };
735
966
  }, OIDC_REQUEST_TIMEOUT_MS, signal);
736
- if (!response.ok)
737
- throw new StorageError("OIDC device authorization failed");
738
- const session = validateDeviceAuthorizationSession(body);
967
+ if (!response.ok) {
968
+ throw providerResponseFailure("device authorization", response, responseText ?? "", "OIDC device authorization failed");
969
+ }
970
+ let session;
971
+ try {
972
+ session = validateDeviceAuthorizationSession(body);
973
+ }
974
+ catch (error) {
975
+ throw responseValidationFailure("device authorization", response, responseText ?? "", error);
976
+ }
739
977
  ensureOidcNotCancelled(signal, "device authorization");
740
978
  // Keep the requested device binding attached to this in-memory session
741
979
  // without changing the RFC response shape or serializing it into logs.
@@ -743,7 +981,9 @@ export async function startDeviceCodeLogin(authMetadata, clientId, deviceId, sig
743
981
  return session;
744
982
  }
745
983
  catch (err) {
746
- throw new StorageError(formatProviderFailure("device authorization", err));
984
+ if (err instanceof AggregateError)
985
+ throw err;
986
+ throw formatProviderFailure("device authorization", err);
747
987
  }
748
988
  }
749
989
  /**
@@ -776,7 +1016,7 @@ export async function waitForDeviceCodeLogin(authMetadata, clientId, session, si
776
1016
  ensureOidcNotCancelled(signal, "device authorization");
777
1017
  return { error: "expired" };
778
1018
  }
779
- const { response, body } = await requestWithTimeout(endpoint, {
1019
+ const { response, body, responseText } = await requestWithTimeout(endpoint, {
780
1020
  method: "POST",
781
1021
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
782
1022
  body: new URLSearchParams({
@@ -786,21 +1026,36 @@ export async function waitForDeviceCodeLogin(authMetadata, clientId, session, si
786
1026
  }).toString(),
787
1027
  redirect: "manual",
788
1028
  }, "device authorization", async (response, requestSignal) => {
789
- assertNoRedirect(response, endpoint, "device authorization");
1029
+ await assertNoRedirect(response, endpoint, "device authorization");
1030
+ const body = await readJsonResponse(response, "device authorization", requestSignal);
790
1031
  return {
791
1032
  response,
792
- body: await readBoundedJsonResponse(response, "device authorization", requestSignal),
1033
+ body: body.value,
1034
+ responseText: body.text,
793
1035
  };
794
1036
  }, Math.min(OIDC_REQUEST_TIMEOUT_MS, remaining), signal);
795
- if (response.ok && isValidDeviceAccessTokenResponse(body)) {
796
- const tokenResponse = validateDeviceAccessTokenResponse(body);
797
- if (!requestedDeviceId ||
798
- typeof tokenResponse.scope !== "string" ||
799
- !scopeMatchesDevice(tokenResponse.scope, requestedDeviceId)) {
800
- throw new OidcValidationError("OIDC device authorization returned an unexpected granted scope");
1037
+ if (!response.ok && !isRecord(body)) {
1038
+ throw providerResponseFailure("device authorization", response, responseText ?? "", "OIDC device authorization returned an invalid response");
1039
+ }
1040
+ if (response.ok) {
1041
+ if (!isValidDeviceAccessTokenResponse(body)) {
1042
+ throw providerResponseFailure("device authorization", response, responseText ?? "", "OIDC device authorization returned an invalid response");
1043
+ }
1044
+ try {
1045
+ const tokenResponse = validateDeviceAccessTokenResponse(body);
1046
+ if (!requestedDeviceId ||
1047
+ typeof tokenResponse.scope !== "string" ||
1048
+ !scopeMatchesDevice(tokenResponse.scope, requestedDeviceId)) {
1049
+ throw new OidcValidationError("OIDC device authorization returned an unexpected granted scope");
1050
+ }
1051
+ ensureOidcNotCancelled(signal, "device authorization");
1052
+ return tokenResponse;
1053
+ }
1054
+ catch (error) {
1055
+ if (error instanceof OidcRequestCancelledError)
1056
+ throw error;
1057
+ throw responseValidationFailure("device authorization", response, responseText ?? "", error);
801
1058
  }
802
- ensureOidcNotCancelled(signal, "device authorization");
803
- return tokenResponse;
804
1059
  }
805
1060
  const error = isRecord(body) && typeof body.error === "string" ? body.error : undefined;
806
1061
  switch (error) {
@@ -815,7 +1070,7 @@ export async function waitForDeviceCodeLogin(authMetadata, clientId, session, si
815
1070
  return { error: SAFE_OAUTH_ERROR_CODES.has(error) ? error : "provider_error" };
816
1071
  default:
817
1072
  ensureOidcNotCancelled(signal, "device authorization");
818
- return { error: "provider_error" };
1073
+ throw providerResponseFailure("device authorization", response, responseText ?? "", "OIDC device authorization returned an unknown provider error");
819
1074
  }
820
1075
  await abortableDelay(Math.min(interval, Math.max(0, expiration - Date.now())), signal);
821
1076
  } while (Date.now() < expiration);
@@ -823,9 +1078,9 @@ export async function waitForDeviceCodeLogin(authMetadata, clientId, session, si
823
1078
  return { error: "expired" };
824
1079
  }
825
1080
  catch (err) {
826
- if (err instanceof OidcValidationError)
1081
+ if (err instanceof OidcValidationError || err instanceof AggregateError)
827
1082
  throw err;
828
- throw new StorageError(formatProviderFailure("device authorization", err));
1083
+ throw formatProviderFailure("device authorization", err);
829
1084
  }
830
1085
  }
831
1086
  export function isDeviceAccessTokenError(result) {
@@ -862,8 +1117,8 @@ export async function beginAuthorizationCodeFlow(opts) {
862
1117
  try {
863
1118
  generated = new URL(url);
864
1119
  }
865
- catch {
866
- throw new StorageError("OIDC authorization URL is invalid");
1120
+ catch (error) {
1121
+ throw new StorageError("OIDC authorization URL is invalid", { cause: error });
867
1122
  }
868
1123
  const expectedEndpoint = parseHttpUrl(authMetadata.authorization_endpoint, "authorization endpoint");
869
1124
  const stateValues = generated.searchParams.getAll("state");
@@ -900,24 +1155,35 @@ export async function completeAuthorizationCodeFlow(code, state, signal) {
900
1155
  const serialized = store.getItem(key);
901
1156
  if (!serialized)
902
1157
  throw new StorageError("OIDC authorization context is missing or expired");
1158
+ let context;
1159
+ let validationError;
903
1160
  if (serialized.length > MAX_AUTHORIZATION_CONTEXT_LENGTH) {
904
- store.removeItem(key);
905
- throw new StorageError("OIDC authorization context is too large");
1161
+ validationError = new StorageError("OIDC authorization context is too large");
906
1162
  }
907
- let context;
1163
+ else {
1164
+ try {
1165
+ context = parseAuthorizationContext(JSON.parse(serialized), state);
1166
+ }
1167
+ catch (error) {
1168
+ validationError = error instanceof StorageError
1169
+ ? error
1170
+ : new StorageError("OIDC authorization context is missing or invalid", { cause: error });
1171
+ }
1172
+ }
1173
+ // Consume the state before any network exchange. A retry must not replay
1174
+ // the authorization code, even if validation fails. Preserve a validation
1175
+ // failure when storage cleanup fails as well.
908
1176
  try {
909
- context = parseAuthorizationContext(JSON.parse(serialized), state);
1177
+ store.removeItem(key);
910
1178
  }
911
1179
  catch (error) {
912
- if (error instanceof StorageError)
913
- throw error;
914
- throw new StorageError("OIDC authorization context is missing or invalid");
915
- }
916
- finally {
917
- // Consume the state before any network exchange. A retry must not replay
918
- // the authorization code, even if the exchange fails.
919
- store.removeItem(key);
1180
+ if (validationError !== undefined) {
1181
+ throw new AggregateError([validationError, error], "OIDC authorization context validation and cleanup failed", { cause: validationError });
1182
+ }
1183
+ throw error;
920
1184
  }
1185
+ if (validationError !== undefined)
1186
+ throw validationError;
921
1187
  try {
922
1188
  requireBoundedString(code, "authorization code", MAX_OIDC_CODE_LENGTH);
923
1189
  const endpoint = exactEndpoint(context.authMetadata.token_endpoint, "token endpoint");
@@ -928,26 +1194,37 @@ export async function completeAuthorizationCodeFlow(code, state, signal) {
928
1194
  redirect_uri: context.redirectUri,
929
1195
  code,
930
1196
  });
931
- const { response, body } = await requestWithTimeout(endpoint, {
1197
+ const { response, body, responseText } = await requestWithTimeout(endpoint, {
932
1198
  method: "POST",
933
1199
  headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
934
1200
  body: params.toString(),
935
1201
  redirect: "manual",
936
1202
  }, "authorization code exchange", async (response, requestSignal) => {
937
- assertNoRedirect(response, endpoint, "authorization code exchange");
1203
+ await assertNoRedirect(response, endpoint, "authorization code exchange");
1204
+ const body = await readJsonResponse(response, "authorization code exchange", requestSignal);
938
1205
  return {
939
1206
  response,
940
- body: await readBoundedJsonResponse(response, "authorization code exchange", requestSignal),
1207
+ body: body.value,
1208
+ responseText: body.text,
941
1209
  };
942
1210
  }, OIDC_REQUEST_TIMEOUT_MS, signal);
943
- if (!response.ok)
944
- throw new StorageError("OIDC authorization code exchange failed");
945
- validateBearerTokenResponse(body);
946
- validateTokenBounds(body, "authorization code exchange");
947
- const normalized = normalizeBearerTokenResponseTokenType(body);
948
- const grantedScope = normalized.scope;
949
- if (typeof grantedScope !== "string" || !scopeMatchesDevice(grantedScope, context.deviceId)) {
950
- throw new StorageError("OIDC authorization code exchange returned an unexpected granted scope");
1211
+ if (!response.ok) {
1212
+ throw providerResponseFailure("authorization code exchange", response, responseText ?? "", "OIDC authorization code exchange failed");
1213
+ }
1214
+ let normalized;
1215
+ let grantedScope;
1216
+ try {
1217
+ validateBearerTokenResponse(body);
1218
+ validateTokenBounds(body, "authorization code exchange");
1219
+ normalized = normalizeBearerTokenResponseTokenType(body);
1220
+ const normalizedScope = normalized.scope;
1221
+ if (typeof normalizedScope !== "string" || !scopeMatchesDevice(normalizedScope, context.deviceId)) {
1222
+ throw new StorageError("OIDC authorization code exchange returned an unexpected granted scope");
1223
+ }
1224
+ grantedScope = normalizedScope;
1225
+ }
1226
+ catch (error) {
1227
+ throw responseValidationFailure("authorization code exchange", response, responseText ?? "", error);
951
1228
  }
952
1229
  const tokenResponse = {
953
1230
  ...normalized,
@@ -961,9 +1238,9 @@ export async function completeAuthorizationCodeFlow(code, state, signal) {
961
1238
  };
962
1239
  }
963
1240
  catch (err) {
964
- if (err instanceof StorageError)
1241
+ if (err instanceof StorageError || err instanceof AggregateError)
965
1242
  throw err;
966
- throw new StorageError(formatProviderFailure("authorization code exchange", err));
1243
+ throw formatProviderFailure("authorization code exchange", err);
967
1244
  }
968
1245
  }
969
1246
  // ---------------------------------------------------------------------------
@@ -1029,8 +1306,8 @@ function validateMatrixUserId(userId, serverName) {
1029
1306
  try {
1030
1307
  return validateCanonicalMatrixUserId(userId, serverName);
1031
1308
  }
1032
- catch {
1033
- throw new StorageError("OIDC identity confirmation returned an invalid or foreign user ID");
1309
+ catch (error) {
1310
+ throw new StorageError("OIDC identity confirmation returned an invalid or foreign user ID", { cause: error });
1034
1311
  }
1035
1312
  }
1036
1313
  /** Confirms {userId, deviceId} for a freshly-obtained access token via
@@ -1040,35 +1317,50 @@ function validateMatrixUserId(userId, serverName) {
1040
1317
  export async function whoAmI(homeserverUrl, accessToken, serverName, signal) {
1041
1318
  try {
1042
1319
  const homeserver = parseHttpUrl(homeserverUrl, "homeserver URL");
1043
- const baseUrl = homeserver.toString();
1044
1320
  requireBoundedString(accessToken, "access token", MAX_OIDC_TOKEN_LENGTH);
1045
- const client = createClient({
1046
- baseUrl,
1047
- accessToken,
1048
- localTimeoutMs: OIDC_REQUEST_TIMEOUT_MS,
1049
- fetchFn: boundedMatrixFetch("identity confirmation", signal),
1050
- });
1051
- const res = await client.whoami();
1052
- const userId = validateMatrixUserId(res?.user_id, serverName);
1053
- if (res.device_id !== undefined && res.device_id !== null) {
1321
+ const endpoint = matrixWhoAmIEndpoint(homeserver);
1322
+ const { response, body, responseText } = await requestWithTimeout(endpoint, {
1323
+ method: "GET",
1324
+ headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json" },
1325
+ redirect: "manual",
1326
+ }, "identity confirmation", async (response, requestSignal) => {
1327
+ await assertNoRedirect(response, endpoint, "identity confirmation");
1328
+ if (!response.ok) {
1329
+ const body = await readResponseText(response, requestSignal, "identity confirmation");
1330
+ return { response, body: undefined, responseText: body.text };
1331
+ }
1332
+ const body = await readJsonResponse(response, "identity confirmation", requestSignal);
1333
+ return { response, body: body.value, responseText: body.text };
1334
+ }, OIDC_REQUEST_TIMEOUT_MS, signal);
1335
+ if (!response.ok) {
1336
+ throw providerResponseFailure("identity confirmation", response, responseText ?? "", `OIDC identity confirmation failed (${response.status})`);
1337
+ }
1338
+ if (!isRecord(body)) {
1339
+ throw responseValidationFailure("identity confirmation", response, responseText ?? "", new StorageError("OIDC identity confirmation returned an invalid response"));
1340
+ }
1341
+ const userId = validateMatrixUserId(body.user_id, serverName);
1342
+ let deviceId = null;
1343
+ if (body.device_id !== undefined && body.device_id !== null) {
1054
1344
  try {
1055
- validateMatrixDeviceId(res.device_id);
1345
+ deviceId = validateMatrixDeviceId(body.device_id);
1056
1346
  }
1057
- catch {
1058
- throw new StorageError("OIDC identity confirmation returned an invalid device ID");
1347
+ catch (error) {
1348
+ throw new StorageError("OIDC identity confirmation returned an invalid device ID", { cause: error });
1059
1349
  }
1060
1350
  }
1061
1351
  if (signal?.aborted)
1062
1352
  throw new OidcRequestCancelledError("identity confirmation");
1063
- return { userId, deviceId: res.device_id ?? null };
1353
+ return { userId, deviceId };
1064
1354
  }
1065
1355
  catch (err) {
1356
+ if (err instanceof AggregateError)
1357
+ throw err;
1066
1358
  if (signal?.aborted) {
1067
1359
  throw new StorageError("OIDC identity confirmation cancelled");
1068
1360
  }
1069
1361
  if (err instanceof StorageError)
1070
1362
  throw err;
1071
- throw new StorageError(formatProviderFailure("identity confirmation", err));
1363
+ throw formatProviderFailure("identity confirmation", err);
1072
1364
  }
1073
1365
  }
1074
1366
  /**
@@ -1076,9 +1368,8 @@ export async function whoAmI(homeserverUrl, accessToken, serverName, signal) {
1076
1368
  * POST to the token endpoint. A public client (`token_endpoint_auth_method:
1077
1369
  * "none"`, what `registerClient` above registers) authenticates a refresh
1078
1370
  * with just `client_id` in the body, no secret. This narrow request remains
1079
- * local because Matrix 42's `OAuth2.performRefreshTokenGrant` parses every
1080
- * non-success body with unbounded `response.json()`; this path bounds the
1081
- * body and exposes only an allowlisted OAuth error code.
1371
+ * local because Matrix 42's `OAuth2.performRefreshTokenGrant` exposes
1372
+ * provider-controlled response details directly.
1082
1373
  */
1083
1374
  async function refreshOidcToken(tokenEndpoint, clientId, refreshToken, expectedDeviceId, signal) {
1084
1375
  const endpoint = exactEndpoint(tokenEndpoint, "token endpoint");
@@ -1102,35 +1393,43 @@ async function refreshOidcToken(tokenEndpoint, clientId, refreshToken, expectedD
1102
1393
  }).toString(),
1103
1394
  signal: controller.signal,
1104
1395
  redirect: "manual",
1105
- }), controller.signal, () => undefined, () => signal?.aborted
1106
- ? new StorageError("OIDC token refresh cancelled")
1107
- : new StorageError("OIDC token refresh timed out"));
1108
- assertNoRedirect(res, endpoint, "token refresh");
1396
+ }), controller.signal, () => signal?.aborted
1397
+ ? new StorageError("OIDC token refresh cancelled", {
1398
+ cause: new OidcRequestCancelledError("token refresh"),
1399
+ })
1400
+ : new StorageError("OIDC token refresh timed out", {
1401
+ cause: new OidcRequestTimeoutError("token refresh"),
1402
+ }));
1403
+ await assertNoRedirect(res, endpoint, "token refresh");
1109
1404
  if (!res.ok) {
1110
1405
  let body;
1111
1406
  try {
1112
- body = await readBoundedResponseText(res, MAX_OAUTH_ERROR_BODY_BYTES, controller.signal);
1407
+ body = await readResponseText(res, controller.signal, "token refresh");
1113
1408
  }
1114
1409
  catch (error) {
1115
1410
  if (signal?.aborted || controller.signal.aborted)
1116
1411
  throw error;
1117
- body = { text: "", truncated: false };
1412
+ throw new StorageError("OIDC token refresh error response could not be read", {
1413
+ cause: diagnosticCause(error),
1414
+ });
1118
1415
  }
1119
- throw new StorageError(formatTokenRefreshFailure(res.status, body));
1120
- }
1121
- const body = await readBoundedResponseText(res, MAX_OAUTH_SUCCESS_BODY_BYTES, controller.signal);
1122
- if (body.truncated) {
1123
- throw new StorageError("OIDC token refresh returned an oversized response");
1416
+ throw formatTokenRefreshFailure(res.status, body);
1124
1417
  }
1418
+ const body = await readResponseText(res, controller.signal, "token refresh");
1419
+ const invalidResponse = (message, cause) => new StorageError(message, {
1420
+ cause: new OidcResponseError("token refresh", res.status, body.text, cause),
1421
+ });
1125
1422
  let data;
1126
1423
  try {
1127
1424
  data = JSON.parse(body.text);
1128
1425
  }
1129
- catch {
1130
- throw new StorageError("OIDC token refresh returned an invalid response");
1426
+ catch (error) {
1427
+ throw new StorageError("OIDC token refresh returned an invalid response", {
1428
+ cause: new OidcResponseError("token refresh", res.status, body.text, error),
1429
+ });
1131
1430
  }
1132
1431
  if (!data || typeof data !== "object") {
1133
- throw new StorageError("OIDC token refresh returned an invalid response");
1432
+ throw invalidResponse("OIDC token refresh returned an invalid response");
1134
1433
  }
1135
1434
  const record = data;
1136
1435
  const accessToken = record.access_token;
@@ -1141,42 +1440,42 @@ async function refreshOidcToken(tokenEndpoint, clientId, refreshToken, expectedD
1141
1440
  accessToken.trim() === "" ||
1142
1441
  accessToken.length > MAX_OIDC_TOKEN_LENGTH ||
1143
1442
  /[\s\u0000-\u001f\u007f]/.test(accessToken)) {
1144
- throw new StorageError("OIDC token refresh returned no access token");
1443
+ throw invalidResponse("OIDC token refresh returned no access token");
1145
1444
  }
1146
1445
  const tokenType = record.token_type;
1147
1446
  if (typeof tokenType !== "string" || tokenType.toLowerCase() !== "bearer") {
1148
- throw new StorageError("OIDC token refresh returned an invalid token type");
1447
+ throw invalidResponse("OIDC token refresh returned an invalid token type");
1149
1448
  }
1150
1449
  if (nextRefreshToken !== undefined &&
1151
1450
  (typeof nextRefreshToken !== "string" ||
1152
1451
  nextRefreshToken.trim() === "" ||
1153
1452
  nextRefreshToken.length > MAX_OIDC_TOKEN_LENGTH ||
1154
1453
  /[\s\u0000-\u001f\u007f]/.test(nextRefreshToken))) {
1155
- throw new StorageError("OIDC token refresh returned an invalid refresh token");
1454
+ throw invalidResponse("OIDC token refresh returned an invalid refresh token");
1156
1455
  }
1157
1456
  if (grantedScope !== undefined) {
1158
1457
  if (typeof grantedScope !== "string") {
1159
- throw new StorageError("OIDC token refresh returned an invalid scope");
1458
+ throw invalidResponse("OIDC token refresh returned an invalid scope");
1160
1459
  }
1161
1460
  try {
1162
1461
  requireScope(grantedScope, "scope");
1163
1462
  }
1164
- catch {
1165
- throw new StorageError("OIDC token refresh returned an invalid scope");
1463
+ catch (error) {
1464
+ throw invalidResponse("OIDC token refresh returned an invalid scope", { cause: error });
1166
1465
  }
1167
1466
  if (!scopeMatchesDevice(grantedScope, expectedDeviceId)) {
1168
- throw new StorageError("OIDC token refresh returned an unexpected granted scope");
1467
+ throw invalidResponse("OIDC token refresh returned an unexpected granted scope");
1169
1468
  }
1170
1469
  }
1171
1470
  else {
1172
- throw new StorageError("OIDC token refresh returned no device-bound scope");
1471
+ throw invalidResponse("OIDC token refresh returned no device-bound scope");
1173
1472
  }
1174
1473
  if (expiresIn !== undefined &&
1175
1474
  (typeof expiresIn !== "number" ||
1176
1475
  !Number.isInteger(expiresIn) ||
1177
1476
  expiresIn < 0 ||
1178
1477
  expiresIn > 86400)) {
1179
- throw new StorageError("OIDC token refresh returned an invalid expiry");
1478
+ throw invalidResponse("OIDC token refresh returned an invalid expiry");
1180
1479
  }
1181
1480
  ensureOidcNotCancelled(signal, "token refresh");
1182
1481
  return {
@@ -1190,15 +1489,15 @@ async function refreshOidcToken(tokenEndpoint, clientId, refreshToken, expectedD
1190
1489
  };
1191
1490
  }
1192
1491
  catch (error) {
1193
- if (error instanceof StorageError)
1492
+ if (error instanceof StorageError || error instanceof AggregateError)
1194
1493
  throw error;
1195
1494
  if (signal?.aborted) {
1196
- throw new StorageError("OIDC token refresh cancelled");
1495
+ throw new StorageError("OIDC token refresh cancelled", { cause: diagnosticCause(error) });
1197
1496
  }
1198
1497
  if (controller.signal.aborted) {
1199
- throw new StorageError("OIDC token refresh timed out");
1498
+ throw new StorageError("OIDC token refresh timed out", { cause: diagnosticCause(error) });
1200
1499
  }
1201
- throw new StorageError("OIDC token refresh failed");
1500
+ throw new StorageError("OIDC token refresh failed", { cause: diagnosticCause(error) });
1202
1501
  }
1203
1502
  finally {
1204
1503
  clearTimeout(timeout);
@@ -1210,27 +1509,30 @@ async function revokeOidcToken(revocationEndpoint, clientId, token, tokenTypeHin
1210
1509
  const endpoint = exactEndpoint(revocationEndpoint, "revocation endpoint");
1211
1510
  const safeClientId = requireClientId(clientId);
1212
1511
  const safeToken = requireBoundedString(token, "token", MAX_OIDC_TOKEN_LENGTH);
1213
- const { response } = await requestWithTimeout(endpoint, {
1214
- method: "POST",
1215
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
1216
- body: new URLSearchParams({
1217
- client_id: safeClientId,
1218
- token: safeToken,
1219
- token_type_hint: tokenTypeHint,
1220
- }).toString(),
1221
- redirect: "manual",
1222
- }, "token revocation", async (res, requestSignal) => {
1223
- assertNoRedirect(res, endpoint, "token revocation");
1224
- if (!res.ok) {
1225
- await readBoundedResponseText(res, MAX_OAUTH_ERROR_BODY_BYTES, requestSignal);
1226
- }
1227
- else {
1228
- await readBoundedResponseText(res, MAX_OAUTH_SUCCESS_BODY_BYTES, requestSignal);
1512
+ try {
1513
+ const { response, responseText } = await requestWithTimeout(endpoint, {
1514
+ method: "POST",
1515
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1516
+ body: new URLSearchParams({
1517
+ client_id: safeClientId,
1518
+ token: safeToken,
1519
+ token_type_hint: tokenTypeHint,
1520
+ }).toString(),
1521
+ redirect: "manual",
1522
+ }, "token revocation", async (res, requestSignal) => {
1523
+ await assertNoRedirect(res, endpoint, "token revocation");
1524
+ const body = await readResponseText(res, requestSignal, "token revocation");
1525
+ return { response: res, responseText: body.text };
1526
+ }, OIDC_REQUEST_TIMEOUT_MS, signal);
1527
+ if (!response.ok) {
1528
+ throw providerResponseFailure("token revocation", response, responseText ?? "", "OIDC token revocation failed");
1229
1529
  }
1230
- return { response: res };
1231
- }, OIDC_REQUEST_TIMEOUT_MS, signal);
1232
- if (!response.ok)
1233
- throw new StorageError("OIDC token revocation failed");
1530
+ }
1531
+ catch (error) {
1532
+ if (error instanceof StorageError || error instanceof AggregateError)
1533
+ throw error;
1534
+ throw formatProviderFailure("token revocation", error);
1535
+ }
1234
1536
  }
1235
1537
  async function persistOidcTokens(onPersist, tokens, externalSignal) {
1236
1538
  if (externalSignal?.aborted)
@@ -1250,7 +1552,7 @@ async function persistOidcTokens(onPersist, tokens, externalSignal) {
1250
1552
  // Promise.resolve().then() also observes synchronous callback throws and
1251
1553
  // lets raceWithAbort attach a rejection handler before a late adapter
1252
1554
  // rejection can become unhandled after the bounded call has settled.
1253
- await raceWithAbort(Promise.resolve().then(() => onPersist(tokens, controller.signal)), controller.signal, () => undefined, () => timedOut
1555
+ await raceWithAbort(Promise.resolve().then(() => onPersist(tokens, controller.signal)), controller.signal, () => timedOut
1254
1556
  ? new OidcRequestTimeoutError("token persistence")
1255
1557
  : new OidcRequestCancelledError("token persistence"));
1256
1558
  // The persistence promise and the caller signal can settle in either
@@ -1295,26 +1597,42 @@ export function buildTokenRefreshFunction(metadata, clientId, onPersist, expecte
1295
1597
  try {
1296
1598
  await persistOidcTokens(onPersist, { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken }, signal);
1297
1599
  }
1298
- catch {
1600
+ catch (persistenceError) {
1299
1601
  if (revocationEndpoint) {
1300
1602
  // Cleanup must remain possible even when the caller's signal is
1301
1603
  // already aborted. Use a separate bounded signal so persistence
1302
1604
  // failure cannot strand freshly-issued tokens merely because the
1303
1605
  // original request was cancelled.
1304
1606
  const cleanup = createOidcCleanupSignal();
1607
+ const cleanupFailures = [];
1305
1608
  try {
1306
- await revokeOidcToken(revocationEndpoint, safeClientId, tokens.accessToken, "access_token", cleanup.signal);
1609
+ try {
1610
+ await revokeOidcToken(revocationEndpoint, safeClientId, tokens.accessToken, "access_token", cleanup.signal);
1611
+ }
1612
+ catch (error) {
1613
+ cleanupFailures.push(error);
1614
+ }
1307
1615
  if (tokens.refreshToken) {
1308
- await revokeOidcToken(revocationEndpoint, safeClientId, tokens.refreshToken, "refresh_token", cleanup.signal);
1616
+ try {
1617
+ await revokeOidcToken(revocationEndpoint, safeClientId, tokens.refreshToken, "refresh_token", cleanup.signal);
1618
+ }
1619
+ catch (error) {
1620
+ cleanupFailures.push(error);
1621
+ }
1309
1622
  }
1310
1623
  }
1311
- catch {
1624
+ finally {
1312
1625
  cleanup.close();
1313
- throw new StorageError("OIDC token persistence failed and session cleanup was incomplete");
1314
1626
  }
1315
- cleanup.close();
1627
+ if (cleanupFailures.length > 0) {
1628
+ throw new StorageError("OIDC token persistence failed and session cleanup was incomplete", {
1629
+ cause: new AggregateError([persistenceError, ...cleanupFailures], "OIDC token persistence and session cleanup failed"),
1630
+ });
1631
+ }
1316
1632
  }
1317
- throw new StorageError("OIDC token persistence failed; discard the refreshed session");
1633
+ throw new StorageError("OIDC token persistence failed; discard the refreshed session", {
1634
+ cause: persistenceError,
1635
+ });
1318
1636
  }
1319
1637
  ensureOidcNotCancelled(signal, "token refresh");
1320
1638
  return tokens;