@telecrypt-io/storage 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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
385
  function parseHttpUrl(value, name) {
579
- const text = requireBoundedString(value, name, MAX_OIDC_URL_LENGTH);
386
+ const text = requireOpaqueString(value, name);
580
387
  let parsed;
581
388
  try {
582
389
  parsed = new URL(text);
@@ -645,7 +452,6 @@ function validateAuthMetadata(value) {
645
452
  if (field !== undefined &&
646
453
  (typeof field !== "string" ||
647
454
  field.length === 0 ||
648
- field.length > MAX_OIDC_URL_LENGTH ||
649
455
  /[\u0000-\u001f\u007f]/.test(field))) {
650
456
  throw new StorageError("OIDC authorization context has invalid auth metadata");
651
457
  }
@@ -664,7 +470,6 @@ function validateAuthMetadata(value) {
664
470
  if (field !== undefined &&
665
471
  (typeof field !== "string" ||
666
472
  field.length === 0 ||
667
- field.length > MAX_OIDC_URL_LENGTH ||
668
473
  /[\u0000-\u001f\u007f]/.test(field))) {
669
474
  throw new StorageError("OIDC authorization context has invalid auth metadata");
670
475
  }
@@ -679,9 +484,8 @@ function validateAuthMetadata(value) {
679
484
  ]) {
680
485
  const field = metadata[name];
681
486
  if (field !== undefined &&
682
- (!Array.isArray(field) || field.length > 32 || field.some((item) => typeof item !== "string" ||
487
+ (!Array.isArray(field) || field.some((item) => typeof item !== "string" ||
683
488
  item.length === 0 ||
684
- item.length > MAX_OIDC_METADATA_STRING_LENGTH ||
685
489
  /[\u0000-\u001f\u007f]/.test(item)))) {
686
490
  throw new StorageError("OIDC authorization context has invalid auth metadata");
687
491
  }
@@ -724,21 +528,18 @@ function validateDeviceAuthorizationSession(value) {
724
528
  throw new StorageError("OIDC device authorization returned an invalid response", { cause: error });
725
529
  }
726
530
  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);
531
+ requireOpaqueString(session.device_code, "device code");
532
+ requireOpaqueString(session.user_code, "user code");
729
533
  parseSafeRedirectUri(session.verification_uri, "verification URI");
730
534
  if (session.verification_uri_complete !== undefined) {
731
535
  parseSafeRedirectUri(session.verification_uri_complete, "complete verification URI");
732
536
  }
733
- if (!Number.isInteger(session.expires_in) ||
734
- session.expires_in <= 0 ||
735
- session.expires_in > MAX_DEVICE_EXPIRES_IN_SECONDS) {
537
+ if (!Number.isSafeInteger(session.expires_in) ||
538
+ session.expires_in <= 0) {
736
539
  throw new StorageError("OIDC device authorization returned an invalid expiry");
737
540
  }
738
541
  if (session.interval !== undefined &&
739
- (!Number.isInteger(session.interval) ||
740
- session.interval <= 0 ||
741
- session.interval > MAX_DEVICE_INTERVAL_SECONDS)) {
542
+ (!Number.isSafeInteger(session.interval) || session.interval <= 0)) {
742
543
  throw new StorageError("OIDC device authorization returned an invalid polling interval");
743
544
  }
744
545
  return session;
@@ -756,18 +557,16 @@ function validateDeviceAccessTokenResponse(value) {
756
557
  }
757
558
  function validateTokenBounds(value, operation) {
758
559
  try {
759
- requireBoundedString(value.access_token, "access token", MAX_OIDC_TOKEN_LENGTH);
560
+ requireOpaqueString(value.access_token, "access token");
760
561
  if (value.refresh_token !== undefined) {
761
- requireBoundedString(value.refresh_token, "refresh token", MAX_OIDC_TOKEN_LENGTH);
562
+ requireOpaqueString(value.refresh_token, "refresh token");
762
563
  }
763
- if (value.scope !== undefined)
764
- requireScope(value.scope, "scope");
765
564
  }
766
565
  catch (error) {
767
566
  throw new StorageError(`OIDC ${operation} returned an invalid token response`, { cause: error });
768
567
  }
769
568
  if (value.expires_in !== undefined &&
770
- (!Number.isInteger(value.expires_in) || value.expires_in < 0 || value.expires_in > 86400)) {
569
+ (!Number.isSafeInteger(value.expires_in) || value.expires_in < 0)) {
771
570
  throw new StorageError(`OIDC ${operation} returned an invalid token expiry`);
772
571
  }
773
572
  }
@@ -787,26 +586,16 @@ function validateAuthorizationInputs(opts) {
787
586
  }
788
587
  requireClientId(opts.clientId);
789
588
  const deviceId = opts.deviceId ?? randomHex(5).toUpperCase();
790
- requireString(deviceId, "device ID", DEVICE_ID_PATTERN);
589
+ requireDeviceId(deviceId);
791
590
  return { authMetadata, homeserver, redirect, deviceId };
792
591
  }
793
592
  function parseAuthorizationContext(value, expectedState) {
794
- if (!isRecord(value) || value.version !== AUTHORIZATION_CONTEXT_VERSION) {
593
+ if (!isRecord(value)) {
795
594
  throw new StorageError("OIDC authorization context is missing or invalid");
796
595
  }
797
596
  if (value.state !== expectedState) {
798
597
  throw new StorageError("OIDC authorization context state does not match callback state");
799
598
  }
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
599
  const authMetadata = validateTrustedAuthMetadata(value.authMetadata);
811
600
  const homeserver = parseHttpUrl(value.homeserverUrl, "homeserver URL");
812
601
  const redirect = parseHttpUrl(value.redirectUri, "redirect URI");
@@ -814,14 +603,12 @@ function parseAuthorizationContext(value, expectedState) {
814
603
  throw new StorageError("OIDC authorization context origins are not trusted");
815
604
  }
816
605
  return {
817
- version: AUTHORIZATION_CONTEXT_VERSION,
818
- createdAtMs: value.createdAtMs,
819
606
  state: expectedState,
820
607
  authMetadata,
821
608
  clientId: requireClientId(value.clientId),
822
609
  redirectUri: redirect.toString(),
823
610
  homeserverUrl: homeserver.toString(),
824
- deviceId: requireString(value.deviceId, "device ID", DEVICE_ID_PATTERN),
611
+ deviceId: requireDeviceId(value.deviceId),
825
612
  codeVerifier: requireString(value.codeVerifier, "PKCE verifier", PKCE_VERIFIER_PATTERN),
826
613
  };
827
614
  }
@@ -862,9 +649,8 @@ export async function discoverOidcIssuer(homeserverBaseUrl, signal) {
862
649
  }
863
650
  }
864
651
  /**
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).
652
+ * Dynamic client registration (DCR). The issuer validates the supplied
653
+ * metadata; the PKCE flow separately binds its callback to the current app.
868
654
  */
869
655
  export async function registerClient(authMetadata, metadata, signal) {
870
656
  try {
@@ -944,7 +730,7 @@ export async function startDeviceCodeLogin(authMetadata, clientId, deviceId, sig
944
730
  try {
945
731
  const safeAuthMetadata = validateTrustedAuthMetadata(authMetadata);
946
732
  const safeClientId = requireClientId(clientId);
947
- requireString(deviceId, "device ID", DEVICE_ID_PATTERN);
733
+ requireDeviceId(deviceId);
948
734
  const scope = generateScope(deviceId);
949
735
  const endpoint = safeAuthMetadata.device_authorization_endpoint;
950
736
  if (!endpoint)
@@ -1006,10 +792,10 @@ export async function waitForDeviceCodeLogin(authMetadata, clientId, session, si
1006
792
  if (requestedDeviceId === undefined) {
1007
793
  throw new StorageError("OIDC device authorization requires an expected device ID");
1008
794
  }
1009
- requireString(requestedDeviceId, "device ID", DEVICE_ID_PATTERN);
795
+ requireDeviceId(requestedDeviceId);
1010
796
  const endpoint = exactEndpoint(safeAuthMetadata.token_endpoint, "token endpoint");
1011
797
  let interval = (safeSession.interval ?? 5) * 1000;
1012
- const expiration = Date.now() + Math.min(safeSession.expires_in * 1000, MAX_DEVICE_POLL_DURATION_MS);
798
+ const expiration = Date.now() + safeSession.expires_in * 1000;
1013
799
  do {
1014
800
  const remaining = expiration - Date.now();
1015
801
  if (remaining <= 0) {
@@ -1038,9 +824,6 @@ export async function waitForDeviceCodeLogin(authMetadata, clientId, session, si
1038
824
  throw providerResponseFailure("device authorization", response, responseText ?? "", "OIDC device authorization returned an invalid response");
1039
825
  }
1040
826
  if (response.ok) {
1041
- if (!isValidDeviceAccessTokenResponse(body)) {
1042
- throw providerResponseFailure("device authorization", response, responseText ?? "", "OIDC device authorization returned an invalid response");
1043
- }
1044
827
  try {
1045
828
  const tokenResponse = validateDeviceAccessTokenResponse(body);
1046
829
  if (!requestedDeviceId ||
@@ -1101,7 +884,6 @@ export async function beginAuthorizationCodeFlow(opts) {
1101
884
  if (opts.signal?.aborted)
1102
885
  throw new StorageError("OIDC authorization cancelled");
1103
886
  const { authMetadata, homeserver, redirect, deviceId } = validateAuthorizationInputs(opts);
1104
- const createdAtMs = Date.now();
1105
887
  const state = randomHex(32);
1106
888
  const codeVerifier = randomHex(64);
1107
889
  const oauth = new OAuth2(authMetadata, {
@@ -1134,8 +916,6 @@ export async function beginAuthorizationCodeFlow(opts) {
1134
916
  throw new StorageError("OIDC authorization URL is not bound to the validated request");
1135
917
  }
1136
918
  const context = {
1137
- version: AUTHORIZATION_CONTEXT_VERSION,
1138
- createdAtMs,
1139
919
  state,
1140
920
  authMetadata,
1141
921
  clientId: opts.clientId,
@@ -1149,43 +929,27 @@ export async function beginAuthorizationCodeFlow(opts) {
1149
929
  }
1150
930
  /** Completes the authorization-code exchange after the `?code&state` redirect. */
1151
931
  export async function completeAuthorizationCodeFlow(code, state, signal) {
1152
- requireString(state, "authorization state", STATE_PATTERN);
1153
932
  const store = sessionStorage();
1154
933
  const key = `${AUTHORIZATION_CONTEXT_PREFIX}${state}`;
1155
934
  const serialized = store.getItem(key);
1156
935
  if (!serialized)
1157
- throw new StorageError("OIDC authorization context is missing or expired");
936
+ throw new StorageError("OIDC authorization context is missing");
1158
937
  let context;
1159
938
  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
939
  try {
1177
- store.removeItem(key);
940
+ context = parseAuthorizationContext(JSON.parse(serialized), state);
1178
941
  }
1179
942
  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;
943
+ validationError = error instanceof StorageError
944
+ ? error
945
+ : new StorageError("OIDC authorization context is missing or invalid", { cause: error });
1184
946
  }
947
+ // Consume the state before any network exchange so a retry cannot replay it.
948
+ store.removeItem(key);
1185
949
  if (validationError !== undefined)
1186
950
  throw validationError;
1187
951
  try {
1188
- requireBoundedString(code, "authorization code", MAX_OIDC_CODE_LENGTH);
952
+ requireOpaqueString(code, "authorization code");
1189
953
  const endpoint = exactEndpoint(context.authMetadata.token_endpoint, "token endpoint");
1190
954
  const params = new URLSearchParams({
1191
955
  grant_type: "authorization_code",
@@ -1250,7 +1014,6 @@ export async function completeAuthorizationCodeFlow(code, state, signal) {
1250
1014
  * OAuth scope. */
1251
1015
  export function extractDeviceIdFromScope(scope) {
1252
1016
  if (typeof scope !== "string" ||
1253
- scope.length > MAX_OIDC_SCOPE_LENGTH ||
1254
1017
  /[\u0000-\u001f\u007f]/.test(scope) ||
1255
1018
  scope.trim() !== scope) {
1256
1019
  return null;
@@ -1264,7 +1027,7 @@ export function extractDeviceIdFromScope(scope) {
1264
1027
  if (!prefix)
1265
1028
  return null;
1266
1029
  const deviceId = deviceToken.slice(`${prefix}device:`.length);
1267
- return DEVICE_ID_PATTERN.test(deviceId) ? deviceId : null;
1030
+ return isDeviceId(deviceId) ? deviceId : null;
1268
1031
  }
1269
1032
  /**
1270
1033
  * Accepts the legacy Matrix OAuth scope and the current MSC2967 namespace
@@ -1275,9 +1038,8 @@ export function extractDeviceIdFromScope(scope) {
1275
1038
  function scopeMatchesDevice(scope, expectedDeviceId) {
1276
1039
  if (typeof scope !== "string" ||
1277
1040
  typeof expectedDeviceId !== "string" ||
1278
- !DEVICE_ID_PATTERN.test(expectedDeviceId) ||
1041
+ !isDeviceId(expectedDeviceId) ||
1279
1042
  scope.length === 0 ||
1280
- scope.length > MAX_OIDC_SCOPE_LENGTH ||
1281
1043
  /[\u0000-\u001f\u007f]/.test(scope) ||
1282
1044
  scope.trim() !== scope ||
1283
1045
  /\s{2,}/.test(scope)) {
@@ -1317,7 +1079,7 @@ function validateMatrixUserId(userId, serverName) {
1317
1079
  export async function whoAmI(homeserverUrl, accessToken, serverName, signal) {
1318
1080
  try {
1319
1081
  const homeserver = parseHttpUrl(homeserverUrl, "homeserver URL");
1320
- requireBoundedString(accessToken, "access token", MAX_OIDC_TOKEN_LENGTH);
1082
+ requireOpaqueString(accessToken, "access token");
1321
1083
  const endpoint = matrixWhoAmIEndpoint(homeserver);
1322
1084
  const { response, body, responseText } = await requestWithTimeout(endpoint, {
1323
1085
  method: "GET",
@@ -1374,7 +1136,7 @@ export async function whoAmI(homeserverUrl, accessToken, serverName, signal) {
1374
1136
  async function refreshOidcToken(tokenEndpoint, clientId, refreshToken, expectedDeviceId, signal) {
1375
1137
  const endpoint = exactEndpoint(tokenEndpoint, "token endpoint");
1376
1138
  const safeClientId = requireClientId(clientId);
1377
- const safeRefreshToken = requireBoundedString(refreshToken, "refresh token", MAX_OIDC_TOKEN_LENGTH);
1139
+ const safeRefreshToken = requireOpaqueString(refreshToken, "refresh token");
1378
1140
  const controller = new AbortController();
1379
1141
  const timeout = setTimeout(() => controller.abort(), OIDC_REFRESH_TIMEOUT_MS);
1380
1142
  const abortExternal = () => controller.abort(signal?.reason);
@@ -1438,7 +1200,6 @@ async function refreshOidcToken(tokenEndpoint, clientId, refreshToken, expectedD
1438
1200
  const expiresIn = record.expires_in;
1439
1201
  if (typeof accessToken !== "string" ||
1440
1202
  accessToken.trim() === "" ||
1441
- accessToken.length > MAX_OIDC_TOKEN_LENGTH ||
1442
1203
  /[\s\u0000-\u001f\u007f]/.test(accessToken)) {
1443
1204
  throw invalidResponse("OIDC token refresh returned no access token");
1444
1205
  }
@@ -1449,7 +1210,6 @@ async function refreshOidcToken(tokenEndpoint, clientId, refreshToken, expectedD
1449
1210
  if (nextRefreshToken !== undefined &&
1450
1211
  (typeof nextRefreshToken !== "string" ||
1451
1212
  nextRefreshToken.trim() === "" ||
1452
- nextRefreshToken.length > MAX_OIDC_TOKEN_LENGTH ||
1453
1213
  /[\s\u0000-\u001f\u007f]/.test(nextRefreshToken))) {
1454
1214
  throw invalidResponse("OIDC token refresh returned an invalid refresh token");
1455
1215
  }
@@ -1457,12 +1217,6 @@ async function refreshOidcToken(tokenEndpoint, clientId, refreshToken, expectedD
1457
1217
  if (typeof grantedScope !== "string") {
1458
1218
  throw invalidResponse("OIDC token refresh returned an invalid scope");
1459
1219
  }
1460
- try {
1461
- requireScope(grantedScope, "scope");
1462
- }
1463
- catch (error) {
1464
- throw invalidResponse("OIDC token refresh returned an invalid scope", { cause: error });
1465
- }
1466
1220
  if (!scopeMatchesDevice(grantedScope, expectedDeviceId)) {
1467
1221
  throw invalidResponse("OIDC token refresh returned an unexpected granted scope");
1468
1222
  }
@@ -1472,9 +1226,8 @@ async function refreshOidcToken(tokenEndpoint, clientId, refreshToken, expectedD
1472
1226
  }
1473
1227
  if (expiresIn !== undefined &&
1474
1228
  (typeof expiresIn !== "number" ||
1475
- !Number.isInteger(expiresIn) ||
1476
- expiresIn < 0 ||
1477
- expiresIn > 86400)) {
1229
+ !Number.isSafeInteger(expiresIn) ||
1230
+ expiresIn < 0)) {
1478
1231
  throw invalidResponse("OIDC token refresh returned an invalid expiry");
1479
1232
  }
1480
1233
  ensureOidcNotCancelled(signal, "token refresh");
@@ -1508,7 +1261,7 @@ async function refreshOidcToken(tokenEndpoint, clientId, refreshToken, expectedD
1508
1261
  async function revokeOidcToken(revocationEndpoint, clientId, token, tokenTypeHint = "access_token", signal) {
1509
1262
  const endpoint = exactEndpoint(revocationEndpoint, "revocation endpoint");
1510
1263
  const safeClientId = requireClientId(clientId);
1511
- const safeToken = requireBoundedString(token, "token", MAX_OIDC_TOKEN_LENGTH);
1264
+ const safeToken = requireOpaqueString(token, "token");
1512
1265
  try {
1513
1266
  const { response, responseText } = await requestWithTimeout(endpoint, {
1514
1267
  method: "POST",
@@ -1591,7 +1344,7 @@ export function buildTokenRefreshFunction(metadata, clientId, onPersist, expecte
1591
1344
  // endpoint and cause a refresh or revocation token to leave that issuer.
1592
1345
  const { tokenEndpoint: endpoint, revocationEndpoint } = validateTokenEndpointMetadata(metadata);
1593
1346
  const safeClientId = requireClientId(clientId);
1594
- const safeExpectedDeviceId = requireString(expectedDeviceId, "device ID", DEVICE_ID_PATTERN);
1347
+ const safeExpectedDeviceId = requireDeviceId(expectedDeviceId);
1595
1348
  return async (refreshToken, signal) => {
1596
1349
  const tokens = await refreshOidcToken(endpoint, safeClientId, refreshToken, safeExpectedDeviceId, signal);
1597
1350
  try {