@telecrypt-io/storage 0.6.1 → 0.7.1

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
@@ -11,35 +11,13 @@ import { StorageError } from "./errors.js";
11
11
  import { cancelResponseBody, raceWithAbort, readResponseBody, ResponseBodyReadError, } from "./http.js";
12
12
  import { validateCanonicalMatrixUserId, validateMatrixDeviceId, } from "./constants.js";
13
13
  const AUTHORIZATION_CONTEXT_PREFIX = "telecrypt:oauth2:pkce:v1:";
14
- const AUTHORIZATION_CONTEXT_VERSION = 1;
15
- // The callback context is a short-lived transaction, not a durable login
16
- // session. Ten minutes covers normal redirects while limiting replay after a
17
- // stale browser tab or session-storage copy is recovered.
18
- const AUTHORIZATION_CONTEXT_MAX_AGE_MS = 10 * 60 * 1000;
19
- const AUTHORIZATION_CONTEXT_MAX_FUTURE_SKEW_MS = 30 * 1000;
20
- const MAX_AUTHORIZATION_CONTEXT_LENGTH = 64 * 1024;
21
14
  const PKCE_VERIFIER_PATTERN = /^[A-Za-z0-9._~-]{43,128}$/;
22
- const DEVICE_ID_PATTERN = /^[A-Za-z0-9._~-]{1,128}$/;
23
- const STATE_PATTERN = /^[A-Za-z0-9._~-]{32,128}$/;
24
15
  const OIDC_REQUEST_TIMEOUT_MS = 30_000;
25
16
  const OIDC_REFRESH_TIMEOUT_MS = 30_000;
26
17
  const OIDC_CLEANUP_TIMEOUT_MS = 30_000;
27
- const MAX_OIDC_URL_LENGTH = 2048;
28
- const MAX_OIDC_METADATA_STRING_LENGTH = 4096;
29
- const MAX_OIDC_CLIENT_ID_LENGTH = 512;
30
- const MAX_OIDC_CODE_LENGTH = 4096;
31
- const MAX_OIDC_TOKEN_LENGTH = 8192;
32
- const MAX_OIDC_SCOPE_LENGTH = 4096;
33
- const MAX_OIDC_CONTACTS = 16;
34
- const MAX_OIDC_REDIRECT_URIS = 8;
35
18
  const LEGACY_MATRIX_SCOPE_PREFIX = "urn:matrix:client:";
36
19
  const MSC2967_MATRIX_SCOPE_PREFIX = "urn:matrix:org.matrix.msc2967.client:";
37
20
  const MATRIX_SCOPE_PREFIXES = [LEGACY_MATRIX_SCOPE_PREFIX, MSC2967_MATRIX_SCOPE_PREFIX];
38
- // MAS advertises a 20-minute device-code lifetime. Keep that provider window
39
- // as the hard upper bound while retaining a finite polling budget.
40
- const MAX_DEVICE_EXPIRES_IN_SECONDS = 20 * 60;
41
- const MAX_DEVICE_INTERVAL_SECONDS = 5 * 60;
42
- const MAX_DEVICE_POLL_DURATION_MS = MAX_DEVICE_EXPIRES_IN_SECONDS * 1000;
43
21
  const deviceAuthorizationDeviceIds = new WeakMap();
44
22
  const SAFE_OAUTH_ERROR_CODES = new Set([
45
23
  "invalid_request",
@@ -82,7 +60,7 @@ class OidcValidationError extends StorageError {
82
60
  this.name = "OidcValidationError";
83
61
  }
84
62
  }
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;
63
+ 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|private[_-]?key|encryption|signing|password|secret|api[_-]?key|recovery[_-]?key|cookie|session)/iu;
86
64
  const DIAGNOSTIC_EMAIL = /[A-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Z0-9-]+(?:\.[A-Z0-9-]+)+/giu;
87
65
  const DIAGNOSTIC_MXID = /@[A-Z0-9._=+\-/]+:[A-Z0-9.-]+/giu;
88
66
  const DIAGNOSTIC_ULID = /\b[0-9A-HJKMNP-TV-Z]{26}\b/giu;
@@ -112,194 +90,19 @@ class OidcResponseError extends Error {
112
90
  this.status = status;
113
91
  }
114
92
  }
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]");
93
+ /** Copy only a native transport error's message across the SDK boundary. */
94
+ function diagnosticCause(error, seen = new Set()) {
95
+ if (error instanceof Error && seen.has(error))
96
+ return new Error("cyclic failure cause");
97
+ const detail = error instanceof Error
98
+ ? `${error.name}: ${error.message}`
99
+ : typeof error === "string"
100
+ ? error
101
+ : "request failed";
102
+ const cause = error instanceof Error ? error.cause : undefined;
103
+ if (error instanceof Error)
246
104
  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;
105
+ return new Error(sanitizeDiagnosticText(detail) || "request failed", cause instanceof Error ? { cause: diagnosticCause(cause, seen) } : undefined);
303
106
  }
