@telecrypt-io/storage 0.4.0 → 0.5.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/core/oidc.js CHANGED
@@ -1,35 +1,644 @@
1
1
  /**
2
2
  * Shared OIDC/MAS login building blocks — used by BOTH the CLI (device-code
3
3
  * grant) and the web UI (authorization-code + PKCE). Browser-safe (no
4
- * node:fs/path/v8/process/commander/fake-indexeddb): the functions here only
5
- * ever call browser-only matrix-js-sdk OIDC helpers (window.crypto for PKCE)
6
- * when actually invoked from the browser adapter — importing this module
7
- * under Node is safe (nothing here touches `window` at import time), calling
8
- * the PKCE-only functions from Node is not (by design: the CLI never calls
9
- * them, only the device-code functions, which are plain `fetch` calls).
4
+ * node:fs/path/v8/process/commander/fake-indexeddb): browser state is only
5
+ * touched when the PKCE functions are called by the web adapter. Importing
6
+ * this module under Node is safe; the CLI uses the device-code functions,
7
+ * which are plain fetch calls.
10
8
  */
11
9
  import { createClient } from "matrix-js-sdk";
12
- import { registerOidcClient, generateScope, generateOidcAuthorizationUrl, completeAuthorizationCodeGrant, startDeviceAuthorization, waitForDeviceAuthorization, } from "matrix-js-sdk/lib/oidc/index.js";
13
- import { OidcClient, WebStorageStateStore } from "oidc-client-ts";
10
+ import { generateScope, isValidDeviceAccessTokenResponse, normalizeBearerTokenResponseTokenType, validateBearerTokenResponse, validateDeviceAuthorizationResponse, validateRegistrationResponse, OAuthGrantType, OAuth2, } from "matrix-js-sdk/lib/oauth/index.js";
14
11
  import { StorageError } from "./errors.js";
