@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/README.md +6 -9
- package/dist/TeleCryptIOStorage.d.ts +1 -1
- package/dist/TeleCryptIOStorage.js +39 -83
- package/dist/core/constants.d.ts +2 -4
- package/dist/core/constants.js +8 -10
- package/dist/core/http.d.ts +2 -2
- package/dist/core/http.js +24 -95
- package/dist/core/oidc.d.ts +2 -6
- package/dist/core/oidc.js +86 -333
- package/dist/core/operations.js +20 -31
- package/package.json +2 -4
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 =
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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 (
|
|
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
|
|
329
|
-
|
|
330
|
-
value
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
throw new StorageError(
|
|
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
|
|
147
|
+
function requireOpaqueString(value, name) {
|
|
338
148
|
if (typeof value !== "string" ||
|
|
339
149
|
value.length === 0 ||
|
|
340
|
-
value
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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)
|
|
399
|
-
throw new StorageError("OIDC dynamic client registration has
|
|
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"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
728
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
560
|
+
requireOpaqueString(value.access_token, "access token");
|
|
760
561
|
if (value.refresh_token !== undefined) {
|
|
761
|
-
|
|
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.
|
|
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
|
-
|
|
589
|
+
requireDeviceId(deviceId);
|
|
791
590
|
return { authMetadata, homeserver, redirect, deviceId };
|
|
792
591
|
}
|
|
793
592
|
function parseAuthorizationContext(value, expectedState) {
|
|
794
|
-
if (!isRecord(value)
|
|
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:
|
|
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).
|
|
866
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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() +
|
|
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
|
|
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
|
-
|
|
940
|
+
context = parseAuthorizationContext(JSON.parse(serialized), state);
|
|
1178
941
|
}
|
|
1179
942
|
catch (error) {
|
|
1180
|
-
|
|
1181
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
!
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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 =
|
|
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 =
|
|
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 {
|