304
107
  function isRecord(value) {
305
108
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -311,7 +114,6 @@ function randomHex(byteLength) {
311
114
  function requireString(value, name, pattern) {
312
115
  if (typeof value !== "string" ||
313
116
  value.length === 0 ||
314
- value.length > MAX_OIDC_METADATA_STRING_LENGTH ||
315
117
  /[\u0000-\u001f\u007f]/.test(value) ||
316
118
  (pattern && !pattern.test(value))) {
317
119
  throw new StorageError(`OIDC authorization context has an invalid ${name}`);
@@ -320,27 +122,32 @@ function requireString(value, name, pattern) {
320
122
  }
321
123
  function requireClientId(value) {
322
124
  const clientId = requireString(value, "client ID");
323
- if (clientId.length > MAX_OIDC_CLIENT_ID_LENGTH || /\s/.test(clientId)) {
125
+ if (/\s/.test(clientId)) {
324
126
  throw new StorageError("OIDC authorization context has an invalid client ID");
325
127
  }
326
128
  return clientId;
327
129
  }
328
- function requireBoundedString(value, name, maxLength) {
329
- if (typeof value !== "string" ||
330
- value.length === 0 ||
331
- value.length > maxLength ||
332
- /[\s\u0000-\u001f\u007f]/.test(value)) {
333
- throw new StorageError(`OIDC authorization context has an invalid ${name}`);
130
+ function requireDeviceId(value) {
131
+ try {
132
+ return validateMatrixDeviceId(value);
133
+ }
134
+ catch (error) {
135
+ throw new StorageError("OIDC authorization context has an invalid device ID", { cause: error });
136
+ }
137
+ }
138
+ function isDeviceId(value) {
139
+ try {
140
+ validateMatrixDeviceId(value);
141
+ return true;
142
+ }
143
+ catch {
144
+ return false;
334
145
  }
335
- return value;
336
146
  }
337
- function requireScope(value, name) {
147
+ function requireOpaqueString(value, name) {
338
148
  if (typeof value !== "string" ||
339
149
  value.length === 0 ||
340
- value.length > MAX_OIDC_SCOPE_LENGTH ||
341
- /[\u0000-\u001f\u007f]/.test(value) ||
342
- value.trim() !== value ||
343
- /\s{2,}/.test(value)) {
150
+ /[\s\u0000-\u001f\u007f]/.test(value)) {
344
151
  throw new StorageError(`OIDC authorization context has an invalid ${name}`);
345
152
  }
346
153
  return value;
@@ -350,7 +157,7 @@ function isLoopbackHostname(hostname) {
350
157
  return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1";
351
158
  }
352
159
  function parseSafeRedirectUri(value, name) {
353
- const text = requireBoundedString(value, name, MAX_OIDC_URL_LENGTH);
160
+ const text = requireOpaqueString(value, name);
354
161
  let parsed;
355
162
  try {
356
163
  parsed = new URL(text);
@@ -369,10 +176,9 @@ function parseSafeRedirectUri(value, name) {
369
176
  }
370
177
  return parsed;
371
178
  }
372
- function requireMetadataString(value, name, maxLength = MAX_OIDC_METADATA_STRING_LENGTH) {
179
+ function requireMetadataString(value, name) {
373
180
  if (typeof value !== "string" ||
374
181
  value.length === 0 ||
375
- value.length > maxLength ||
376
182
  /[\u0000-\u001f\u007f]/.test(value)) {
377
183
  throw new StorageError(`OIDC dynamic client registration has an invalid ${name}`);
378
184
  }
@@ -388,30 +194,23 @@ function validateRegistrationMetadata(metadata) {
388
194
  ["policy URI", metadata.policyUri],
389
195
  ]) {
390
196
  if (value !== undefined) {
391
- const uri = parseHttpUrl(value, name);
392
- if (uri.origin !== clientUri.origin) {
393
- throw new StorageError("OIDC dynamic client registration has an untrusted metadata origin");
394
- }
197
+ parseHttpUrl(value, name);
395
198
  }
396
199
  }
397
200
  if (metadata.contacts !== undefined) {
398
- if (!Array.isArray(metadata.contacts) || metadata.contacts.length > MAX_OIDC_CONTACTS) {
399
- throw new StorageError("OIDC dynamic client registration has too many contacts");
201
+ if (!Array.isArray(metadata.contacts)) {
202
+ throw new StorageError("OIDC dynamic client registration has invalid contacts");
400
203
  }
401
- metadata.contacts.forEach((contact) => requireMetadataString(contact, "contact", 512));
204
+ metadata.contacts.forEach((contact) => requireMetadataString(contact, "contact"));
402
205
  }
403
206
  if (metadata.redirectUris === undefined)
404
207
  return { clientUri };
405
208
  if (!Array.isArray(metadata.redirectUris) ||
406
- metadata.redirectUris.length === 0 ||
407
- metadata.redirectUris.length > MAX_OIDC_REDIRECT_URIS) {
408
- throw new StorageError("OIDC dynamic client registration has too many redirect URIs");
209
+ metadata.redirectUris.length === 0) {
210
+ throw new StorageError("OIDC dynamic client registration has invalid redirect URIs");
409
211
  }
410
212
  const redirectUris = metadata.redirectUris.map((value) => {
411
213
  const uri = parseSafeRedirectUri(value, "redirect URI");
412
- if (uri.origin !== clientUri.origin) {
413
- throw new StorageError("OIDC dynamic client registration has an untrusted redirect origin");
414
- }
415
214
  return uri.toString();
416
215
  });
417
216
  return { clientUri, redirectUris };
@@ -451,7 +250,7 @@ async function assertNoRedirect(response, endpoint, operation) {
451
250
  (response.url !== "" && response.url !== endpoint) ||
452
251
  response.redirected) {
453
252
  const failure = new StorageError(`OIDC ${operation} rejected an untrusted redirect`);
454
- await cancelResponseBody(response, `OIDC ${operation} redirect`, failure);
253
+ cancelResponseBody(response);
455
254
  throw failure;
456
255
  }
457
256
  }
@@ -563,20 +362,28 @@ function abortableDelay(milliseconds, signal) {
563
362
  return Promise.reject(new OidcRequestCancelledError("device authorization"));
564
363
  return new Promise((resolve, reject) => {
565
364
  let timer;
365
+ let remaining = milliseconds;
566
366
  const abort = () => {
567
367
  clearTimeout(timer);
568
368
  signal?.removeEventListener("abort", abort);
569
369
  reject(new OidcRequestCancelledError("device authorization"));
570
370
  };
571
- timer = setTimeout(() => {
572
- signal?.removeEventListener("abort", abort);
573
- resolve();
574
- }, milliseconds);
371
+ const schedule = () => {
372
+ if (remaining <= 0) {
373
+ signal?.removeEventListener("abort", abort);
374
+ resolve();
375
+ return;
376
+ }
377
+ const delay = Math.min(remaining, 2_147_483_647);
378
+ remaining -= delay;
379
+ timer = setTimeout(schedule, delay);
380
+ };
575
381
  signal?.addEventListener("abort", abort, { once: true });
382
+ schedule();
576
383
  });
577
384
  }
578
- function parseHttpUrl(value, name) {
579
- const text = requireBoundedString(value, name, MAX_OIDC_URL_LENGTH);
385
+ function parseHttpUrl(value, name, allowQuery = false) {
386
+ const text = requireOpaqueString(value, name);
580
387
  let parsed;
581
388
  try {
582
389
  parsed = new URL(text);
@@ -589,7 +396,7 @@ function parseHttpUrl(value, name) {
589
396
  (parsed.protocol === "http:" && !isLoopbackHostname(parsed.hostname)) ||
590
397
  parsed.username !== "" ||
591
398
  parsed.password !== "" ||
592
- parsed.search !== "" ||
399
+ (!allowQuery && parsed.search !== "") ||
593
400
  parsed.hash !== "" ||
594
401
  (parsed.toString() !== text && !isRootWithoutSlash)) {
595
402
  throw new StorageError(`OIDC authorization context has an invalid ${name}`);
@@ -602,6 +409,15 @@ function isWithinIssuerPath(issuer, endpoint) {
602
409
  const prefix = issuer.pathname.endsWith("/") ? issuer.pathname : `${issuer.pathname}/`;
603
410
  return endpoint.pathname === issuer.pathname || endpoint.pathname.startsWith(prefix);
604
411
  }
412
+ /** Validates an endpoint against the configured homeserver and optional issuer path. */
413
+ export function assertOidcEndpoint(value, trustedHomeserver, name, issuer, allowQuery = false) {
414
+ const homeserver = parseHttpUrl(trustedHomeserver, "homeserver URL");
415
+ const endpoint = parseHttpUrl(value, name, allowQuery);
416
+ if (endpoint.origin !== homeserver.origin || (issuer && !isWithinIssuerPath(issuer, endpoint))) {
417
+ throw new StorageError(`${name} must remain on the configured OIDC origin and issuer path`);
418
+ }
419
+ return endpoint.toString();
420
+ }
605
421
  function matrixAuthMetadataEndpoint(homeserver) {
606
422
  const basePath = homeserver.pathname.replace(/\/+$/u, "");
607
423
  return new URL(`${basePath}/_matrix/client/v1/auth_metadata`, homeserver.origin).toString();
@@ -645,7 +461,6 @@ function validateAuthMetadata(value) {
645
461
  if (field !== undefined &&
646
462
  (typeof field !== "string" ||
647
463
  field.length === 0 ||
648
- field.length > MAX_OIDC_URL_LENGTH ||
649
464
  /[\u0000-\u001f\u007f]/.test(field))) {
650
465
  throw new StorageError("OIDC authorization context has invalid auth metadata");
651
466
  }
@@ -664,7 +479,6 @@ function validateAuthMetadata(value) {
664
479
  if (field !== undefined &&
665
480
  (typeof field !== "string" ||
666
481
  field.length === 0 ||
667
- field.length > MAX_OIDC_URL_LENGTH ||
668
482
  /[\u0000-\u001f\u007f]/.test(field))) {
669
483
  throw new StorageError("OIDC authorization context has invalid auth metadata");
670
484
  }
@@ -679,9 +493,8 @@ function validateAuthMetadata(value) {
679
493
  ]) {
680
494
  const field = metadata[name];
681
495
  if (field !== undefined &&
682
- (!Array.isArray(field) || field.length > 32 || field.some((item) => typeof item !== "string" ||
496
+ (!Array.isArray(field) || field.some((item) => typeof item !== "string" ||
683
497
  item.length === 0 ||
684
- item.length > MAX_OIDC_METADATA_STRING_LENGTH ||
685
498
  /[\u0000-\u001f\u007f]/.test(item)))) {
686
499
  throw new StorageError("OIDC authorization context has invalid auth metadata");
687
500
  }
@@ -724,21 +537,18 @@ function validateDeviceAuthorizationSession(value) {
724
537
  throw new StorageError("OIDC device authorization returned an invalid response", { cause: error });
725
538
  }
726
539
  const session = value;
727
- requireBoundedString(session.device_code, "device code", MAX_OIDC_CODE_LENGTH);
728
- requireBoundedString(session.user_code, "user code", MAX_OIDC_CODE_LENGTH);
540
+ requireOpaqueString(session.device_code, "device code");
541
+ requireOpaqueString(session.user_code, "user code");
729
542
  parseSafeRedirectUri(session.verification_uri, "verification URI");
730
543
  if (session.verification_uri_complete !== undefined) {
731
544
  parseSafeRedirectUri(session.verification_uri_complete, "complete verification URI");
732
545
  }
733
- if (!Number.isInteger(session.expires_in) ||
734
- session.expires_in <= 0 ||
735
- session.expires_in > MAX_DEVICE_EXPIRES_IN_SECONDS) {
546
+ if (!Number.isSafeInteger(session.expires_in) ||
547
+ session.expires_in <= 0) {
736
548
  throw new StorageError("OIDC device authorization returned an invalid expiry");
737
549
  }
738
550
  if (session.interval !== undefined &&
739
- (!Number.isInteger(session.interval) ||
740
- session.interval <= 0 ||
741
- session.interval > MAX_DEVICE_INTERVAL_SECONDS)) {
551
+ (!Number.isSafeInteger(session.interval) || session.interval <= 0)) {
742
552
  throw new StorageError("OIDC device authorization returned an invalid polling interval");
743
553
  }
744
554
  return session;
@@ -756,18 +566,16 @@ function validateDeviceAccessTokenResponse(value) {
756
566
  }
757
567
  function validateTokenBounds(value, operation) {
758
568
  try {
759
- requireBoundedString(value.access_token, "access token", MAX_OIDC_TOKEN_LENGTH);
569
+ requireOpaqueString(value.access_token, "access token");
760
570
  if (value.refresh_token !== undefined) {
761
- requireBoundedString(value.refresh_token, "refresh token", MAX_OIDC_TOKEN_LENGTH);
571
+ requireOpaqueString(value.refresh_token, "refresh token");
762
572
  }
763
- if (value.scope !== undefined)
764
- requireScope(value.scope, "scope");
765
573
  }
766
574
  catch (error) {
767
575
  throw new StorageError(`OIDC ${operation} returned an invalid token response`, { cause: error });
768
576
  }
769
577
  if (value.expires_in !== undefined &&
770
- (!Number.isInteger(value.expires_in) || value.expires_in < 0 || value.expires_in > 86400)) {
578
+ (!Number.isSafeInteger(value.expires_in) || value.expires_in < 0)) {
771
579
  throw new StorageError(`OIDC ${operation} returned an invalid token expiry`);
772
580
  }
773
581
  }
@@ -787,26 +595,16 @@ function validateAuthorizationInputs(opts) {
787
595
  }
788
596
  requireClientId(opts.clientId);
789
597
  const deviceId = opts.deviceId ?? randomHex(5).toUpperCase();
790
- requireString(deviceId, "device ID", DEVICE_ID_PATTERN);
598
+ requireDeviceId(deviceId);
791
599
  return { authMetadata, homeserver, redirect, deviceId };
792
600
  }
793
601
  function parseAuthorizationContext(value, expectedState) {
794
- if (!isRecord(value) || value.version !== AUTHORIZATION_CONTEXT_VERSION) {
602
+ if (!isRecord(value)) {
795
603
  throw new StorageError("OIDC authorization context is missing or invalid");
796
604
  }
797
605
  if (value.state !== expectedState) {
798
606
  throw new StorageError("OIDC authorization context state does not match callback state");
799
607
  }
800
- if (typeof value.createdAtMs !== "number" || !Number.isSafeInteger(value.createdAtMs)) {
801
- throw new StorageError("OIDC authorization context has an invalid creation time");
802
- }
803
- const ageMs = Date.now() - value.createdAtMs;
804
- if (ageMs < -AUTHORIZATION_CONTEXT_MAX_FUTURE_SKEW_MS) {
805
- throw new StorageError("OIDC authorization context is from the future");
806
- }
807
- if (ageMs > AUTHORIZATION_CONTEXT_MAX_AGE_MS) {
808
- throw new StorageError("OIDC authorization context is expired");
809
- }
810
608
  const authMetadata = validateTrustedAuthMetadata(value.authMetadata);
811
609
  const homeserver = parseHttpUrl(value.homeserverUrl, "homeserver URL");
812
610
  const redirect = parseHttpUrl(value.redirectUri, "redirect URI");
@@ -814,14 +612,12 @@ function parseAuthorizationContext(value, expectedState) {
814
612
  throw new StorageError("OIDC authorization context origins are not trusted");
815
613
  }
816
614
  return {
817
- version: AUTHORIZATION_CONTEXT_VERSION,
818
- createdAtMs: value.createdAtMs,
819
615
  state: expectedState,
820
616
  authMetadata,
821
617
  clientId: requireClientId(value.clientId),
822
618
  redirectUri: redirect.toString(),
823
619
  homeserverUrl: homeserver.toString(),
824
- deviceId: requireString(value.deviceId, "device ID", DEVICE_ID_PATTERN),
620
+ deviceId: requireDeviceId(value.deviceId),
825
621
  codeVerifier: requireString(value.codeVerifier, "PKCE verifier", PKCE_VERIFIER_PATTERN),
826
622
  };
827
623
  }
@@ -853,7 +649,12 @@ export async function discoverOidcIssuer(homeserverBaseUrl, signal) {
853
649
  throw failure;
854
650
  }
855
651
  ensureOidcNotCancelled(signal, "discovery");
856
- return validateTrustedAuthMetadata(body);
652
+ try {
653
+ return validateTrustedAuthMetadata(body);
654
+ }
655
+ catch (error) {
656
+ throw responseValidationFailure("discovery", response, responseText ?? "", error);
657
+ }
857
658
  }
858
659
  catch (err) {
859
660
  if (err instanceof AggregateError)
@@ -862,9 +663,8 @@ export async function discoverOidcIssuer(homeserverBaseUrl, signal) {
862
663
  }
863
664
  }
864
665
  /**
865
- * Dynamic client registration (DCR). `clientUri`/`redirectUris` must share a
866
- * host unless the issuer's policy allows a mismatch (our local dev/test MAS
867
- * does; a production issuer may not).
666
+ * Dynamic client registration (DCR). The issuer validates the supplied
667
+ * metadata; the PKCE flow separately binds its callback to the current app.
868
668
  */
869
669
  export async function registerClient(authMetadata, metadata, signal) {
870
670
  try {
@@ -944,7 +744,7 @@ export async function startDeviceCodeLogin(authMetadata, clientId, deviceId, sig
944
744
  try {
945
745
  const safeAuthMetadata = validateTrustedAuthMetadata(authMetadata);
946
746
  const safeClientId = requireClientId(clientId);
947
- requireString(deviceId, "device ID", DEVICE_ID_PATTERN);
747
+ requireDeviceId(deviceId);
948
748
  const scope = generateScope(deviceId);
949
749
  const endpoint = safeAuthMetadata.device_authorization_endpoint;
950
750
  if (!endpoint)
@@ -1006,10 +806,10 @@ export async function waitForDeviceCodeLogin(authMetadata, clientId, session, si
1006
806
  if (requestedDeviceId === undefined) {
1007
807
  throw new StorageError("OIDC device authorization requires an expected device ID");
1008
808
  }
1009
- requireString(requestedDeviceId, "device ID", DEVICE_ID_PATTERN);
809
+ requireDeviceId(requestedDeviceId);
1010
810
  const endpoint = exactEndpoint(safeAuthMetadata.token_endpoint, "token endpoint");
1011
811
  let interval = (safeSession.interval ?? 5) * 1000;
1012
- const expiration = Date.now() + Math.min(safeSession.expires_in * 1000, MAX_DEVICE_POLL_DURATION_MS);
812
+ const expiration = Date.now() + safeSession.expires_in * 1000;
1013
813
  do {
1014
814
  const remaining = expiration - Date.now();
1015
815
  if (remaining <= 0) {
@@ -1038,9 +838,6 @@ export async function waitForDeviceCodeLogin(authMetadata, clientId, session, si
1038
838
  throw providerResponseFailure("device authorization", response, responseText ?? "", "OIDC device authorization returned an invalid response");
1039
839
  }
1040
840
  if (response.ok) {
1041
- if (!isValidDeviceAccessTokenResponse(body)) {
1042
- throw providerResponseFailure("device authorization", response, responseText ?? "", "OIDC device authorization returned an invalid response");
1043
- }
1044
841
  try {
1045
842
  const tokenResponse = validateDeviceAccessTokenResponse(body);
1046
843
  if (!requestedDeviceId ||
@@ -1101,7 +898,6 @@ export async function beginAuthorizationCodeFlow(opts) {
1101
898
  if (opts.signal?.aborted)
1102
899
  throw new StorageError("OIDC authorization cancelled");
1103
900
  const { authMetadata, homeserver, redirect, deviceId } = validateAuthorizationInputs(opts);
1104
- const createdAtMs = Date.now();
1105
901
  const state = randomHex(32);
1106
902
  const codeVerifier = randomHex(64);
1107
903
  const oauth = new OAuth2(authMetadata, {
@@ -1134,8 +930,6 @@ export async function beginAuthorizationCodeFlow(opts) {
1134
930
  throw new StorageError("OIDC authorization URL is not bound to the validated request");
1135
931
  }
1136
932
  const context = {
1137
- version: AUTHORIZATION_CONTEXT_VERSION,
1138
- createdAtMs,
1139
933
  state,
1140
934
  authMetadata,
1141
935
  clientId: opts.clientId,
@@ -1149,43 +943,27 @@ export async function beginAuthorizationCodeFlow(opts) {
1149
943
  }
1150
944
  /** Completes the authorization-code exchange after the `?code&state` redirect. */
1151
945
  export async function completeAuthorizationCodeFlow(code, state, signal) {
1152
- requireString(state, "authorization state", STATE_PATTERN);
1153
946
  const store = sessionStorage();
1154
947
  const key = `${AUTHORIZATION_CONTEXT_PREFIX}${state}`;
1155
948
  const serialized = store.getItem(key);
1156
949
  if (!serialized)
1157
- throw new StorageError("OIDC authorization context is missing or expired");
950
+ throw new StorageError("OIDC authorization context is missing");
1158
951
  let context;
1159
952
  let validationError;
1160
- if (serialized.length > MAX_AUTHORIZATION_CONTEXT_LENGTH) {
1161
- validationError = new StorageError("OIDC authorization context is too large");
1162
- }
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.
1176
953
  try {
1177
- store.removeItem(key);
954
+ context = parseAuthorizationContext(JSON.parse(serialized), state);
1178
955
  }
1179
956
  catch (error) {
1180
- if (validationError !== undefined) {
1181
- throw new AggregateError([validationError, error], "OIDC authorization context validation and cleanup failed", { cause: validationError });
1182
- }
1183
- throw error;
957
+ validationError = error instanceof StorageError
958
+ ? error
959
+ : new StorageError("OIDC authorization context is missing or invalid", { cause: error });
1184
960
  }
961
+ // Consume the state before any network exchange so a retry cannot replay it.
962
+ store.removeItem(key);
1185
963
  if (validationError !== undefined)
1186
964
  throw validationError;
1187
965
  try {
1188
- requireBoundedString(code, "authorization code", MAX_OIDC_CODE_LENGTH);
966
+ requireOpaqueString(code, "authorization code");
1189
967
  const endpoint = exactEndpoint(context.authMetadata.token_endpoint, "token endpoint");
1190
968
  const params = new URLSearchParams({
1191
969
  grant_type: "authorization_code",
@@ -1250,7 +1028,6 @@ export async function completeAuthorizationCodeFlow(code, state, signal) {
1250
1028
  * OAuth scope. */
1251
1029
  export function extractDeviceIdFromScope(scope) {
1252
1030
  if (typeof scope !== "string" ||
1253
- scope.length > MAX_OIDC_SCOPE_LENGTH ||
1254
1031
  /[\u0000-\u001f\u007f]/.test(scope) ||
1255
1032
  scope.trim() !== scope) {
1256
1033
  return null;
@@ -1264,7 +1041,7 @@ export function extractDeviceIdFromScope(scope) {
1264
1041
  if (!prefix)
1265
1042
  return null;
1266
1043
  const deviceId = deviceToken.slice(`${prefix}device:`.length);
1267
- return DEVICE_ID_PATTERN.test(deviceId) ? deviceId : null;
1044
+ return isDeviceId(deviceId) ? deviceId : null;
1268
1045
  }
1269
1046
  /**
1270
1047
  * Accepts the legacy Matrix OAuth scope and the current MSC2967 namespace
@@ -1275,9 +1052,8 @@ export function extractDeviceIdFromScope(scope) {
1275
1052
  function scopeMatchesDevice(scope, expectedDeviceId) {
1276
1053
  if (typeof scope !== "string" ||
1277
1054
  typeof expectedDeviceId !== "string" ||
1278
- !DEVICE_ID_PATTERN.test(expectedDeviceId) ||
1055
+ !isDeviceId(expectedDeviceId) ||
1279
1056
  scope.length === 0 ||
1280
- scope.length > MAX_OIDC_SCOPE_LENGTH ||
1281
1057
  /[\u0000-\u001f\u007f]/.test(scope) ||
1282
1058
  scope.trim() !== scope ||
1283
1059
  /\s{2,}/.test(scope)) {
@@ -1317,7 +1093,7 @@ function validateMatrixUserId(userId, serverName) {
1317
1093
  export async function whoAmI(homeserverUrl, accessToken, serverName, signal) {
1318
1094
  try {
1319
1095
  const homeserver = parseHttpUrl(homeserverUrl, "homeserver URL");
1320
- requireBoundedString(accessToken, "access token", MAX_OIDC_TOKEN_LENGTH);
1096
+ requireOpaqueString(accessToken, "access token");
1321
1097
  const endpoint = matrixWhoAmIEndpoint(homeserver);
1322
1098
  const { response, body, responseText } = await requestWithTimeout(endpoint, {
1323
1099
  method: "GET",
@@ -1338,14 +1114,20 @@ export async function whoAmI(homeserverUrl, accessToken, serverName, signal) {
1338
1114
  if (!isRecord(body)) {
1339
1115
  throw responseValidationFailure("identity confirmation", response, responseText ?? "", new StorageError("OIDC identity confirmation returned an invalid response"));
1340
1116
  }
1341
- const userId = validateMatrixUserId(body.user_id, serverName);
1117
+ let userId;
1118
+ try {
1119
+ userId = validateMatrixUserId(body.user_id, serverName);
1120
+ }
1121
+ catch (error) {
1122
+ throw responseValidationFailure("identity confirmation", response, responseText ?? "", error);
1123
+ }
1342
1124
  let deviceId = null;
1343
1125
  if (body.device_id !== undefined && body.device_id !== null) {
1344
1126
  try {
1345
1127
  deviceId = validateMatrixDeviceId(body.device_id);
1346
1128
  }
1347
1129
  catch (error) {
1348
- throw new StorageError("OIDC identity confirmation returned an invalid device ID", { cause: error });
1130
+ throw responseValidationFailure("identity confirmation", response, responseText ?? "", new StorageError("OIDC identity confirmation returned an invalid device ID", { cause: error }));
1349
1131
  }
1350
1132
  }
1351
1133
  if (signal?.aborted)
@@ -1374,7 +1156,7 @@ export async function whoAmI(homeserverUrl, accessToken, serverName, signal) {
1374
1156
  async function refreshOidcToken(tokenEndpoint, clientId, refreshToken, expectedDeviceId, signal) {
1375
1157
  const endpoint = exactEndpoint(tokenEndpoint, "token endpoint");
1376
1158
  const safeClientId = requireClientId(clientId);
1377
- const safeRefreshToken = requireBoundedString(refreshToken, "refresh token", MAX_OIDC_TOKEN_LENGTH);
1159
+ const safeRefreshToken = requireOpaqueString(refreshToken, "refresh token");
1378
1160
  const controller = new AbortController();
1379
1161
  const timeout = setTimeout(() => controller.abort(), OIDC_REFRESH_TIMEOUT_MS);
1380
1162
  const abortExternal = () => controller.abort(signal?.reason);
@@ -1438,7 +1220,6 @@ async function refreshOidcToken(tokenEndpoint, clientId, refreshToken, expectedD
1438
1220
  const expiresIn = record.expires_in;
1439
1221
  if (typeof accessToken !== "string" ||
1440
1222
  accessToken.trim() === "" ||
1441
- accessToken.length > MAX_OIDC_TOKEN_LENGTH ||
1442
1223
  /[\s\u0000-\u001f\u007f]/.test(accessToken)) {
1443
1224
  throw invalidResponse("OIDC token refresh returned no access token");
1444
1225
  }
@@ -1449,7 +1230,6 @@ async function refreshOidcToken(tokenEndpoint, clientId, refreshToken, expectedD
1449
1230
  if (nextRefreshToken !== undefined &&
1450
1231
  (typeof nextRefreshToken !== "string" ||
1451
1232
  nextRefreshToken.trim() === "" ||
1452
- nextRefreshToken.length > MAX_OIDC_TOKEN_LENGTH ||
1453
1233
  /[\s\u0000-\u001f\u007f]/.test(nextRefreshToken))) {
1454
1234
  throw invalidResponse("OIDC token refresh returned an invalid refresh token");
1455
1235
  }
@@ -1457,12 +1237,6 @@ async function refreshOidcToken(tokenEndpoint, clientId, refreshToken, expectedD
1457
1237
  if (typeof grantedScope !== "string") {
1458
1238
  throw invalidResponse("OIDC token refresh returned an invalid scope");
1459
1239
  }
1460
- try {
1461
- requireScope(grantedScope, "scope");
1462
- }
1463
- catch (error) {
1464
- throw invalidResponse("OIDC token refresh returned an invalid scope", { cause: error });
1465
- }
1466
1240
  if (!scopeMatchesDevice(grantedScope, expectedDeviceId)) {
1467
1241
  throw invalidResponse("OIDC token refresh returned an unexpected granted scope");
1468
1242
  }
@@ -1472,9 +1246,8 @@ async function refreshOidcToken(tokenEndpoint, clientId, refreshToken, expectedD
1472
1246
  }
1473
1247
  if (expiresIn !== undefined &&
1474
1248
  (typeof expiresIn !== "number" ||
1475
- !Number.isInteger(expiresIn) ||
1476
- expiresIn < 0 ||
1477
- expiresIn > 86400)) {
1249
+ !Number.isSafeInteger(expiresIn) ||
1250
+ expiresIn < 0)) {
1478
1251
  throw invalidResponse("OIDC token refresh returned an invalid expiry");
1479
1252
  }
1480
1253
  ensureOidcNotCancelled(signal, "token refresh");
@@ -1508,7 +1281,7 @@ async function refreshOidcToken(tokenEndpoint, clientId, refreshToken, expectedD
1508
1281
  async function revokeOidcToken(revocationEndpoint, clientId, token, tokenTypeHint = "access_token", signal) {
1509
1282
  const endpoint = exactEndpoint(revocationEndpoint, "revocation endpoint");
1510
1283
  const safeClientId = requireClientId(clientId);
1511
- const safeToken = requireBoundedString(token, "token", MAX_OIDC_TOKEN_LENGTH);
1284
+ const safeToken = requireOpaqueString(token, "token");
1512
1285
  try {
1513
1286
  const { response, responseText } = await requestWithTimeout(endpoint, {
1514
1287
  method: "POST",
@@ -1591,7 +1364,7 @@ export function buildTokenRefreshFunction(metadata, clientId, onPersist, expecte
1591
1364
  // endpoint and cause a refresh or revocation token to leave that issuer.
1592
1365
  const { tokenEndpoint: endpoint, revocationEndpoint } = validateTokenEndpointMetadata(metadata);
1593
1366
  const safeClientId = requireClientId(clientId);
1594
- const safeExpectedDeviceId = requireString(expectedDeviceId, "device ID", DEVICE_ID_PATTERN);
1367
+ const safeExpectedDeviceId = requireDeviceId(expectedDeviceId);
1595
1368
  return async (refreshToken, signal) => {
1596
1369
  const tokens = await refreshOidcToken(endpoint, safeClientId, refreshToken, safeExpectedDeviceId, signal);
1597
1370
  try {