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