12
+ import { raceWithAbort, readBoundedResponseBody } from "./http.js";
13
+ import { validateCanonicalMatrixUserId, validateMatrixDeviceId, } from "./constants.js";
14
+ const AUTHORIZATION_CONTEXT_PREFIX = "telecrypt:oauth2:pkce:v1:";
15
+ const AUTHORIZATION_CONTEXT_VERSION = 1;
16
+ // The callback context is a short-lived transaction, not a durable login
17
+ // session. Ten minutes covers normal redirects while limiting replay after a
18
+ // stale browser tab or session-storage copy is recovered.
19
+ const AUTHORIZATION_CONTEXT_MAX_AGE_MS = 10 * 60 * 1000;
20
+ const AUTHORIZATION_CONTEXT_MAX_FUTURE_SKEW_MS = 30 * 1000;
21
+ const MAX_AUTHORIZATION_CONTEXT_LENGTH = 64 * 1024;
22
+ const PKCE_VERIFIER_PATTERN = /^[A-Za-z0-9._~-]{43,128}$/;
23
+ const DEVICE_ID_PATTERN = /^[A-Za-z0-9._~-]{1,128}$/;
24
+ 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
+ const OIDC_REQUEST_TIMEOUT_MS = 30_000;
28
+ const OIDC_REFRESH_TIMEOUT_MS = 30_000;
29
+ const OIDC_CLEANUP_TIMEOUT_MS = 30_000;
30
+ const MAX_OIDC_URL_LENGTH = 2048;
31
+ const MAX_OIDC_METADATA_STRING_LENGTH = 4096;
32
+ const MAX_OIDC_CLIENT_ID_LENGTH = 512;
33
+ const MAX_OIDC_CODE_LENGTH = 4096;
34
+ const MAX_OIDC_TOKEN_LENGTH = 8192;
35
+ const MAX_OIDC_SCOPE_LENGTH = 4096;
36
+ const MAX_OIDC_CONTACTS = 16;
37
+ const MAX_OIDC_REDIRECT_URIS = 8;
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
+ const deviceAuthorizationDeviceIds = new WeakMap();
44
+ const SAFE_OAUTH_ERROR_CODES = new Set([
45
+ "invalid_request",
46
+ "invalid_client",
47
+ "invalid_grant",
48
+ "unauthorized_client",
49
+ "unsupported_grant_type",
50
+ "invalid_scope",
51
+ "invalid_token",
52
+ "temporarily_unavailable",
53
+ "server_error",
54
+ "authorization_pending",
55
+ "slow_down",
56
+ "access_denied",
57
+ "expired_token",
58
+ "expired",
59
+ ]);
60
+ class OidcRequestTimeoutError extends Error {
61
+ operation;
62
+ constructor(operation) {
63
+ super(`OIDC ${operation} timed out`);
64
+ this.operation = operation;
65
+ this.name = "OidcRequestTimeoutError";
66
+ }
67
+ }
68
+ class OidcRequestCancelledError extends Error {
69
+ operation;
70
+ constructor(operation) {
71
+ super(`OIDC ${operation} cancelled`);
72
+ this.operation = operation;
73
+ this.name = "OidcRequestCancelledError";
74
+ }
75
+ }
76
+ /** A local response-consistency failure that must remain distinguishable from
77
+ * a sanitized provider/transport failure. The message is fixed and contains
78
+ * no provider-controlled data. */
79
+ class OidcValidationError extends StorageError {
80
+ constructor(message) {
81
+ super(message);
82
+ this.name = "OidcValidationError";
83
+ }
84
+ }
85
+ function isRecord(value) {
86
+ return typeof value === "object" && value !== null && !Array.isArray(value);
87
+ }
88
+ function randomHex(byteLength) {
89
+ const bytes = crypto.getRandomValues(new Uint8Array(byteLength));
90
+ return Array.from(bytes, (value) => value.toString(16).padStart(2, "0")).join("");
91
+ }
92
+ function requireString(value, name, pattern) {
93
+ if (typeof value !== "string" ||
94
+ value.length === 0 ||
95
+ value.length > MAX_OIDC_METADATA_STRING_LENGTH ||
96
+ /[\u0000-\u001f\u007f]/.test(value) ||
97
+ (pattern && !pattern.test(value))) {
98
+ throw new StorageError(`OIDC authorization context has an invalid ${name}`);
99
+ }
100
+ return value;
101
+ }
102
+ function requireClientId(value) {
103
+ const clientId = requireString(value, "client ID");
104
+ if (clientId.length > MAX_OIDC_CLIENT_ID_LENGTH || /\s/.test(clientId)) {
105
+ throw new StorageError("OIDC authorization context has an invalid client ID");
106
+ }
107
+ return clientId;
108
+ }
109
+ function requireBoundedString(value, name, maxLength) {
110
+ if (typeof value !== "string" ||
111
+ value.length === 0 ||
112
+ value.length > maxLength ||
113
+ /[\s\u0000-\u001f\u007f]/.test(value)) {
114
+ throw new StorageError(`OIDC authorization context has an invalid ${name}`);
115
+ }
116
+ return value;
117
+ }
118
+ function requireScope(value, name) {
119
+ if (typeof value !== "string" ||
120
+ value.length === 0 ||
121
+ value.length > MAX_OIDC_SCOPE_LENGTH ||
122
+ /[\u0000-\u001f\u007f]/.test(value) ||
123
+ value.trim() !== value ||
124
+ /\s{2,}/.test(value)) {
125
+ throw new StorageError(`OIDC authorization context has an invalid ${name}`);
126
+ }
127
+ return value;
128
+ }
129
+ function isLoopbackHostname(hostname) {
130
+ const normalized = hostname.toLowerCase().replace(/^\[|\]$/g, "");
131
+ return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1";
132
+ }
133
+ function parseSafeRedirectUri(value, name) {
134
+ const text = requireBoundedString(value, name, MAX_OIDC_URL_LENGTH);
135
+ let parsed;
136
+ try {
137
+ parsed = new URL(text);
138
+ }
139
+ catch {
140
+ throw new StorageError(`OIDC authorization context has an invalid ${name}`);
141
+ }
142
+ const isRootWithoutSlash = parsed.pathname === "/" && parsed.toString() === `${text}/`;
143
+ if ((parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
144
+ (parsed.protocol === "http:" && !isLoopbackHostname(parsed.hostname)) ||
145
+ parsed.username !== "" ||
146
+ parsed.password !== "" ||
147
+ parsed.hash !== "" ||
148
+ (parsed.toString() !== text && !isRootWithoutSlash)) {
149
+ throw new StorageError(`OIDC authorization context has an invalid ${name}`);
150
+ }
151
+ return parsed;
152
+ }
153
+ function requireMetadataString(value, name, maxLength = MAX_OIDC_METADATA_STRING_LENGTH) {
154
+ if (typeof value !== "string" ||
155
+ value.length === 0 ||
156
+ value.length > maxLength ||
157
+ /[\u0000-\u001f\u007f]/.test(value)) {
158
+ throw new StorageError(`OIDC dynamic client registration has an invalid ${name}`);
159
+ }
160
+ return value;
161
+ }
162
+ function validateRegistrationMetadata(metadata) {
163
+ const clientUri = parseHttpUrl(metadata.clientUri, "client URI");
164
+ if (metadata.clientName !== undefined)
165
+ requireMetadataString(metadata.clientName, "client name");
166
+ for (const [name, value] of [
167
+ ["logo URI", metadata.logoUri],
168
+ ["terms URI", metadata.tosUri],
169
+ ["policy URI", metadata.policyUri],
170
+ ]) {
171
+ if (value !== undefined) {
172
+ const uri = parseHttpUrl(value, name);
173
+ if (uri.origin !== clientUri.origin) {
174
+ throw new StorageError("OIDC dynamic client registration has an untrusted metadata origin");
175
+ }
176
+ }
177
+ }
178
+ if (metadata.contacts !== undefined) {
179
+ if (!Array.isArray(metadata.contacts) || metadata.contacts.length > MAX_OIDC_CONTACTS) {
180
+ throw new StorageError("OIDC dynamic client registration has too many contacts");
181
+ }
182
+ metadata.contacts.forEach((contact) => requireMetadataString(contact, "contact", 512));
183
+ }
184
+ if (metadata.redirectUris === undefined)
185
+ return { clientUri };
186
+ if (!Array.isArray(metadata.redirectUris) ||
187
+ metadata.redirectUris.length === 0 ||
188
+ metadata.redirectUris.length > MAX_OIDC_REDIRECT_URIS) {
189
+ throw new StorageError("OIDC dynamic client registration has too many redirect URIs");
190
+ }
191
+ const redirectUris = metadata.redirectUris.map((value) => {
192
+ const uri = parseSafeRedirectUri(value, "redirect URI");
193
+ if (uri.origin !== clientUri.origin) {
194
+ throw new StorageError("OIDC dynamic client registration has an untrusted redirect origin");
195
+ }
196
+ return uri.toString();
197
+ });
198
+ return { clientUri, redirectUris };
199
+ }
200
+ async function readBoundedResponseText(response, maxBytes = MAX_OAUTH_ERROR_BODY_BYTES, signal) {
201
+ const body = await readBoundedResponseBody(response, maxBytes, signal);
202
+ return { text: new TextDecoder().decode(body.bytes), truncated: body.truncated };
203
+ }
204
+ async function readBoundedJsonResponse(response, operation, signal) {
205
+ const maxBytes = response.ok ? MAX_OAUTH_SUCCESS_BODY_BYTES : MAX_OAUTH_ERROR_BODY_BYTES;
206
+ const body = await readBoundedResponseText(response, maxBytes, signal);
207
+ if (body.truncated) {
208
+ throw new StorageError(`OIDC ${operation} returned an oversized response`);
209
+ }
210
+ try {
211
+ return JSON.parse(body.text);
212
+ }
213
+ catch {
214
+ throw new StorageError(`OIDC ${operation} returned an invalid response`);
215
+ }
216
+ }
217
+ function assertNoRedirect(response, endpoint, operation) {
218
+ // OAuth request bodies contain bearer credentials, refresh tokens, or
219
+ // authorization codes. Redirects must never be followed with those bodies.
220
+ // `redirect: "manual"` makes this a local rejection rather than a network
221
+ // hop, and the URL check protects callers that provide a non-canonical URL.
222
+ if ((response.status >= 300 && response.status < 400) ||
223
+ response.type === "opaqueredirect" ||
224
+ (response.url !== "" && response.url !== endpoint) ||
225
+ response.redirected) {
226
+ void response.body?.cancel().catch(() => undefined);
227
+ throw new StorageError(`OIDC ${operation} rejected an untrusted redirect`);
228
+ }
229
+ }
230
+ function exactEndpoint(value, name) {
231
+ return parseHttpUrl(value, name).toString();
232
+ }
233
+ function parseSafeOAuthError(body) {
234
+ let parsed;
235
+ try {
236
+ parsed = JSON.parse(body);
237
+ }
238
+ catch {
239
+ return undefined;
240
+ }
241
+ if (!isRecord(parsed) || typeof parsed.error !== "string" || !SAFE_OAUTH_ERROR_CODES.has(parsed.error)) {
242
+ return undefined;
243
+ }
244
+ return { code: parsed.error };
245
+ }
246
+ function formatTokenRefreshFailure(status, body) {
247
+ if (body.truncated) {
248
+ return `OIDC token refresh failed (${status}): provider error response was too large`;
249
+ }
250
+ const oauthError = parseSafeOAuthError(body.text);
251
+ if (!oauthError) {
252
+ return `OIDC token refresh failed (${status}): provider returned an invalid OAuth error response`;
253
+ }
254
+ return `OIDC token refresh failed (${status}): OAuth error ${oauthError.code}`;
255
+ }
256
+ function providerErrorStatus(error) {
257
+ if (!isRecord(error) || typeof error.httpStatus !== "number")
258
+ return undefined;
259
+ return Number.isInteger(error.httpStatus) && error.httpStatus >= 100 && error.httpStatus <= 599
260
+ ? error.httpStatus
261
+ : undefined;
262
+ }
263
+ function formatProviderFailure(operation, error) {
264
+ if (error instanceof OidcRequestTimeoutError)
265
+ return error.message;
266
+ if (error instanceof OidcRequestCancelledError)
267
+ return error.message;
268
+ const status = providerErrorStatus(error);
269
+ return `OIDC ${operation} failed${status === undefined ? "" : ` (${status})`}`;
270
+ }
271
+ function ensureOidcNotCancelled(signal, operation) {
272
+ if (signal?.aborted)
273
+ throw new OidcRequestCancelledError(operation);
274
+ }
275
+ async function requestWithTimeout(input, init, operation, consume, timeoutMs = OIDC_REQUEST_TIMEOUT_MS, externalSignal) {
276
+ const controller = new AbortController();
277
+ let timedOut = false;
278
+ const timeout = setTimeout(() => {
279
+ timedOut = true;
280
+ controller.abort();
281
+ }, timeoutMs);
282
+ const abort = () => controller.abort();
283
+ const signals = [externalSignal, init.signal].filter((signal) => signal != null);
284
+ for (const signal of signals) {
285
+ if (signal.aborted)
286
+ controller.abort();
287
+ else
288
+ signal.addEventListener("abort", abort, { once: true });
289
+ }
290
+ try {
291
+ const response = await raceWithAbort(fetch(input, { ...init, signal: controller.signal }), controller.signal, () => undefined, () => timedOut
292
+ ? new OidcRequestTimeoutError(operation)
293
+ : new OidcRequestCancelledError(operation));
294
+ return await consume(response, controller.signal);
295
+ }
296
+ catch (error) {
297
+ if (timedOut)
298
+ throw new OidcRequestTimeoutError(operation);
299
+ if (signals.some((signal) => signal.aborted)) {
300
+ throw new OidcRequestCancelledError(operation);
301
+ }
302
+ throw error;
303
+ }
304
+ finally {
305
+ clearTimeout(timeout);
306
+ for (const signal of signals)
307
+ signal.removeEventListener("abort", abort);
308
+ }
309
+ }
310
+ function abortableDelay(milliseconds, signal) {
311
+ if (signal?.aborted)
312
+ return Promise.reject(new OidcRequestCancelledError("device authorization"));
313
+ return new Promise((resolve, reject) => {
314
+ let timer;
315
+ const abort = () => {
316
+ clearTimeout(timer);
317
+ signal?.removeEventListener("abort", abort);
318
+ reject(new OidcRequestCancelledError("device authorization"));
319
+ };
320
+ timer = setTimeout(() => {
321
+ signal?.removeEventListener("abort", abort);
322
+ resolve();
323
+ }, milliseconds);
324
+ signal?.addEventListener("abort", abort, { once: true });
325
+ });
326
+ }
327
+ function requestUrl(input) {
328
+ if (typeof input === "string")
329
+ return new URL(input).toString();
330
+ if (input instanceof URL)
331
+ return input.toString();
332
+ return input.url;
333
+ }
334
+ /**
335
+ * MatrixClient's OAuth helpers use the SDK HTTP layer for discovery and
336
+ * whoami. Supply a bounded/manual-redirect fetch implementation so those
337
+ * calls have the same body and credential boundary as the direct OAuth calls.
338
+ */
339
+ function boundedMatrixFetch(operation, externalSignal) {
340
+ return async (input, init) => {
341
+ const endpoint = requestUrl(input);
342
+ return requestWithTimeout(input, { ...init, redirect: "manual" }, operation, async (response, requestSignal) => {
343
+ assertNoRedirect(response, endpoint, operation);
344
+ const maxBytes = response.ok ? MAX_OAUTH_SUCCESS_BODY_BYTES : MAX_OAUTH_ERROR_BODY_BYTES;
345
+ const body = await readBoundedResponseText(response, maxBytes, requestSignal);
346
+ if (body.truncated) {
347
+ if (response.ok) {
348
+ throw new StorageError(`OIDC ${operation} returned an oversized response`);
349
+ }
350
+ return new Response(null, {
351
+ status: response.status,
352
+ statusText: response.statusText,
353
+ });
354
+ }
355
+ return new Response(body.text, {
356
+ status: response.status,
357
+ statusText: response.statusText,
358
+ headers: response.headers,
359
+ });
360
+ }, OIDC_REQUEST_TIMEOUT_MS, externalSignal);
361
+ };
362
+ }
363
+ function parseHttpUrl(value, name) {
364
+ const text = requireBoundedString(value, name, MAX_OIDC_URL_LENGTH);
365
+ let parsed;
366
+ try {
367
+ parsed = new URL(text);
368
+ }
369
+ catch {
370
+ throw new StorageError(`OIDC authorization context has an invalid ${name}`);
371
+ }
372
+ const isRootWithoutSlash = parsed.pathname === "/" && parsed.toString() === `${text}/`;
373
+ if ((parsed.protocol !== "http:" && parsed.protocol !== "https:") ||
374
+ (parsed.protocol === "http:" && !isLoopbackHostname(parsed.hostname)) ||
375
+ parsed.username !== "" ||
376
+ parsed.password !== "" ||
377
+ parsed.search !== "" ||
378
+ parsed.hash !== "" ||
379
+ (parsed.toString() !== text && !isRootWithoutSlash)) {
380
+ throw new StorageError(`OIDC authorization context has an invalid ${name}`);
381
+ }
382
+ return parsed;
383
+ }
384
+ function isWithinIssuerPath(issuer, endpoint) {
385
+ if (issuer.pathname === "/")
386
+ return true;
387
+ const prefix = issuer.pathname.endsWith("/") ? issuer.pathname : `${issuer.pathname}/`;
388
+ return endpoint.pathname === issuer.pathname || endpoint.pathname.startsWith(prefix);
389
+ }
390
+ function matrixAuthMetadataEndpoint(homeserver) {
391
+ const basePath = homeserver.pathname.replace(/\/+$/u, "");
392
+ return new URL(`${basePath}/_matrix/client/v1/auth_metadata`, homeserver.origin).toString();
393
+ }
394
+ function validateTokenEndpointMetadata(metadata) {
395
+ const issuer = parseHttpUrl(metadata.issuer, "issuer");
396
+ const tokenEndpoint = parseHttpUrl(metadata.token_endpoint, "token endpoint");
397
+ if (tokenEndpoint.origin !== issuer.origin || !isWithinIssuerPath(issuer, tokenEndpoint)) {
398
+ throw new StorageError("OIDC token endpoint is outside the validated issuer");
399
+ }
400
+ let revocationEndpoint;
401
+ if (metadata.revocation_endpoint !== undefined) {
402
+ const parsed = parseHttpUrl(metadata.revocation_endpoint, "revocation endpoint");
403
+ if (parsed.origin !== issuer.origin || !isWithinIssuerPath(issuer, parsed)) {
404
+ throw new StorageError("OIDC revocation endpoint is outside the validated issuer");
405
+ }
406
+ revocationEndpoint = parsed.toString();
407
+ }
408
+ return { tokenEndpoint: tokenEndpoint.toString(), revocationEndpoint };
409
+ }
410
+ function validateAuthMetadata(value) {
411
+ if (!isRecord(value)) {
412
+ throw new StorageError("OIDC authorization context has invalid auth metadata");
413
+ }
414
+ const metadata = value;
415
+ // Validate the stable Matrix auth-metadata contract locally. The Matrix SDK
416
+ // guard currently models revocation_endpoint as required even though the
417
+ // protocol makes it optional; substituting a fake endpoint there would turn
418
+ // malformed metadata into a trusted credential destination.
419
+ for (const name of [
420
+ "issuer",
421
+ "authorization_endpoint",
422
+ "registration_endpoint",
423
+ "token_endpoint",
424
+ ]) {
425
+ const field = metadata[name];
426
+ if (field !== undefined &&
427
+ (typeof field !== "string" ||
428
+ field.length === 0 ||
429
+ field.length > MAX_OIDC_URL_LENGTH ||
430
+ /[\u0000-\u001f\u007f]/.test(field))) {
431
+ throw new StorageError("OIDC authorization context has invalid auth metadata");
432
+ }
433
+ }
434
+ for (const name of ["issuer", "authorization_endpoint", "registration_endpoint", "token_endpoint"]) {
435
+ if (metadata[name] === undefined) {
436
+ throw new StorageError("OIDC authorization context has invalid auth metadata");
437
+ }
438
+ }
439
+ for (const name of [
440
+ "revocation_endpoint",
441
+ "device_authorization_endpoint",
442
+ "account_management_uri",
443
+ ]) {
444
+ const field = metadata[name];
445
+ if (field !== undefined &&
446
+ (typeof field !== "string" ||
447
+ field.length === 0 ||
448
+ field.length > MAX_OIDC_URL_LENGTH ||
449
+ /[\u0000-\u001f\u007f]/.test(field))) {
450
+ throw new StorageError("OIDC authorization context has invalid auth metadata");
451
+ }
452
+ }
453
+ for (const name of [
454
+ "response_modes_supported",
455
+ "response_types_supported",
456
+ "grant_types_supported",
457
+ "code_challenge_methods_supported",
458
+ "prompt_values_supported",
459
+ "account_management_actions_supported",
460
+ ]) {
461
+ const field = metadata[name];
462
+ if (field !== undefined &&
463
+ (!Array.isArray(field) || field.length > 32 || field.some((item) => typeof item !== "string" ||
464
+ item.length === 0 ||
465
+ item.length > MAX_OIDC_METADATA_STRING_LENGTH ||
466
+ /[\u0000-\u001f\u007f]/.test(item)))) {
467
+ throw new StorageError("OIDC authorization context has invalid auth metadata");
468
+ }
469
+ }
470
+ for (const name of ["response_types_supported", "grant_types_supported", "code_challenge_methods_supported"]) {
471
+ if (!Array.isArray(metadata[name]) || metadata[name].length === 0) {
472
+ throw new StorageError("OIDC authorization context has invalid auth metadata");
473
+ }
474
+ }
475
+ return value;
476
+ }
477
+ /** TeleCrypt's extra trust boundary for browser state rehydration. */
478
+ function validateTrustedAuthMetadata(value) {
479
+ const metadata = validateAuthMetadata(value);
480
+ const issuer = parseHttpUrl(metadata.issuer, "issuer");
481
+ const endpoints = [
482
+ ["authorization endpoint", metadata.authorization_endpoint],
483
+ ["registration endpoint", metadata.registration_endpoint],
484
+ ["token endpoint", metadata.token_endpoint],
485
+ ];
486
+ if (metadata.revocation_endpoint !== undefined) {
487
+ endpoints.push(["revocation endpoint", metadata.revocation_endpoint]);
488
+ }
489
+ if (metadata.device_authorization_endpoint !== undefined) {
490
+ endpoints.push(["device authorization endpoint", metadata.device_authorization_endpoint]);
491
+ }
492
+ for (const [name, value] of endpoints) {
493
+ const endpoint = parseHttpUrl(value, name);
494
+ if (endpoint.origin !== issuer.origin || !isWithinIssuerPath(issuer, endpoint)) {
495
+ throw new StorageError(`OIDC authorization context has an invalid ${name}`);
496
+ }
497
+ }
498
+ return metadata;
499
+ }
500
+ function validateDeviceAuthorizationSession(value) {
501
+ try {
502
+ validateDeviceAuthorizationResponse(value);
503
+ }
504
+ catch {
505
+ throw new StorageError("OIDC device authorization returned an invalid response");
506
+ }
507
+ const session = value;
508
+ requireBoundedString(session.device_code, "device code", MAX_OIDC_CODE_LENGTH);
509
+ requireBoundedString(session.user_code, "user code", MAX_OIDC_CODE_LENGTH);
510
+ parseSafeRedirectUri(session.verification_uri, "verification URI");
511
+ if (session.verification_uri_complete !== undefined) {
512
+ parseSafeRedirectUri(session.verification_uri_complete, "complete verification URI");
513
+ }
514
+ if (!Number.isInteger(session.expires_in) ||
515
+ session.expires_in <= 0 ||
516
+ session.expires_in > MAX_DEVICE_EXPIRES_IN_SECONDS) {
517
+ throw new StorageError("OIDC device authorization returned an invalid expiry");
518
+ }
519
+ if (session.interval !== undefined &&
520
+ (!Number.isInteger(session.interval) ||
521
+ session.interval <= 0 ||
522
+ session.interval > MAX_DEVICE_INTERVAL_SECONDS)) {
523
+ throw new StorageError("OIDC device authorization returned an invalid polling interval");
524
+ }
525
+ return session;
526
+ }
527
+ function validateDeviceAccessTokenResponse(value) {
528
+ if (!isValidDeviceAccessTokenResponse(value)) {
529
+ throw new StorageError("OIDC device authorization returned an invalid token response");
530
+ }
531
+ const response = value;
532
+ if (response.token_type.toLowerCase() !== "bearer") {
533
+ throw new StorageError("OIDC device authorization returned an invalid token type");
534
+ }
535
+ validateTokenBounds(response, "device authorization");
536
+ return response;
537
+ }
538
+ function validateTokenBounds(value, operation) {
539
+ try {
540
+ requireBoundedString(value.access_token, "access token", MAX_OIDC_TOKEN_LENGTH);
541
+ if (value.refresh_token !== undefined) {
542
+ requireBoundedString(value.refresh_token, "refresh token", MAX_OIDC_TOKEN_LENGTH);
543
+ }
544
+ if (value.scope !== undefined)
545
+ requireScope(value.scope, "scope");
546
+ }
547
+ catch {
548
+ throw new StorageError(`OIDC ${operation} returned an invalid token response`);
549
+ }
550
+ if (value.expires_in !== undefined &&
551
+ (!Number.isInteger(value.expires_in) || value.expires_in < 0 || value.expires_in > 86400)) {
552
+ throw new StorageError(`OIDC ${operation} returned an invalid token expiry`);
553
+ }
554
+ }
555
+ function sessionStorage() {
556
+ if (typeof window === "undefined" || !window.sessionStorage) {
557
+ throw new StorageError("OIDC authorization requires browser session storage");
558
+ }
559
+ return window.sessionStorage;
560
+ }
561
+ function validateAuthorizationInputs(opts) {
562
+ const authMetadata = validateTrustedAuthMetadata(opts.authMetadata);
563
+ const issuer = new URL(authMetadata.issuer);
564
+ const homeserver = parseHttpUrl(opts.homeserverUrl, "homeserver URL");
565
+ const redirect = parseHttpUrl(opts.redirectUri, "redirect URI");
566
+ if (homeserver.origin !== issuer.origin || redirect.origin !== window.location.origin) {
567
+ throw new StorageError("OIDC authorization context origins are not trusted");
568
+ }
569
+ requireClientId(opts.clientId);
570
+ const deviceId = opts.deviceId ?? randomHex(5).toUpperCase();
571
+ requireString(deviceId, "device ID", DEVICE_ID_PATTERN);
572
+ return { authMetadata, homeserver, redirect, deviceId };
573
+ }
574
+ function parseAuthorizationContext(value, expectedState) {
575
+ if (!isRecord(value) || value.version !== AUTHORIZATION_CONTEXT_VERSION) {
576
+ throw new StorageError("OIDC authorization context is missing or invalid");
577
+ }
578
+ if (value.state !== expectedState) {
579
+ throw new StorageError("OIDC authorization context state does not match callback state");
580
+ }
581
+ if (typeof value.createdAtMs !== "number" || !Number.isSafeInteger(value.createdAtMs)) {
582
+ throw new StorageError("OIDC authorization context has an invalid creation time");
583
+ }
584
+ const ageMs = Date.now() - value.createdAtMs;
585
+ if (ageMs < -AUTHORIZATION_CONTEXT_MAX_FUTURE_SKEW_MS) {
586
+ throw new StorageError("OIDC authorization context is from the future");
587
+ }
588
+ if (ageMs > AUTHORIZATION_CONTEXT_MAX_AGE_MS) {
589
+ throw new StorageError("OIDC authorization context is expired");
590
+ }
591
+ const authMetadata = validateTrustedAuthMetadata(value.authMetadata);
592
+ const homeserver = parseHttpUrl(value.homeserverUrl, "homeserver URL");
593
+ const redirect = parseHttpUrl(value.redirectUri, "redirect URI");
594
+ if (homeserver.origin !== new URL(authMetadata.issuer).origin || redirect.origin !== window.location.origin) {
595
+ throw new StorageError("OIDC authorization context origins are not trusted");
596
+ }
597
+ return {
598
+ version: AUTHORIZATION_CONTEXT_VERSION,
599
+ createdAtMs: value.createdAtMs,
600
+ state: expectedState,
601
+ authMetadata,
602
+ clientId: requireClientId(value.clientId),
603
+ redirectUri: redirect.toString(),
604
+ homeserverUrl: homeserver.toString(),
605
+ deviceId: requireString(value.deviceId, "device ID", DEVICE_ID_PATTERN),
606
+ codeVerifier: requireString(value.codeVerifier, "PKCE verifier", PKCE_VERIFIER_PATTERN),
607
+ };
608
+ }
15
609
  /**
16
610
  * Discovers the OIDC/MAS issuer and endpoints for a homeserver via the
17
- * Matrix client's authentication metadata endpoint.
18
- *
19
- * NODE CALLERS: this internally constructs oidc-client-ts state that
20
- * requires `window.sessionStorage`/`window.localStorage` to exist (a real
21
- * browser has both natively). A Node adapter may install those only around
22
- * this call: a permanent global `window` breaks rust-crypto WASM's own
23
- * environment detection. The functional-test-only adapter is kept in
24
- * `test/harness/oidcWindowShim.ts`, rather than in the production SDK.
611
+ * Matrix client's stable `/auth_metadata` endpoint. Matrix 42 performs this
612
+ * as a plain metadata fetch; discovery is safe for browser and Node callers.
25
613
  */
26
- export async function discoverOidcIssuer(homeserverBaseUrl) {
27
- const client = createClient({ baseUrl: homeserverBaseUrl });
614
+ export async function discoverOidcIssuer(homeserverBaseUrl, signal) {
28
615
  try {
29
- return await client.getAuthMetadata();
616
+ const homeserver = parseHttpUrl(homeserverBaseUrl, "homeserver URL");
617
+ const endpoint = matrixAuthMetadataEndpoint(homeserver);
618
+ const { response, body } = await requestWithTimeout(endpoint, { method: "GET", headers: { Accept: "application/json" }, redirect: "manual" }, "discovery", async (response, requestSignal) => {
619
+ assertNoRedirect(response, endpoint, "discovery");
620
+ if (!response.ok) {
621
+ // Discovery only consumes metadata on success. Drain a bounded
622
+ // error body without parsing or surfacing provider-controlled text;
623
+ // the status remains the only caller-visible diagnostic.
624
+ await readBoundedResponseBody(response, MAX_OAUTH_ERROR_BODY_BYTES, requestSignal);
625
+ return { response, body: undefined };
626
+ }
627
+ return {
628
+ response,
629
+ body: await readBoundedJsonResponse(response, "discovery", requestSignal),
630
+ };
631
+ }, OIDC_REQUEST_TIMEOUT_MS, signal);
632
+ if (!response.ok) {
633
+ const failure = new StorageError("OIDC discovery failed");
634
+ failure.httpStatus = response.status;
635
+ throw failure;
636
+ }
637
+ ensureOidcNotCancelled(signal, "discovery");
638
+ return validateTrustedAuthMetadata(body);
30
639
  }
31
640
  catch (err) {
32
- throw new StorageError(`OIDC discovery failed: ${err.message}`);
641
+ throw new StorageError(formatProviderFailure("discovery", err));
33
642
  }
34
643
  }
35
644
  /**
@@ -37,184 +646,634 @@ export async function discoverOidcIssuer(homeserverBaseUrl) {
37
646
  * host unless the issuer's policy allows a mismatch (our local dev/test MAS
38
647
  * does; a production issuer may not).
39
648
  */
40
- export async function registerClient(authMetadata, metadata) {
649
+ export async function registerClient(authMetadata, metadata, signal) {
41
650
  try {
42
- return await registerOidcClient(authMetadata, metadata);
651
+ const safeAuthMetadata = validateTrustedAuthMetadata(authMetadata);
652
+ const safeMetadata = validateRegistrationMetadata(metadata);
653
+ const defaultGrantTypes = [
654
+ OAuthGrantType.AuthorizationCode,
655
+ OAuthGrantType.RefreshToken,
656
+ ];
657
+ if (safeAuthMetadata.grant_types_supported.includes(OAuthGrantType.DeviceAuthorization)) {
658
+ defaultGrantTypes.push(OAuthGrantType.DeviceAuthorization);
659
+ }
660
+ const grantTypes = defaultGrantTypes;
661
+ if (grantTypes.some((grantType) => !safeAuthMetadata.grant_types_supported.includes(grantType))) {
662
+ throw new StorageError("OIDC dynamic client registration is not supported");
663
+ }
664
+ const registration = {
665
+ response_types: ["code"],
666
+ token_endpoint_auth_method: "none",
667
+ client_name: metadata.clientName,
668
+ client_uri: safeMetadata.clientUri.toString(),
669
+ grant_types: grantTypes,
670
+ logo_uri: metadata.logoUri,
671
+ application_type: metadata.applicationType,
672
+ redirect_uris: safeMetadata.redirectUris,
673
+ tos_uri: metadata.tosUri,
674
+ policy_uri: metadata.policyUri,
675
+ };
676
+ const endpoint = exactEndpoint(safeAuthMetadata.registration_endpoint, "registration endpoint");
677
+ const { response, body } = await requestWithTimeout(endpoint, {
678
+ method: "POST",
679
+ headers: { Accept: "application/json", "Content-Type": "application/json" },
680
+ body: JSON.stringify(registration),
681
+ redirect: "manual",
682
+ }, "dynamic client registration", async (response, requestSignal) => {
683
+ assertNoRedirect(response, endpoint, "dynamic client registration");
684
+ return {
685
+ response,
686
+ body: await readBoundedJsonResponse(response, "dynamic client registration", requestSignal),
687
+ };
688
+ }, OIDC_REQUEST_TIMEOUT_MS, signal);
689
+ if (!response.ok || !validateRegistrationResponse(body)) {
690
+ throw new StorageError("OIDC dynamic client registration failed");
691
+ }
692
+ ensureOidcNotCancelled(signal, "dynamic client registration");
693
+ return requireClientId(body.client_id);
43
694
  }
44
695
  catch (err) {
45
- throw new StorageError(`OIDC dynamic client registration failed: ${err.message}`);
696
+ throw new StorageError(formatProviderFailure("dynamic client registration", err));
46
697
  }
47
698
  }
48
699
  // ---------------------------------------------------------------------------
49
700
  // Device-code grant (RFC 8628) — the CLI's login flow. Plain `fetch` calls
50
- // under the hood (matrix-js-sdk's `startDeviceAuthorization` /
51
- // `waitForDeviceAuthorization`), nothing browser-only.
701
+ // under the hood (matrix-js-sdk's supported OAuth helpers), nothing
702
+ // browser-only.
52
703
  // ---------------------------------------------------------------------------
53
704
  /**
54
705
  * Starts a device-code authorization session. `deviceId` is caller-chosen
55
706
  * (unlike the authorization-code flow, where the SDK picks a random one) so
56
707
  * it can be reused as this Matrix device's `device_id` throughout —
57
- * `generateScope(deviceId)` embeds it in the requested scope per MSC2967.
708
+ * `generateScope(deviceId)` embeds it in the requested stable Matrix OAuth
709
+ * scope.
58
710
  */
59
- export async function startDeviceCodeLogin(authMetadata, clientId, deviceId) {
60
- const scope = generateScope(deviceId);
61
- return startDeviceAuthorization({ clientId, scope, metadata: authMetadata });
711
+ export async function startDeviceCodeLogin(authMetadata, clientId, deviceId, signal) {
712
+ try {
713
+ const safeAuthMetadata = validateTrustedAuthMetadata(authMetadata);
714
+ const safeClientId = requireClientId(clientId);
715
+ requireString(deviceId, "device ID", DEVICE_ID_PATTERN);
716
+ const scope = generateScope(deviceId);
717
+ const endpoint = safeAuthMetadata.device_authorization_endpoint;
718
+ if (!endpoint)
719
+ throw new StorageError("OIDC device authorization is not supported");
720
+ const trustedEndpoint = exactEndpoint(endpoint, "device authorization endpoint");
721
+ const { response, body } = await requestWithTimeout(trustedEndpoint, {
722
+ method: "POST",
723
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
724
+ body: new URLSearchParams({ client_id: safeClientId, scope }).toString(),
725
+ redirect: "manual",
726
+ }, "device authorization", async (response, requestSignal) => {
727
+ assertNoRedirect(response, trustedEndpoint, "device authorization");
728
+ return {
729
+ response,
730
+ body: await readBoundedJsonResponse(response, "device authorization", requestSignal),
731
+ };
732
+ }, OIDC_REQUEST_TIMEOUT_MS, signal);
733
+ if (!response.ok)
734
+ throw new StorageError("OIDC device authorization failed");
735
+ const session = validateDeviceAuthorizationSession(body);
736
+ ensureOidcNotCancelled(signal, "device authorization");
737
+ // Keep the requested device binding attached to this in-memory session
738
+ // without changing the RFC response shape or serializing it into logs.
739
+ deviceAuthorizationDeviceIds.set(session, deviceId);
740
+ return session;
741
+ }
742
+ catch (err) {
743
+ throw new StorageError(formatProviderFailure("device authorization", err));
744
+ }
62
745
  }
63
746
  /**
64
747
  * Polls the token endpoint until the device-code session is approved (or
65
748
  * denied/expired) — see matrix-js-sdk's `waitForDeviceAuthorization` for the
66
749
  * RFC 8628 polling semantics (honours `interval`/`expires_in`,
67
- * `authorization_pending`/`slow_down`).
750
+ * `authorization_pending`/`slow_down`). Sessions returned by
751
+ * startDeviceCodeLogin carry their binding in memory; reconstructed sessions
752
+ * must provide expectedDeviceId and are rejected before polling when no
753
+ * binding is available.
68
754
  */
69
- export async function waitForDeviceCodeLogin(authMetadata, clientId, session) {
70
- return waitForDeviceAuthorization({ session, metadata: authMetadata, clientId });
755
+ export async function waitForDeviceCodeLogin(authMetadata, clientId, session, signal, expectedDeviceId) {
756
+ try {
757
+ if (signal?.aborted)
758
+ throw new OidcRequestCancelledError("device authorization");
759
+ const safeAuthMetadata = validateTrustedAuthMetadata(authMetadata);
760
+ const safeClientId = requireClientId(clientId);
761
+ const safeSession = validateDeviceAuthorizationSession(session);
762
+ const requestedDeviceId = expectedDeviceId ?? deviceAuthorizationDeviceIds.get(session);
763
+ if (requestedDeviceId === undefined) {
764
+ throw new StorageError("OIDC device authorization requires an expected device ID");
765
+ }
766
+ requireString(requestedDeviceId, "device ID", DEVICE_ID_PATTERN);
767
+ const endpoint = exactEndpoint(safeAuthMetadata.token_endpoint, "token endpoint");
768
+ let interval = (safeSession.interval ?? 5) * 1000;
769
+ const expiration = Date.now() + Math.min(safeSession.expires_in * 1000, MAX_DEVICE_POLL_DURATION_MS);
770
+ do {
771
+ const remaining = expiration - Date.now();
772
+ if (remaining <= 0) {
773
+ ensureOidcNotCancelled(signal, "device authorization");
774
+ return { error: "expired" };
775
+ }
776
+ const { response, body } = await requestWithTimeout(endpoint, {
777
+ method: "POST",
778
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
779
+ body: new URLSearchParams({
780
+ device_code: safeSession.device_code,
781
+ grant_type: OAuthGrantType.DeviceAuthorization,
782
+ client_id: safeClientId,
783
+ }).toString(),
784
+ redirect: "manual",
785
+ }, "device authorization", async (response, requestSignal) => {
786
+ assertNoRedirect(response, endpoint, "device authorization");
787
+ return {
788
+ response,
789
+ body: await readBoundedJsonResponse(response, "device authorization", requestSignal),
790
+ };
791
+ }, Math.min(OIDC_REQUEST_TIMEOUT_MS, remaining), signal);
792
+ if (response.ok && isValidDeviceAccessTokenResponse(body)) {
793
+ const tokenResponse = validateDeviceAccessTokenResponse(body);
794
+ if (!requestedDeviceId || tokenResponse.scope !== generateScope(requestedDeviceId)) {
795
+ throw new OidcValidationError("OIDC device authorization returned an unexpected granted scope");
796
+ }
797
+ ensureOidcNotCancelled(signal, "device authorization");
798
+ return tokenResponse;
799
+ }
800
+ const error = isRecord(body) && typeof body.error === "string" ? body.error : undefined;
801
+ switch (error) {
802
+ case "authorization_pending":
803
+ break;
804
+ case "slow_down":
805
+ interval += 5000;
806
+ break;
807
+ case "access_denied":
808
+ case "expired_token":
809
+ ensureOidcNotCancelled(signal, "device authorization");
810
+ return { error: SAFE_OAUTH_ERROR_CODES.has(error) ? error : "provider_error" };
811
+ default:
812
+ ensureOidcNotCancelled(signal, "device authorization");
813
+ return { error: "provider_error" };
814
+ }
815
+ await abortableDelay(Math.min(interval, Math.max(0, expiration - Date.now())), signal);
816
+ } while (Date.now() < expiration);
817
+ ensureOidcNotCancelled(signal, "device authorization");
818
+ return { error: "expired" };
819
+ }
820
+ catch (err) {
821
+ if (err instanceof OidcValidationError)
822
+ throw err;
823
+ throw new StorageError(formatProviderFailure("device authorization", err));
824
+ }
71
825
  }
72
826
  export function isDeviceAccessTokenError(result) {
73
827
  return typeof result.error === "string";
74
828
  }
75
829
  // ---------------------------------------------------------------------------
76
830
  // Authorization code + PKCE — the web UI's login flow. Browser-only when
77
- // actually called (uses window.crypto/window.sessionStorage internally via
78
- // oidc-client-ts) — never called from the CLI.
831
+ // actually called because its one-time context is held in sessionStorage —
832
+ // never called from the CLI.
79
833
  // ---------------------------------------------------------------------------
80
834
  /**
81
- * Builds the authorization URL to redirect the browser to. PKCE
82
- * verifier/state/nonce are generated and persisted to `window.sessionStorage`
83
- * internally by matrix-js-sdk/oidc-client-ts (`mx_oidc_`-prefixed keys) — the
84
- * caller does not need to manage them; `completeAuthorizationCodeFlow` reads
85
- * them back after the redirect.
86
- *
87
- * `deviceId` is optional: when supplied, it is embedded in the requested
88
- * scope (MSC2967) so MAS grants THAT device instead of minting a fresh one —
89
- * the web UI passes a stable per-browser device id so repeated logins reuse
90
- * the same Matrix device instead of accumulating one device per login.
835
+ * Builds the authorization URL to redirect the browser to. The Matrix 42
836
+ * OAuth2 helper owns PKCE challenge generation and URL construction; this
837
+ * SDK owns only the minimal context needed to recreate that helper after the
838
+ * redirect. The context is keyed by a one-time random state in sessionStorage.
91
839
  */
92
840
  export async function beginAuthorizationCodeFlow(opts) {
93
- const nonce = crypto.randomUUID();
94
- if (opts.deviceId) {
95
- // matrix-js-sdk's generateOidcAuthorizationUrl hardcodes
96
- // generateScope() with no device id, so every login would mint a fresh
97
- // Matrix device. Build the URL ourselves with the stable device id in
98
- // the requested scope (MSC2967) so MAS grants THAT device — mirroring
99
- // matrix-js-sdk's construction exactly (same state store, same user
100
- // state shape) so completeAuthorizationCodeFlow still works unchanged.
101
- const scope = generateScope(opts.deviceId);
102
- const oidcClient = new OidcClient({
103
- ...opts.authMetadata,
104
- signingKeys: opts.authMetadata.signingKeys ?? undefined,
105
- metadata: opts.authMetadata,
106
- client_id: opts.clientId,
107
- redirect_uri: opts.redirectUri,
108
- authority: opts.authMetadata.issuer,
109
- response_mode: "query",
110
- response_type: "code",
111
- scope,
112
- stateStore: new WebStorageStateStore({ prefix: "mx_oidc_", store: window.sessionStorage }),
113
- });
114
- const userState = { homeserverUrl: opts.homeserverUrl, nonce };
115
- const request = await oidcClient.createSigninRequest({
116
- state: userState,
117
- nonce,
118
- });
119
- return request.url;
120
- }
121
- return generateOidcAuthorizationUrl({
122
- metadata: opts.authMetadata,
841
+ if (opts.signal?.aborted)
842
+ throw new StorageError("OIDC authorization cancelled");
843
+ const { authMetadata, homeserver, redirect, deviceId } = validateAuthorizationInputs(opts);
844
+ const createdAtMs = Date.now();
845
+ const state = randomHex(32);
846
+ const codeVerifier = randomHex(64);
847
+ const oauth = new OAuth2(authMetadata, {
123
848
  clientId: opts.clientId,
124
- homeserverUrl: opts.homeserverUrl,
125
- redirectUri: opts.redirectUri,
126
- nonce,
849
+ deviceId,
850
+ codeVerifier,
851
+ redirectUri: redirect.toString(),
127
852
  });
853
+ const url = await oauth.generateAuthorizationCodeGrantUrl(state, "query");
854
+ if (opts.signal?.aborted)
855
+ throw new StorageError("OIDC authorization cancelled");
856
+ let generated;
857
+ try {
858
+ generated = new URL(url);
859
+ }
860
+ catch {
861
+ throw new StorageError("OIDC authorization URL is invalid");
862
+ }
863
+ const expectedEndpoint = parseHttpUrl(authMetadata.authorization_endpoint, "authorization endpoint");
864
+ const stateValues = generated.searchParams.getAll("state");
865
+ if (generated.origin !== expectedEndpoint.origin ||
866
+ generated.pathname !== expectedEndpoint.pathname ||
867
+ generated.username !== "" ||
868
+ generated.password !== "" ||
869
+ generated.hash !== "" ||
870
+ stateValues.length !== 1 ||
871
+ stateValues[0] !== state ||
872
+ generated.searchParams.getAll("redirect_uri").length !== 1 ||
873
+ generated.searchParams.get("redirect_uri") !== redirect.toString()) {
874
+ throw new StorageError("OIDC authorization URL is not bound to the validated request");
875
+ }
876
+ const context = {
877
+ version: AUTHORIZATION_CONTEXT_VERSION,
878
+ createdAtMs,
879
+ state,
880
+ authMetadata,
881
+ clientId: opts.clientId,
882
+ redirectUri: redirect.toString(),
883
+ homeserverUrl: homeserver.toString(),
884
+ deviceId,
885
+ codeVerifier,
886
+ };
887
+ sessionStorage().setItem(`${AUTHORIZATION_CONTEXT_PREFIX}${state}`, JSON.stringify(context));
888
+ return url;
128
889
  }
129
890
  /** Completes the authorization-code exchange after the `?code&state` redirect. */
130
- export async function completeAuthorizationCodeFlow(code, state) {
131
- return completeAuthorizationCodeGrant(code, state);
891
+ export async function completeAuthorizationCodeFlow(code, state, signal) {
892
+ requireString(state, "authorization state", STATE_PATTERN);
893
+ const store = sessionStorage();
894
+ const key = `${AUTHORIZATION_CONTEXT_PREFIX}${state}`;
895
+ const serialized = store.getItem(key);
896
+ if (!serialized)
897
+ throw new StorageError("OIDC authorization context is missing or expired");
898
+ if (serialized.length > MAX_AUTHORIZATION_CONTEXT_LENGTH) {
899
+ store.removeItem(key);
900
+ throw new StorageError("OIDC authorization context is too large");
901
+ }
902
+ let context;
903
+ try {
904
+ context = parseAuthorizationContext(JSON.parse(serialized), state);
905
+ }
906
+ catch (error) {
907
+ if (error instanceof StorageError)
908
+ throw error;
909
+ throw new StorageError("OIDC authorization context is missing or invalid");
910
+ }
911
+ finally {
912
+ // Consume the state before any network exchange. A retry must not replay
913
+ // the authorization code, even if the exchange fails.
914
+ store.removeItem(key);
915
+ }
916
+ try {
917
+ requireBoundedString(code, "authorization code", MAX_OIDC_CODE_LENGTH);
918
+ const endpoint = exactEndpoint(context.authMetadata.token_endpoint, "token endpoint");
919
+ const params = new URLSearchParams({
920
+ grant_type: "authorization_code",
921
+ client_id: context.clientId,
922
+ code_verifier: context.codeVerifier,
923
+ redirect_uri: context.redirectUri,
924
+ code,
925
+ });
926
+ const { response, body } = await requestWithTimeout(endpoint, {
927
+ method: "POST",
928
+ headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
929
+ body: params.toString(),
930
+ redirect: "manual",
931
+ }, "authorization code exchange", async (response, requestSignal) => {
932
+ assertNoRedirect(response, endpoint, "authorization code exchange");
933
+ return {
934
+ response,
935
+ body: await readBoundedJsonResponse(response, "authorization code exchange", requestSignal),
936
+ };
937
+ }, OIDC_REQUEST_TIMEOUT_MS, signal);
938
+ if (!response.ok)
939
+ throw new StorageError("OIDC authorization code exchange failed");
940
+ validateBearerTokenResponse(body);
941
+ validateTokenBounds(body, "authorization code exchange");
942
+ const normalized = normalizeBearerTokenResponseTokenType(body);
943
+ const expectedScope = generateScope(context.deviceId);
944
+ const grantedScope = normalized.scope;
945
+ if (typeof grantedScope !== "string" || grantedScope !== expectedScope) {
946
+ throw new StorageError("OIDC authorization code exchange returned an unexpected granted scope");
947
+ }
948
+ const tokenResponse = {
949
+ ...normalized,
950
+ scope: grantedScope,
951
+ };
952
+ ensureOidcNotCancelled(signal, "authorization code exchange");
953
+ return {
954
+ tokenResponse,
955
+ oidcClientSettings: { clientId: context.clientId, issuer: context.authMetadata.issuer },
956
+ homeserverUrl: context.homeserverUrl,
957
+ };
958
+ }
959
+ catch (err) {
960
+ if (err instanceof StorageError)
961
+ throw err;
962
+ throw new StorageError(formatProviderFailure("authorization code exchange", err));
963
+ }
132
964
  }
133
965
  // ---------------------------------------------------------------------------
134
966
  // Shared helpers
135
967
  // ---------------------------------------------------------------------------
136
- /** The device_id granted is embedded in the token response's `scope`
137
- * (`urn:matrix:org.matrix.msc2967.client:device:<id>`), per MSC2967 — the
138
- * authorization-code flow doesn't return it any other way (the SDK
139
- * auto-generates it when the caller doesn't request a specific one). */
968
+ /** The device_id granted is embedded in the token response's stable Matrix
969
+ * OAuth scope. */
140
970
  export function extractDeviceIdFromScope(scope) {
141
- const match = scope.match(/urn:matrix:org\.matrix\.msc2967\.client:device:(\S+)/);
142
- return match ? match[1] : null;
971
+ if (typeof scope !== "string" ||
972
+ scope.length > MAX_OIDC_SCOPE_LENGTH ||
973
+ /[\u0000-\u001f\u007f]/.test(scope) ||
974
+ scope.trim() !== scope) {
975
+ return null;
976
+ }
977
+ const tokens = scope.split(/\s+/);
978
+ const deviceTokens = tokens.filter((token) => token.startsWith("urn:matrix:client:device:"));
979
+ if (deviceTokens.length !== 1)
980
+ return null;
981
+ const deviceId = deviceTokens[0].slice("urn:matrix:client:device:".length);
982
+ return DEVICE_ID_PATTERN.test(deviceId) ? deviceId : null;
983
+ }
984
+ function validateMatrixUserId(userId, serverName) {
985
+ try {
986
+ return validateCanonicalMatrixUserId(userId, serverName);
987
+ }
988
+ catch {
989
+ throw new StorageError("OIDC identity confirmation returned an invalid or foreign user ID");
990
+ }
143
991
  }
144
992
  /** Confirms {userId, deviceId} for a freshly-obtained access token via
145
993
  * `GET /_matrix/client/v3/account/whoami` — needed because neither the
146
994
  * device-code nor authorization-code token response includes the Matrix
147
995
  * user ID. */
148
- export async function whoAmI(homeserverUrl, accessToken) {
149
- const client = createClient({ baseUrl: homeserverUrl, accessToken });
150
- const res = await client.whoami();
151
- return { userId: res.user_id, deviceId: res.device_id ?? null };
996
+ export async function whoAmI(homeserverUrl, accessToken, serverName, signal) {
997
+ try {
998
+ const homeserver = parseHttpUrl(homeserverUrl, "homeserver URL");
999
+ const baseUrl = homeserver.toString();
1000
+ requireBoundedString(accessToken, "access token", MAX_OIDC_TOKEN_LENGTH);
1001
+ const client = createClient({
1002
+ baseUrl,
1003
+ accessToken,
1004
+ localTimeoutMs: OIDC_REQUEST_TIMEOUT_MS,
1005
+ fetchFn: boundedMatrixFetch("identity confirmation", signal),
1006
+ });
1007
+ const res = await client.whoami();
1008
+ const userId = validateMatrixUserId(res?.user_id, serverName);
1009
+ if (res.device_id !== undefined && res.device_id !== null) {
1010
+ try {
1011
+ validateMatrixDeviceId(res.device_id);
1012
+ }
1013
+ catch {
1014
+ throw new StorageError("OIDC identity confirmation returned an invalid device ID");
1015
+ }
1016
+ }
1017
+ if (signal?.aborted)
1018
+ throw new OidcRequestCancelledError("identity confirmation");
1019
+ return { userId, deviceId: res.device_id ?? null };
1020
+ }
1021
+ catch (err) {
1022
+ if (signal?.aborted) {
1023
+ throw new StorageError("OIDC identity confirmation cancelled");
1024
+ }
1025
+ if (err instanceof StorageError)
1026
+ throw err;
1027
+ throw new StorageError(formatProviderFailure("identity confirmation", err));
1028
+ }
152
1029
  }
153
1030
  /**
154
1031
  * Refreshes an access token via a plain RFC 6749 `grant_type=refresh_token`
155
- * POST to the token endpoint — deliberately NOT matrix-js-sdk's
156
- * `OidcTokenRefresher`/oidc-client-ts, which unconditionally construct an
157
- * internal `OidcClient` requiring `window.sessionStorage`/`window.
158
- * localStorage` even though a plain refresh never actually reads or writes
159
- * them (confirmed by direct testing: `ReferenceError: window is not
160
- * defined` under plain Node; see test/functional/oidc.test.ts O.2). A public client (`token_endpoint_auth_method:
1032
+ * POST to the token endpoint. A public client (`token_endpoint_auth_method:
161
1033
  * "none"`, what `registerClient` above registers) authenticates a refresh
162
- * with just `client_id` in the body, no secret — this is the whole request.
163
- * Works identically in Node and the browser (plain `fetch`), so both
164
- * adapters share it with zero platform-specific code.
1034
+ * with just `client_id` in the body, no secret. This narrow request remains
1035
+ * local because Matrix 42's `OAuth2.performRefreshTokenGrant` parses every
1036
+ * non-success body with unbounded `response.json()`; this path bounds the
1037
+ * body and exposes only an allowlisted OAuth error code.
165
1038
  */
166
- export async function refreshOidcToken(tokenEndpoint, clientId, refreshToken) {
167
- const res = await fetch(tokenEndpoint, {
1039
+ async function refreshOidcToken(tokenEndpoint, clientId, refreshToken, expectedDeviceId, signal) {
1040
+ const endpoint = exactEndpoint(tokenEndpoint, "token endpoint");
1041
+ const safeClientId = requireClientId(clientId);
1042
+ const safeRefreshToken = requireBoundedString(refreshToken, "refresh token", MAX_OIDC_TOKEN_LENGTH);
1043
+ const controller = new AbortController();
1044
+ const timeout = setTimeout(() => controller.abort(), OIDC_REFRESH_TIMEOUT_MS);
1045
+ const abortExternal = () => controller.abort(signal?.reason);
1046
+ if (signal?.aborted)
1047
+ abortExternal();
1048
+ else
1049
+ signal?.addEventListener("abort", abortExternal, { once: true });
1050
+ try {
1051
+ const res = await raceWithAbort(fetch(endpoint, {
1052
+ method: "POST",
1053
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1054
+ body: new URLSearchParams({
1055
+ grant_type: "refresh_token",
1056
+ refresh_token: safeRefreshToken,
1057
+ client_id: safeClientId,
1058
+ }).toString(),
1059
+ signal: controller.signal,
1060
+ redirect: "manual",
1061
+ }), controller.signal, () => undefined, () => signal?.aborted
1062
+ ? new StorageError("OIDC token refresh cancelled")
1063
+ : new StorageError("OIDC token refresh timed out"));
1064
+ assertNoRedirect(res, endpoint, "token refresh");
1065
+ if (!res.ok) {
1066
+ let body;
1067
+ try {
1068
+ body = await readBoundedResponseText(res, MAX_OAUTH_ERROR_BODY_BYTES, controller.signal);
1069
+ }
1070
+ catch (error) {
1071
+ if (signal?.aborted || controller.signal.aborted)
1072
+ throw error;
1073
+ body = { text: "", truncated: false };
1074
+ }
1075
+ throw new StorageError(formatTokenRefreshFailure(res.status, body));
1076
+ }
1077
+ const body = await readBoundedResponseText(res, MAX_OAUTH_SUCCESS_BODY_BYTES, controller.signal);
1078
+ if (body.truncated) {
1079
+ throw new StorageError("OIDC token refresh returned an oversized response");
1080
+ }
1081
+ let data;
1082
+ try {
1083
+ data = JSON.parse(body.text);
1084
+ }
1085
+ catch {
1086
+ throw new StorageError("OIDC token refresh returned an invalid response");
1087
+ }
1088
+ if (!data || typeof data !== "object") {
1089
+ throw new StorageError("OIDC token refresh returned an invalid response");
1090
+ }
1091
+ const record = data;
1092
+ const accessToken = record.access_token;
1093
+ const nextRefreshToken = record.refresh_token;
1094
+ const grantedScope = record.scope;
1095
+ const expiresIn = record.expires_in;
1096
+ if (typeof accessToken !== "string" ||
1097
+ accessToken.trim() === "" ||
1098
+ accessToken.length > MAX_OIDC_TOKEN_LENGTH ||
1099
+ /[\s\u0000-\u001f\u007f]/.test(accessToken)) {
1100
+ throw new StorageError("OIDC token refresh returned no access token");
1101
+ }
1102
+ const tokenType = record.token_type;
1103
+ if (typeof tokenType !== "string" || tokenType.toLowerCase() !== "bearer") {
1104
+ throw new StorageError("OIDC token refresh returned an invalid token type");
1105
+ }
1106
+ if (nextRefreshToken !== undefined &&
1107
+ (typeof nextRefreshToken !== "string" ||
1108
+ nextRefreshToken.trim() === "" ||
1109
+ nextRefreshToken.length > MAX_OIDC_TOKEN_LENGTH ||
1110
+ /[\s\u0000-\u001f\u007f]/.test(nextRefreshToken))) {
1111
+ throw new StorageError("OIDC token refresh returned an invalid refresh token");
1112
+ }
1113
+ if (grantedScope !== undefined) {
1114
+ if (typeof grantedScope !== "string") {
1115
+ throw new StorageError("OIDC token refresh returned an invalid scope");
1116
+ }
1117
+ try {
1118
+ requireScope(grantedScope, "scope");
1119
+ }
1120
+ catch {
1121
+ throw new StorageError("OIDC token refresh returned an invalid scope");
1122
+ }
1123
+ if (extractDeviceIdFromScope(grantedScope) === null ||
1124
+ grantedScope !== generateScope(expectedDeviceId)) {
1125
+ throw new StorageError("OIDC token refresh returned an unexpected granted scope");
1126
+ }
1127
+ }
1128
+ else {
1129
+ throw new StorageError("OIDC token refresh returned no device-bound scope");
1130
+ }
1131
+ if (expiresIn !== undefined &&
1132
+ (typeof expiresIn !== "number" ||
1133
+ !Number.isInteger(expiresIn) ||
1134
+ expiresIn < 0 ||
1135
+ expiresIn > 86400)) {
1136
+ throw new StorageError("OIDC token refresh returned an invalid expiry");
1137
+ }
1138
+ ensureOidcNotCancelled(signal, "token refresh");
1139
+ return {
1140
+ accessToken,
1141
+ // RFC 6749 permits the provider to omit refresh_token when it remains
1142
+ // valid. Preserve the token that authorized this grant in that case.
1143
+ refreshToken: nextRefreshToken === undefined ? safeRefreshToken : nextRefreshToken,
1144
+ expiry: expiresIn === undefined
1145
+ ? undefined
1146
+ : new Date(Date.now() + expiresIn * 1000),
1147
+ };
1148
+ }
1149
+ catch (error) {
1150
+ if (error instanceof StorageError)
1151
+ throw error;
1152
+ if (signal?.aborted) {
1153
+ throw new StorageError("OIDC token refresh cancelled");
1154
+ }
1155
+ if (controller.signal.aborted) {
1156
+ throw new StorageError("OIDC token refresh timed out");
1157
+ }
1158
+ throw new StorageError("OIDC token refresh failed");
1159
+ }
1160
+ finally {
1161
+ clearTimeout(timeout);
1162
+ signal?.removeEventListener("abort", abortExternal);
1163
+ }
1164
+ }
1165
+ /** Revokes a token after a caller-side session commit fails or becomes stale. */
1166
+ async function revokeOidcToken(revocationEndpoint, clientId, token, tokenTypeHint = "access_token", signal) {
1167
+ const endpoint = exactEndpoint(revocationEndpoint, "revocation endpoint");
1168
+ const safeClientId = requireClientId(clientId);
1169
+ const safeToken = requireBoundedString(token, "token", MAX_OIDC_TOKEN_LENGTH);
1170
+ const { response } = await requestWithTimeout(endpoint, {
168
1171
  method: "POST",
169
1172
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
170
1173
  body: new URLSearchParams({
171
- grant_type: "refresh_token",
172
- refresh_token: refreshToken,
173
- client_id: clientId,
1174
+ client_id: safeClientId,
1175
+ token: safeToken,
1176
+ token_type_hint: tokenTypeHint,
174
1177
  }).toString(),
175
- });
176
- if (!res.ok) {
177
- const body = await res.text();
178
- throw new StorageError(`OIDC token refresh failed (${res.status}): ${body}`);
179
- }
180
- const data = await res.json();
181
- if (!data || typeof data !== "object") {
182
- throw new StorageError("OIDC token refresh returned an invalid response");
183
- }
184
- const record = data;
185
- const accessToken = record.access_token;
186
- const nextRefreshToken = record.refresh_token;
187
- const expiresIn = record.expires_in;
188
- if (typeof accessToken !== "string" || accessToken.trim() === "") {
189
- throw new StorageError("OIDC token refresh returned no access token");
1178
+ redirect: "manual",
1179
+ }, "token revocation", async (res, requestSignal) => {
1180
+ assertNoRedirect(res, endpoint, "token revocation");
1181
+ if (!res.ok) {
1182
+ await readBoundedResponseText(res, MAX_OAUTH_ERROR_BODY_BYTES, requestSignal);
1183
+ }
1184
+ else {
1185
+ await readBoundedResponseText(res, MAX_OAUTH_SUCCESS_BODY_BYTES, requestSignal);
1186
+ }
1187
+ return { response: res };
1188
+ }, OIDC_REQUEST_TIMEOUT_MS, signal);
1189
+ if (!response.ok)
1190
+ throw new StorageError("OIDC token revocation failed");
1191
+ }
1192
+ async function persistOidcTokens(onPersist, tokens, externalSignal) {
1193
+ if (externalSignal?.aborted)
1194
+ throw new OidcRequestCancelledError("token persistence");
1195
+ const controller = new AbortController();
1196
+ let timedOut = false;
1197
+ const timeout = setTimeout(() => {
1198
+ timedOut = true;
1199
+ controller.abort();
1200
+ }, OIDC_REFRESH_TIMEOUT_MS);
1201
+ const abortExternal = () => controller.abort(externalSignal?.reason);
1202
+ if (externalSignal?.aborted)
1203
+ abortExternal();
1204
+ else
1205
+ externalSignal?.addEventListener("abort", abortExternal, { once: true });
1206
+ try {
1207
+ // Promise.resolve().then() also observes synchronous callback throws and
1208
+ // lets raceWithAbort attach a rejection handler before a late adapter
1209
+ // rejection can become unhandled after the bounded call has settled.
1210
+ await raceWithAbort(Promise.resolve().then(() => onPersist(tokens, controller.signal)), controller.signal, () => undefined, () => timedOut
1211
+ ? new OidcRequestTimeoutError("token persistence")
1212
+ : new OidcRequestCancelledError("token persistence"));
1213
+ // The persistence promise and the caller signal can settle in either
1214
+ // order. Never report success from the narrow window where persistence
1215
+ // won the promise race but the caller has already cancelled.
1216
+ if (externalSignal?.aborted)
1217
+ throw new OidcRequestCancelledError("token persistence");
190
1218
  }
191
- if (nextRefreshToken !== undefined &&
192
- (typeof nextRefreshToken !== "string" || nextRefreshToken.trim() === "")) {
193
- throw new StorageError("OIDC token refresh returned an invalid refresh token");
1219
+ catch (error) {
1220
+ if (timedOut)
1221
+ throw new OidcRequestTimeoutError("token persistence");
1222
+ if (externalSignal?.aborted)
1223
+ throw new OidcRequestCancelledError("token persistence");
1224
+ throw error;
194
1225
  }
195
- if (expiresIn !== undefined &&
196
- (typeof expiresIn !== "number" || !Number.isFinite(expiresIn) || expiresIn < 0)) {
197
- throw new StorageError("OIDC token refresh returned an invalid expiry");
1226
+ finally {
1227
+ clearTimeout(timeout);
1228
+ externalSignal?.removeEventListener("abort", abortExternal);
198
1229
  }
199
- return {
200
- accessToken,
201
- refreshToken: nextRefreshToken,
202
- expiry: expiresIn === undefined
203
- ? undefined
204
- : new Date(Date.now() + expiresIn * 1000),
205
- };
1230
+ }
1231
+ function createOidcCleanupSignal() {
1232
+ const controller = new AbortController();
1233
+ const timeout = setTimeout(() => controller.abort(), OIDC_CLEANUP_TIMEOUT_MS);
1234
+ return { signal: controller.signal, close: () => clearTimeout(timeout) };
206
1235
  }
207
1236
  /**
208
1237
  * Builds the `tokenRefreshFunction` `TeleCryptIOStorage.createFromOidc`
209
- * expects: refreshes via `refreshOidcToken`, then calls `onPersist` with the
210
- * new tokens before returning them — so a caller's adapter-specific
1238
+ * expects: refreshes via the internal OAuth request, then calls `onPersist`
1239
+ * with the new tokens before returning them — so a caller's adapter-specific
211
1240
  * persistence (CLI: profile file; UI: localStorage) always sees a refresh
212
1241
  * that just happened, not just ones it initiated itself.
213
1242
  */
214
- export function buildTokenRefreshFunction(tokenEndpoint, clientId, onPersist) {
215
- return async (refreshToken) => {
216
- const tokens = await refreshOidcToken(tokenEndpoint, clientId, refreshToken);
217
- await onPersist({ accessToken: tokens.accessToken, refreshToken: tokens.refreshToken });
1243
+ export function buildTokenRefreshFunction(metadata, clientId, onPersist, expectedDeviceId) {
1244
+ // Store canonical endpoints bound to the same validated issuer for the
1245
+ // lifetime of this callback. Callers cannot provide an arbitrary HTTPS
1246
+ // endpoint and cause a refresh or revocation token to leave that issuer.
1247
+ const { tokenEndpoint: endpoint, revocationEndpoint } = validateTokenEndpointMetadata(metadata);
1248
+ const safeClientId = requireClientId(clientId);
1249
+ const safeExpectedDeviceId = requireString(expectedDeviceId, "device ID", DEVICE_ID_PATTERN);
1250
+ return async (refreshToken, signal) => {
1251
+ const tokens = await refreshOidcToken(endpoint, safeClientId, refreshToken, safeExpectedDeviceId, signal);
1252
+ try {
1253
+ await persistOidcTokens(onPersist, { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken }, signal);
1254
+ }
1255
+ catch {
1256
+ if (revocationEndpoint) {
1257
+ // Cleanup must remain possible even when the caller's signal is
1258
+ // already aborted. Use a separate bounded signal so persistence
1259
+ // failure cannot strand freshly-issued tokens merely because the
1260
+ // original request was cancelled.
1261
+ const cleanup = createOidcCleanupSignal();
1262
+ try {
1263
+ await revokeOidcToken(revocationEndpoint, safeClientId, tokens.accessToken, "access_token", cleanup.signal);
1264
+ if (tokens.refreshToken) {
1265
+ await revokeOidcToken(revocationEndpoint, safeClientId, tokens.refreshToken, "refresh_token", cleanup.signal);
1266
+ }
1267
+ }
1268
+ catch {
1269
+ cleanup.close();
1270
+ throw new StorageError("OIDC token persistence failed and session cleanup was incomplete");
1271
+ }
1272
+ cleanup.close();
1273
+ }
1274
+ throw new StorageError("OIDC token persistence failed; discard the refreshed session");
1275
+ }
1276
+ ensureOidcNotCancelled(signal, "token refresh");
218
1277
  return tokens;
219
1278
  };
220
1279
  }