@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/README.md +54 -9
- package/dist/TeleCryptIOStorage.d.ts +86 -45
- package/dist/TeleCryptIOStorage.js +1418 -230
- package/dist/core/constants.d.ts +16 -0
- package/dist/core/constants.js +54 -0
- package/dist/core/errors.d.ts +66 -0
- package/dist/core/errors.js +108 -0
- package/dist/core/http.d.ts +24 -0
- package/dist/core/http.js +199 -0
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.js +1 -0
- package/dist/core/oidc.d.ts +47 -69
- package/dist/core/oidc.js +1196 -137
- package/dist/core/operations.d.ts +31 -32
- package/dist/core/operations.js +1270 -219
- package/dist/core/poll.d.ts +2 -1
- package/dist/core/poll.js +59 -3
- package/dist/core/types.d.ts +14 -0
- package/dist/core/validation.d.ts +3 -0
- package/dist/core/validation.js +10 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/package.json +11 -9
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):
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
|
51
|
-
//
|
|
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
|
|
708
|
+
* `generateScope(deviceId)` embeds it in the requested stable Matrix OAuth
|
|
709
|
+
* scope.
|
|
58
710
|
*/
|
|
59
|
-
export async function startDeviceCodeLogin(authMetadata, clientId, deviceId) {
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
|
78
|
-
//
|
|
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.
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
|
137
|
-
*
|
|
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
|
-
|
|
142
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
|
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
|
|
163
|
-
*
|
|
164
|
-
*
|
|
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
|
-
|
|
167
|
-
const
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
1174
|
+
client_id: safeClientId,
|
|
1175
|
+
token: safeToken,
|
|
1176
|
+
token_type_hint: tokenTypeHint,
|
|
174
1177
|
}).toString(),
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
196
|
-
(
|
|
197
|
-
|
|
1226
|
+
finally {
|
|
1227
|
+
clearTimeout(timeout);
|
|
1228
|
+
externalSignal?.removeEventListener("abort", abortExternal);
|
|
198
1229
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
|
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(
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
}
|