mcp-compose 0.3.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +93 -6
- package/dist/bin/mcp-compose.js +91 -41
- package/dist/bin/mcp-compose.js.map +1 -1
- package/dist/package.json +16 -4
- package/dist/src/config.d.ts +4 -1
- package/dist/src/config.d.ts.map +1 -1
- package/dist/src/config.js +23 -3
- package/dist/src/config.js.map +1 -1
- package/dist/src/gateway.d.ts.map +1 -1
- package/dist/src/gateway.js +2059 -208
- package/dist/src/gateway.js.map +1 -1
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +2 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/oauth.d.ts +27 -3
- package/dist/src/oauth.d.ts.map +1 -1
- package/dist/src/oauth.js +557 -78
- package/dist/src/oauth.js.map +1 -1
- package/dist/src/sync-clients.d.ts +5 -0
- package/dist/src/sync-clients.d.ts.map +1 -0
- package/dist/src/sync-clients.js +51 -0
- package/dist/src/sync-clients.js.map +1 -0
- package/dist/src/sync-deepseek.d.ts +16 -0
- package/dist/src/sync-deepseek.d.ts.map +1 -0
- package/dist/src/sync-deepseek.js +209 -0
- package/dist/src/sync-deepseek.js.map +1 -0
- package/dist/src/sync-engine.d.ts +48 -0
- package/dist/src/sync-engine.d.ts.map +1 -0
- package/dist/src/sync-engine.js +104 -0
- package/dist/src/sync-engine.js.map +1 -0
- package/dist/src/sync-file.d.ts +4 -0
- package/dist/src/sync-file.d.ts.map +1 -0
- package/dist/src/sync-file.js +61 -0
- package/dist/src/sync-file.js.map +1 -0
- package/dist/src/sync-state.d.ts +10 -0
- package/dist/src/sync-state.d.ts.map +1 -0
- package/dist/src/sync-state.js +50 -0
- package/dist/src/sync-state.js.map +1 -0
- package/dist/src/sync-targets.d.ts +11 -0
- package/dist/src/sync-targets.d.ts.map +1 -0
- package/dist/src/sync-targets.js +30 -0
- package/dist/src/sync-targets.js.map +1 -0
- package/dist/src/sync.d.ts +5 -4
- package/dist/src/sync.d.ts.map +1 -1
- package/dist/src/sync.js +15 -165
- package/dist/src/sync.js.map +1 -1
- package/dist/src/types.d.ts +28 -1
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/validation.d.ts.map +1 -1
- package/dist/src/validation.js +38 -0
- package/dist/src/validation.js.map +1 -1
- package/package.json +16 -4
package/dist/src/oauth.js
CHANGED
|
@@ -3,11 +3,13 @@ import { createHash, randomBytes } from 'node:crypto';
|
|
|
3
3
|
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
|
-
import {
|
|
6
|
+
import { spawn } from 'node:child_process';
|
|
7
7
|
// --- Constants ---
|
|
8
8
|
const CONFIG_DIR = join(homedir(), '.mcp-auth', 'mcp-compose');
|
|
9
9
|
const DEFAULT_AUTH_TIMEOUT = 120_000;
|
|
10
|
-
const MCP_PROTOCOL_VERSION = '
|
|
10
|
+
const MCP_PROTOCOL_VERSION = '2026-07-28';
|
|
11
|
+
/** OAuth metadata and token endpoints are remote input. Never buffer them without a limit. */
|
|
12
|
+
const MAX_OAUTH_RESPONSE_BODY_BYTES = 1024 * 1024;
|
|
11
13
|
const CLIENT_NAME = 'mcp-compose';
|
|
12
14
|
// --- Helpers ---
|
|
13
15
|
function serverHash(url) {
|
|
@@ -16,27 +18,242 @@ function serverHash(url) {
|
|
|
16
18
|
function generateCodeVerifier() {
|
|
17
19
|
return randomBytes(32).toString('base64url');
|
|
18
20
|
}
|
|
21
|
+
function generateState() {
|
|
22
|
+
return randomBytes(32).toString('base64url');
|
|
23
|
+
}
|
|
19
24
|
function generateCodeChallenge(verifier) {
|
|
20
25
|
return createHash('sha256').update(verifier).digest('base64url');
|
|
21
26
|
}
|
|
27
|
+
function normalizeResourceUrl(serverUrl) {
|
|
28
|
+
const url = new URL(serverUrl);
|
|
29
|
+
url.search = '';
|
|
30
|
+
url.hash = '';
|
|
31
|
+
return url.toString();
|
|
32
|
+
}
|
|
33
|
+
function validateAuthorizationServerIssuer(value) {
|
|
34
|
+
if (typeof value !== 'string')
|
|
35
|
+
throw new Error('OAuth protected-resource metadata has an invalid authorization server');
|
|
36
|
+
let url;
|
|
37
|
+
try {
|
|
38
|
+
url = new URL(value);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
throw new Error('OAuth protected-resource metadata has an invalid authorization server');
|
|
42
|
+
}
|
|
43
|
+
if (!['http:', 'https:'].includes(url.protocol)
|
|
44
|
+
|| url.username || url.password || url.hash || url.search) {
|
|
45
|
+
throw new Error('OAuth protected-resource metadata has an invalid authorization server');
|
|
46
|
+
}
|
|
47
|
+
if (url.protocol === 'http:' && !isLiteralLoopbackHost(url.hostname)) {
|
|
48
|
+
throw new Error('OAuth authorization server must use HTTPS outside loopback');
|
|
49
|
+
}
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
function isLiteralLoopbackHost(hostname) {
|
|
53
|
+
const host = hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
54
|
+
return host === 'localhost' || host === '::1' || /^127(?:\.\d{1,3}){3}$/.test(host);
|
|
55
|
+
}
|
|
56
|
+
function validateOAuthEndpoint(value, label) {
|
|
57
|
+
let url;
|
|
58
|
+
try {
|
|
59
|
+
url = new URL(value);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
throw new Error(`OAuth ${label} endpoint is invalid`);
|
|
63
|
+
}
|
|
64
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash
|
|
65
|
+
|| (url.protocol === 'http:' && !isLiteralLoopbackHost(url.hostname))) {
|
|
66
|
+
throw new Error(`OAuth ${label} endpoint must use HTTPS outside loopback`);
|
|
67
|
+
}
|
|
68
|
+
return url.toString();
|
|
69
|
+
}
|
|
22
70
|
async function ensureConfigDir() {
|
|
23
71
|
await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
24
72
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
73
|
+
const defaultBrowserLauncher = (command, args, options) => spawn(command, args, options);
|
|
74
|
+
/** Opens an authorization URL as a literal process argument, never through a shell. */
|
|
75
|
+
export function openBrowser(url, launchBrowser = defaultBrowserLauncher) {
|
|
76
|
+
let command;
|
|
77
|
+
let args;
|
|
78
|
+
if (process.platform === 'darwin') {
|
|
79
|
+
command = 'open';
|
|
80
|
+
args = [url];
|
|
81
|
+
}
|
|
82
|
+
else if (process.platform === 'win32') {
|
|
83
|
+
// `start` is a cmd.exe built-in. rundll32 accepts the URL as a direct
|
|
84
|
+
// argument, so URL syntax cannot become shell syntax on Windows.
|
|
85
|
+
command = 'rundll32.exe';
|
|
86
|
+
args = ['url.dll,FileProtocolHandler', url];
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
command = 'xdg-open';
|
|
90
|
+
args = [url];
|
|
91
|
+
}
|
|
92
|
+
const child = launchBrowser(command, args, { detached: true, stdio: 'ignore', windowsHide: true });
|
|
93
|
+
child.once('error', () => {
|
|
94
|
+
process.stderr.write(`Please open this URL in your browser:\n${url}\n`);
|
|
33
95
|
});
|
|
96
|
+
child.unref();
|
|
34
97
|
}
|
|
35
|
-
function
|
|
98
|
+
function normalizeScope(scope) {
|
|
99
|
+
if (!scope)
|
|
100
|
+
return undefined;
|
|
101
|
+
const tokens = scope.trim().split(/\s+/);
|
|
102
|
+
return tokens.length > 0 && tokens.every((token) => /^[\x21\x23-\x5B\x5D-\x7E]+$/.test(token))
|
|
103
|
+
? tokens.join(' ')
|
|
104
|
+
: undefined;
|
|
105
|
+
}
|
|
106
|
+
function scopeTokens(scope) {
|
|
107
|
+
return new Set(normalizeScope(scope)?.split(' ') ?? []);
|
|
108
|
+
}
|
|
109
|
+
function isScopeSubset(scope, allowedScope) {
|
|
110
|
+
const requested = scopeTokens(scope);
|
|
111
|
+
const allowed = scopeTokens(allowedScope);
|
|
112
|
+
return requested.size === 0 || (allowed.size > 0 && [...requested].every((token) => allowed.has(token)));
|
|
113
|
+
}
|
|
114
|
+
function unionScopes(...scopes) {
|
|
115
|
+
const tokens = new Set();
|
|
116
|
+
for (const scope of scopes) {
|
|
117
|
+
for (const token of scopeTokens(scope))
|
|
118
|
+
tokens.add(token);
|
|
119
|
+
}
|
|
120
|
+
return tokens.size > 0 ? [...tokens].join(' ') : undefined;
|
|
121
|
+
}
|
|
122
|
+
function metadataScopes(scope) {
|
|
123
|
+
return Array.isArray(scope) ? normalizeScope(scope.filter((value) => typeof value === 'string').join(' ')) : undefined;
|
|
124
|
+
}
|
|
125
|
+
/** Parse Bearer auth-params without mistaking quoted commas or escaped quotes for challenge boundaries. */
|
|
126
|
+
function parseBearerChallenge(header) {
|
|
36
127
|
if (!header)
|
|
37
128
|
return undefined;
|
|
38
|
-
|
|
39
|
-
|
|
129
|
+
let position = 0;
|
|
130
|
+
let firstBearer;
|
|
131
|
+
const readToken = () => {
|
|
132
|
+
const start = position;
|
|
133
|
+
while (position < header.length && /[!#$%&'*+.^_`|~0-9A-Za-z-]/.test(header[position] ?? ''))
|
|
134
|
+
position += 1;
|
|
135
|
+
return position > start ? header.slice(start, position) : undefined;
|
|
136
|
+
};
|
|
137
|
+
const skipWhitespace = () => {
|
|
138
|
+
while (position < header.length && /[ \t]/.test(header[position] ?? ''))
|
|
139
|
+
position += 1;
|
|
140
|
+
};
|
|
141
|
+
const readValue = () => {
|
|
142
|
+
if (header[position] !== '"')
|
|
143
|
+
return readToken();
|
|
144
|
+
position += 1;
|
|
145
|
+
let value = '';
|
|
146
|
+
while (position < header.length) {
|
|
147
|
+
const char = header[position++] ?? '';
|
|
148
|
+
if (char === '"')
|
|
149
|
+
return value;
|
|
150
|
+
if (char === '\\' && position < header.length)
|
|
151
|
+
value += header[position++] ?? '';
|
|
152
|
+
else
|
|
153
|
+
value += char;
|
|
154
|
+
}
|
|
155
|
+
return undefined;
|
|
156
|
+
};
|
|
157
|
+
while (position < header.length) {
|
|
158
|
+
while (header[position] === ',' || /[ \t]/.test(header[position] ?? ''))
|
|
159
|
+
position += 1;
|
|
160
|
+
const scheme = readToken();
|
|
161
|
+
if (!scheme)
|
|
162
|
+
break;
|
|
163
|
+
skipWhitespace();
|
|
164
|
+
const params = new Map();
|
|
165
|
+
while (position < header.length) {
|
|
166
|
+
const saved = position;
|
|
167
|
+
const name = readToken();
|
|
168
|
+
skipWhitespace();
|
|
169
|
+
if (!name || header[position] !== '=') {
|
|
170
|
+
position = saved;
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
position += 1;
|
|
174
|
+
skipWhitespace();
|
|
175
|
+
const value = readValue();
|
|
176
|
+
if (value === undefined)
|
|
177
|
+
break;
|
|
178
|
+
params.set(name.toLowerCase(), value);
|
|
179
|
+
skipWhitespace();
|
|
180
|
+
if (header[position] !== ',')
|
|
181
|
+
break;
|
|
182
|
+
position += 1;
|
|
183
|
+
const afterComma = position;
|
|
184
|
+
skipWhitespace();
|
|
185
|
+
readToken();
|
|
186
|
+
skipWhitespace();
|
|
187
|
+
const isNextParam = header[position] === '=';
|
|
188
|
+
position = afterComma;
|
|
189
|
+
if (!isNextParam)
|
|
190
|
+
break;
|
|
191
|
+
skipWhitespace();
|
|
192
|
+
}
|
|
193
|
+
if (scheme.toLowerCase() === 'bearer') {
|
|
194
|
+
firstBearer ??= params;
|
|
195
|
+
if (params.has('scope') || params.has('resource_metadata'))
|
|
196
|
+
return params;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return firstBearer;
|
|
200
|
+
}
|
|
201
|
+
function parseWwwAuthenticateScope(header) {
|
|
202
|
+
return normalizeScope(parseBearerChallenge(header)?.get('scope'));
|
|
203
|
+
}
|
|
204
|
+
function challengeMetadataUrl(header, serverUrl) {
|
|
205
|
+
const raw = parseBearerChallenge(header)?.get('resource_metadata');
|
|
206
|
+
if (!raw)
|
|
207
|
+
return undefined;
|
|
208
|
+
try {
|
|
209
|
+
const expected = new URL(serverUrl);
|
|
210
|
+
const metadata = new URL(raw);
|
|
211
|
+
if (metadata.origin !== expected.origin
|
|
212
|
+
|| !['http:', 'https:'].includes(metadata.protocol)
|
|
213
|
+
|| metadata.username || metadata.password || metadata.hash)
|
|
214
|
+
return undefined;
|
|
215
|
+
return metadata.toString();
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
function abortError(signal) {
|
|
222
|
+
return signal.reason instanceof Error ? signal.reason : new Error('OAuth operation aborted');
|
|
223
|
+
}
|
|
224
|
+
function throwIfAborted(signal) {
|
|
225
|
+
if (signal.aborted)
|
|
226
|
+
throw abortError(signal);
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Use one deadline for the whole operation, rather than giving each fallback
|
|
230
|
+
* endpoint a fresh timeout. This prevents a discovery chain from exceeding
|
|
231
|
+
* the lifetime of its gateway request.
|
|
232
|
+
*/
|
|
233
|
+
function createBoundedOperation(timeout, callerSignal) {
|
|
234
|
+
const controller = new AbortController();
|
|
235
|
+
const abortFromCaller = () => { controller.abort(callerSignal?.reason); };
|
|
236
|
+
if (callerSignal?.aborted)
|
|
237
|
+
abortFromCaller();
|
|
238
|
+
else
|
|
239
|
+
callerSignal?.addEventListener('abort', abortFromCaller, { once: true });
|
|
240
|
+
const timer = setTimeout(() => { controller.abort(new Error('OAuth operation timed out')); }, timeout);
|
|
241
|
+
timer.unref();
|
|
242
|
+
return {
|
|
243
|
+
signal: controller.signal,
|
|
244
|
+
dispose: () => {
|
|
245
|
+
clearTimeout(timer);
|
|
246
|
+
callerSignal?.removeEventListener('abort', abortFromCaller);
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
async function awaitWithSignal(promise, signal) {
|
|
251
|
+
throwIfAborted(signal);
|
|
252
|
+
return await new Promise((resolve, reject) => {
|
|
253
|
+
const onAbort = () => { reject(abortError(signal)); };
|
|
254
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
255
|
+
promise.then(resolve, reject).finally(() => { signal.removeEventListener('abort', onAbort); });
|
|
256
|
+
});
|
|
40
257
|
}
|
|
41
258
|
function listenOnRandomPort(server) {
|
|
42
259
|
return new Promise((resolve) => {
|
|
@@ -60,24 +277,132 @@ async function saveStoredAuth(hash, auth) {
|
|
|
60
277
|
await writeFile(join(CONFIG_DIR, `${hash}.json`), JSON.stringify(auth, null, 2), { mode: 0o600 });
|
|
61
278
|
}
|
|
62
279
|
// --- Discovery ---
|
|
63
|
-
|
|
280
|
+
class OAuthResponseTooLargeError extends Error {
|
|
281
|
+
constructor() {
|
|
282
|
+
super(`OAuth response body exceeds ${String(MAX_OAUTH_RESPONSE_BODY_BYTES)} bytes`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
async function readBoundedResponseText(res) {
|
|
286
|
+
const body = res.body;
|
|
287
|
+
if (!body)
|
|
288
|
+
return '';
|
|
289
|
+
const contentLength = res.headers.get('content-length');
|
|
290
|
+
if (contentLength && /^\d+$/.test(contentLength) && Number(contentLength) > MAX_OAUTH_RESPONSE_BODY_BYTES) {
|
|
291
|
+
await body.cancel();
|
|
292
|
+
throw new OAuthResponseTooLargeError();
|
|
293
|
+
}
|
|
294
|
+
const reader = body.getReader();
|
|
295
|
+
const chunks = [];
|
|
296
|
+
let byteLength = 0;
|
|
64
297
|
try {
|
|
65
|
-
|
|
298
|
+
for (;;) {
|
|
299
|
+
const result = await reader.read();
|
|
300
|
+
if (result.done)
|
|
301
|
+
break;
|
|
302
|
+
const value = result.value;
|
|
303
|
+
byteLength += value.byteLength;
|
|
304
|
+
if (byteLength > MAX_OAUTH_RESPONSE_BODY_BYTES) {
|
|
305
|
+
await reader.cancel();
|
|
306
|
+
throw new OAuthResponseTooLargeError();
|
|
307
|
+
}
|
|
308
|
+
chunks.push(value);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
finally {
|
|
312
|
+
reader.releaseLock();
|
|
313
|
+
}
|
|
314
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
315
|
+
}
|
|
316
|
+
async function readBoundedJson(res) {
|
|
317
|
+
return JSON.parse(await readBoundedResponseText(res));
|
|
318
|
+
}
|
|
319
|
+
/** Consume an ignored error response up to the same limit, then release its socket. */
|
|
320
|
+
async function discardBoundedResponseBody(res) {
|
|
321
|
+
const body = res.body;
|
|
322
|
+
if (!body)
|
|
323
|
+
return;
|
|
324
|
+
const contentLength = res.headers.get('content-length');
|
|
325
|
+
if (contentLength && /^\d+$/.test(contentLength) && Number(contentLength) > MAX_OAUTH_RESPONSE_BODY_BYTES) {
|
|
326
|
+
await body.cancel();
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
const reader = body.getReader();
|
|
330
|
+
let byteLength = 0;
|
|
331
|
+
try {
|
|
332
|
+
for (;;) {
|
|
333
|
+
const result = await reader.read();
|
|
334
|
+
if (result.done)
|
|
335
|
+
return;
|
|
336
|
+
byteLength += result.value.byteLength;
|
|
337
|
+
if (byteLength > MAX_OAUTH_RESPONSE_BODY_BYTES) {
|
|
338
|
+
await reader.cancel();
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
finally {
|
|
344
|
+
reader.releaseLock();
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
async function fetchJson(url, signal, headers) {
|
|
348
|
+
try {
|
|
349
|
+
throwIfAborted(signal);
|
|
350
|
+
const res = headers
|
|
351
|
+
? await fetch(url, { headers, signal, redirect: 'error' })
|
|
352
|
+
: await fetch(url, { signal, redirect: 'error' });
|
|
66
353
|
if (res.ok)
|
|
67
|
-
return await res
|
|
354
|
+
return await readBoundedJson(res);
|
|
355
|
+
await discardBoundedResponseBody(res);
|
|
68
356
|
}
|
|
69
|
-
catch {
|
|
357
|
+
catch (error) {
|
|
358
|
+
throwIfAborted(signal);
|
|
359
|
+
if (error instanceof OAuthResponseTooLargeError)
|
|
360
|
+
throw error;
|
|
70
361
|
// ignore network errors, continue
|
|
71
362
|
}
|
|
72
363
|
return undefined;
|
|
73
364
|
}
|
|
74
|
-
async function discoverProtectedResource(serverUrl, headers) {
|
|
75
|
-
const
|
|
76
|
-
const fetchHeaders =
|
|
77
|
-
|
|
78
|
-
|
|
365
|
+
async function discoverProtectedResource(serverUrl, signal, headers, challengedMetadataUrl) {
|
|
366
|
+
const resource = normalizeResourceUrl(serverUrl);
|
|
367
|
+
const fetchHeaders = Object.fromEntries(Object.entries(headers ?? {})
|
|
368
|
+
.filter(([name]) => name.toLowerCase() !== 'mcp-protocol-version'));
|
|
369
|
+
// Headers are case-insensitive on the wire. Set this after user headers so
|
|
370
|
+
// discovery always carries the protocol version this client implements.
|
|
371
|
+
fetchHeaders['MCP-Protocol-Version'] = MCP_PROTOCOL_VERSION;
|
|
372
|
+
if (challengedMetadataUrl) {
|
|
373
|
+
const metadata = await fetchJson(challengedMetadataUrl, signal, fetchHeaders);
|
|
374
|
+
if (metadata)
|
|
375
|
+
return validateProtectedResourceMetadata(metadata, resource, challengedMetadataUrl, 'resource_metadata');
|
|
376
|
+
}
|
|
377
|
+
// Path-aware URL first, then root fallback (RFC 9728).
|
|
378
|
+
for (const metadataUrl of protectedResourceMetadataUrls(resource)) {
|
|
379
|
+
const metadata = await fetchJson(metadataUrl, signal, fetchHeaders);
|
|
380
|
+
if (metadata)
|
|
381
|
+
return validateProtectedResourceMetadata(metadata, resource, metadataUrl, 'discovery');
|
|
382
|
+
}
|
|
383
|
+
return undefined;
|
|
384
|
+
}
|
|
385
|
+
function protectedResourceMetadataUrl(resource) {
|
|
386
|
+
const url = new URL(resource);
|
|
387
|
+
return new URL(`/.well-known/oauth-protected-resource${url.pathname}`, url.origin).toString();
|
|
388
|
+
}
|
|
389
|
+
function protectedResourceMetadataUrls(resource) {
|
|
390
|
+
const url = new URL(resource);
|
|
391
|
+
return [...new Set([
|
|
392
|
+
protectedResourceMetadataUrl(resource),
|
|
393
|
+
new URL('/.well-known/oauth-protected-resource', url.origin).toString(),
|
|
394
|
+
])];
|
|
79
395
|
}
|
|
80
|
-
|
|
396
|
+
/** RFC 9728 §3.3 requires exact string identity, after JSON decoding, not URL normalization. */
|
|
397
|
+
function validateProtectedResourceMetadata(metadata, resource, metadataUrl, source) {
|
|
398
|
+
if (metadata.resource !== resource
|
|
399
|
+
|| (source === 'discovery' && !protectedResourceMetadataUrls(resource).includes(metadataUrl))) {
|
|
400
|
+
const label = source === 'resource_metadata' ? 'resource_metadata' : 'protected-resource metadata';
|
|
401
|
+
throw new Error(`OAuth ${label} does not bind to its protected resource`);
|
|
402
|
+
}
|
|
403
|
+
return metadata;
|
|
404
|
+
}
|
|
405
|
+
async function discoverAuthServer(authServerUrl, signal) {
|
|
81
406
|
const url = new URL(authServerUrl);
|
|
82
407
|
const hasPath = url.pathname !== '/' && url.pathname !== '';
|
|
83
408
|
// RFC 8414 + OIDC Discovery URLs in priority order
|
|
@@ -90,16 +415,20 @@ async function discoverAuthServer(authServerUrl) {
|
|
|
90
415
|
new URL('/.well-known/openid-configuration', url.origin),
|
|
91
416
|
];
|
|
92
417
|
for (const discoveryUrl of urls) {
|
|
93
|
-
const meta = await fetchJson(discoveryUrl.toString());
|
|
94
|
-
if (meta)
|
|
418
|
+
const meta = await fetchJson(discoveryUrl.toString(), signal);
|
|
419
|
+
if (meta) {
|
|
420
|
+
if (typeof meta.issuer !== 'string' || meta.issuer !== authServerUrl) {
|
|
421
|
+
throw new Error('OAuth authorization-server metadata issuer is not bound to its server');
|
|
422
|
+
}
|
|
95
423
|
return meta;
|
|
424
|
+
}
|
|
96
425
|
}
|
|
97
426
|
return undefined;
|
|
98
427
|
}
|
|
99
428
|
// --- Registration (RFC 7591) ---
|
|
100
|
-
async function registerClient(authServerUrl, metadata, redirectUri) {
|
|
101
|
-
const endpoint = metadata?.registration_endpoint
|
|
102
|
-
?? new URL('/register', authServerUrl).toString();
|
|
429
|
+
async function registerClient(authServerUrl, metadata, redirectUri, signal) {
|
|
430
|
+
const endpoint = validateOAuthEndpoint(metadata?.registration_endpoint
|
|
431
|
+
?? new URL('/register', authServerUrl).toString(), 'registration');
|
|
103
432
|
const res = await fetch(endpoint, {
|
|
104
433
|
method: 'POST',
|
|
105
434
|
headers: { 'Content-Type': 'application/json' },
|
|
@@ -109,12 +438,16 @@ async function registerClient(authServerUrl, metadata, redirectUri) {
|
|
|
109
438
|
grant_types: ['authorization_code', 'refresh_token'],
|
|
110
439
|
response_types: ['code'],
|
|
111
440
|
token_endpoint_auth_method: 'none',
|
|
441
|
+
application_type: 'native',
|
|
112
442
|
}),
|
|
443
|
+
signal,
|
|
444
|
+
redirect: 'error',
|
|
113
445
|
});
|
|
114
446
|
if (!res.ok) {
|
|
115
|
-
|
|
447
|
+
await readBoundedResponseText(res);
|
|
448
|
+
throw new Error(`Client registration failed with HTTP ${String(res.status)}`);
|
|
116
449
|
}
|
|
117
|
-
const info = await res
|
|
450
|
+
const info = await readBoundedJson(res);
|
|
118
451
|
info.redirect_uris = [redirectUri];
|
|
119
452
|
return info;
|
|
120
453
|
}
|
|
@@ -129,56 +462,92 @@ function buildClientAuthHeaders(clientInfo) {
|
|
|
129
462
|
}
|
|
130
463
|
return headers;
|
|
131
464
|
}
|
|
132
|
-
|
|
133
|
-
|
|
465
|
+
function requireBearerToken(tokens) {
|
|
466
|
+
if (tokens.token_type.toLowerCase() !== 'bearer') {
|
|
467
|
+
throw new Error(`Unsupported OAuth token type: ${tokens.token_type}`);
|
|
468
|
+
}
|
|
469
|
+
return tokens;
|
|
470
|
+
}
|
|
471
|
+
async function exchangeCodeForTokens(authServerUrl, metadata, clientInfo, code, codeVerifier, redirectUri, resource, signal) {
|
|
472
|
+
const tokenUrl = validateOAuthEndpoint(metadata?.token_endpoint ?? new URL('/token', authServerUrl).toString(), 'token');
|
|
134
473
|
const params = new URLSearchParams({
|
|
135
474
|
grant_type: 'authorization_code',
|
|
136
475
|
code,
|
|
137
476
|
code_verifier: codeVerifier,
|
|
138
477
|
redirect_uri: redirectUri,
|
|
139
478
|
client_id: clientInfo.client_id,
|
|
479
|
+
resource,
|
|
140
480
|
});
|
|
141
481
|
const res = await fetch(tokenUrl, {
|
|
142
482
|
method: 'POST',
|
|
143
483
|
headers: buildClientAuthHeaders(clientInfo),
|
|
144
484
|
body: params.toString(),
|
|
485
|
+
signal,
|
|
486
|
+
redirect: 'error',
|
|
145
487
|
});
|
|
146
488
|
if (!res.ok) {
|
|
147
|
-
|
|
489
|
+
await readBoundedResponseText(res);
|
|
490
|
+
throw new Error(`Token exchange failed with HTTP ${String(res.status)}`);
|
|
148
491
|
}
|
|
149
|
-
return await res
|
|
492
|
+
return requireBearerToken(await readBoundedJson(res));
|
|
150
493
|
}
|
|
151
|
-
async function refreshAccessToken(authServerUrl, metadata, clientInfo, refreshToken) {
|
|
152
|
-
const tokenUrl = metadata?.token_endpoint ?? new URL('/token', authServerUrl).toString();
|
|
494
|
+
async function refreshAccessToken(authServerUrl, metadata, clientInfo, refreshToken, resource, scope, originalScope, signal) {
|
|
495
|
+
const tokenUrl = validateOAuthEndpoint(metadata?.token_endpoint ?? new URL('/token', authServerUrl).toString(), 'token');
|
|
153
496
|
const params = new URLSearchParams({
|
|
154
497
|
grant_type: 'refresh_token',
|
|
155
498
|
refresh_token: refreshToken,
|
|
156
499
|
client_id: clientInfo.client_id,
|
|
500
|
+
resource,
|
|
157
501
|
});
|
|
502
|
+
if (scope)
|
|
503
|
+
params.set('scope', scope);
|
|
158
504
|
try {
|
|
159
505
|
const res = await fetch(tokenUrl, {
|
|
160
506
|
method: 'POST',
|
|
161
507
|
headers: buildClientAuthHeaders(clientInfo),
|
|
162
508
|
body: params.toString(),
|
|
509
|
+
signal,
|
|
510
|
+
redirect: 'error',
|
|
163
511
|
});
|
|
164
|
-
if (!res.ok)
|
|
512
|
+
if (!res.ok) {
|
|
513
|
+
await discardBoundedResponseBody(res);
|
|
514
|
+
return undefined;
|
|
515
|
+
}
|
|
516
|
+
const tokens = requireBearerToken(await readBoundedJson(res));
|
|
517
|
+
// RFC 6749 permits scope omission on refresh. Keep the known grant, and
|
|
518
|
+
// reject a response that claims a broader grant than the original token.
|
|
519
|
+
if (tokens.scope && originalScope && !isScopeSubset(tokens.scope, originalScope))
|
|
165
520
|
return undefined;
|
|
166
|
-
const
|
|
521
|
+
const effectiveScope = scope ?? originalScope;
|
|
522
|
+
if (!tokens.scope && effectiveScope)
|
|
523
|
+
tokens.scope = effectiveScope;
|
|
167
524
|
// Preserve original refresh token if server doesn't return a new one
|
|
168
525
|
tokens.refresh_token ??= refreshToken;
|
|
169
526
|
return tokens;
|
|
170
527
|
}
|
|
171
528
|
catch {
|
|
529
|
+
throwIfAborted(signal);
|
|
172
530
|
return undefined;
|
|
173
531
|
}
|
|
174
532
|
}
|
|
175
533
|
// --- Callback Server ---
|
|
176
|
-
function waitForOAuthCallback(server, port,
|
|
534
|
+
function waitForOAuthCallback(server, port, expectedState, expectedIssuer, issuerRequired, signal) {
|
|
177
535
|
return new Promise((resolve, reject) => {
|
|
178
|
-
|
|
536
|
+
let settled = false;
|
|
537
|
+
const finish = (callback) => {
|
|
538
|
+
if (settled)
|
|
539
|
+
return;
|
|
540
|
+
settled = true;
|
|
541
|
+
signal.removeEventListener('abort', onAbort);
|
|
179
542
|
server.close();
|
|
180
|
-
|
|
181
|
-
}
|
|
543
|
+
callback();
|
|
544
|
+
};
|
|
545
|
+
const onAbort = () => { finish(() => { reject(abortError(signal)); }); };
|
|
546
|
+
if (signal.aborted) {
|
|
547
|
+
onAbort();
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
182
551
|
server.on('request', (req, res) => {
|
|
183
552
|
const reqUrl = new URL(req.url ?? '/', `http://127.0.0.1:${String(port)}`);
|
|
184
553
|
if (reqUrl.pathname !== '/oauth/callback') {
|
|
@@ -188,21 +557,39 @@ function waitForOAuthCallback(server, port, timeout) {
|
|
|
188
557
|
}
|
|
189
558
|
const code = reqUrl.searchParams.get('code');
|
|
190
559
|
const error = reqUrl.searchParams.get('error');
|
|
560
|
+
const state = reqUrl.searchParams.get('state');
|
|
561
|
+
const issuer = reqUrl.searchParams.get('iss');
|
|
562
|
+
if (state !== expectedState) {
|
|
563
|
+
finish(() => {
|
|
564
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
565
|
+
res.end('<html><body><h1>Authorization failed</h1><p>Invalid state</p></body></html>');
|
|
566
|
+
reject(new Error('OAuth state validation failed'));
|
|
567
|
+
});
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
if ((issuer === null && issuerRequired) || (issuer !== null && issuer !== expectedIssuer)) {
|
|
571
|
+
finish(() => {
|
|
572
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
573
|
+
res.end('<html><body><h1>Authorization failed</h1><p>Invalid issuer</p></body></html>');
|
|
574
|
+
reject(new Error('OAuth issuer validation failed'));
|
|
575
|
+
});
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
191
578
|
if (code) {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
579
|
+
finish(() => {
|
|
580
|
+
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
581
|
+
res.end('<html><body><h1>Authorization successful</h1><p>You can close this tab.</p></body></html>');
|
|
582
|
+
resolve(code);
|
|
583
|
+
});
|
|
197
584
|
}
|
|
198
585
|
else {
|
|
199
|
-
clearTimeout(timer);
|
|
200
586
|
const msg = (error ?? reqUrl.searchParams.get('error_description') ?? 'Unknown error')
|
|
201
587
|
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
588
|
+
finish(() => {
|
|
589
|
+
res.writeHead(400, { 'Content-Type': 'text/html' });
|
|
590
|
+
res.end(`<html><body><h1>Authorization failed</h1><p>${msg}</p></body></html>`);
|
|
591
|
+
reject(new Error(`OAuth error: ${msg}`));
|
|
592
|
+
});
|
|
206
593
|
}
|
|
207
594
|
});
|
|
208
595
|
});
|
|
@@ -216,31 +603,55 @@ export class OAuthClient {
|
|
|
216
603
|
stored;
|
|
217
604
|
authServerUrl;
|
|
218
605
|
authServerMetadata;
|
|
606
|
+
resource;
|
|
607
|
+
protectedResourceScope;
|
|
219
608
|
/** Deduplicates concurrent handleUnauthorized() calls into a single auth flow. */
|
|
220
609
|
pendingAuth = null;
|
|
221
610
|
constructor(options) {
|
|
222
611
|
this.serverUrl = options.serverUrl;
|
|
223
612
|
this.headers = options.headers ?? {};
|
|
224
613
|
this.authTimeout = options.authTimeout ?? DEFAULT_AUTH_TIMEOUT;
|
|
614
|
+
if (!Number.isFinite(this.authTimeout) || this.authTimeout <= 0) {
|
|
615
|
+
throw new Error('OAuth authTimeout must be a positive finite number');
|
|
616
|
+
}
|
|
225
617
|
this.hash = serverHash(options.serverUrl);
|
|
226
618
|
}
|
|
619
|
+
async runOperation(options, operation) {
|
|
620
|
+
const bounded = createBoundedOperation(this.authTimeout, options?.signal);
|
|
621
|
+
try {
|
|
622
|
+
return await operation(bounded.signal);
|
|
623
|
+
}
|
|
624
|
+
finally {
|
|
625
|
+
bounded.dispose();
|
|
626
|
+
}
|
|
627
|
+
}
|
|
227
628
|
/**
|
|
228
|
-
* Get a
|
|
629
|
+
* Get a cached access token only after binding it to the authorization
|
|
630
|
+
* server currently discovered for this protected resource.
|
|
229
631
|
*/
|
|
230
|
-
async getAccessToken() {
|
|
231
|
-
this.
|
|
232
|
-
|
|
632
|
+
async getAccessToken(options) {
|
|
633
|
+
return await this.runOperation(options, async (signal) => {
|
|
634
|
+
this.stored ??= await loadStoredAuth(this.hash);
|
|
635
|
+
const storedTokens = this.stored?.tokens;
|
|
636
|
+
if (!storedTokens?.access_token || typeof storedTokens.token_type !== 'string'
|
|
637
|
+
|| storedTokens.token_type.toLowerCase() !== 'bearer')
|
|
638
|
+
return undefined;
|
|
639
|
+
await this.discover(signal);
|
|
640
|
+
const issuer = this.authServerMetadata?.issuer;
|
|
641
|
+
return issuer !== undefined && this.stored?.issuer === issuer ? storedTokens.access_token : undefined;
|
|
642
|
+
});
|
|
233
643
|
}
|
|
234
644
|
/**
|
|
235
645
|
* Handle a 401 response from the remote server.
|
|
236
646
|
* Deduplicates concurrent calls — only one auth flow runs at a time;
|
|
237
647
|
* subsequent callers wait for the same result.
|
|
238
648
|
*/
|
|
239
|
-
async handleUnauthorized(wwwAuthenticate) {
|
|
240
|
-
|
|
241
|
-
|
|
649
|
+
async handleUnauthorized(wwwAuthenticate, options) {
|
|
650
|
+
const pendingAuth = this.pendingAuth;
|
|
651
|
+
if (pendingAuth) {
|
|
652
|
+
return await this.runOperation(options, async (signal) => await awaitWithSignal(pendingAuth, signal));
|
|
242
653
|
}
|
|
243
|
-
this.pendingAuth = this.executeAuthFlow(wwwAuthenticate);
|
|
654
|
+
this.pendingAuth = this.runOperation(options, async (signal) => await this.executeAuthFlow(wwwAuthenticate, signal));
|
|
244
655
|
try {
|
|
245
656
|
return await this.pendingAuth;
|
|
246
657
|
}
|
|
@@ -248,14 +659,26 @@ export class OAuthClient {
|
|
|
248
659
|
this.pendingAuth = null;
|
|
249
660
|
}
|
|
250
661
|
}
|
|
251
|
-
async executeAuthFlow(wwwAuthenticate) {
|
|
252
|
-
const
|
|
253
|
-
|
|
662
|
+
async executeAuthFlow(wwwAuthenticate, signal) {
|
|
663
|
+
const challengedScope = parseWwwAuthenticateScope(wwwAuthenticate);
|
|
664
|
+
const challengedMetadataUrl = challengeMetadataUrl(wwwAuthenticate, this.serverUrl);
|
|
665
|
+
this.stored ??= await loadStoredAuth(this.hash);
|
|
666
|
+
this.stored ??= {};
|
|
254
667
|
// Discover auth server
|
|
255
|
-
await this.discover();
|
|
668
|
+
await this.discover(signal, challengedMetadataUrl);
|
|
669
|
+
const issuer = this.getAuthServerIssuer();
|
|
670
|
+
const scope = this.effectiveScope(challengedScope, issuer);
|
|
256
671
|
// Try refresh first
|
|
257
|
-
if (this.stored.tokens?.refresh_token
|
|
258
|
-
|
|
672
|
+
if (this.stored.tokens?.refresh_token
|
|
673
|
+
&& this.stored.clientInfo
|
|
674
|
+
&& this.authServerUrl
|
|
675
|
+
&& this.stored.issuer === issuer
|
|
676
|
+
&& this.resource
|
|
677
|
+
&& (!challengedScope || isScopeSubset(challengedScope, this.stored.tokens.scope))) {
|
|
678
|
+
const refreshScope = challengedScope && isScopeSubset(challengedScope, this.stored.tokens.scope)
|
|
679
|
+
? challengedScope
|
|
680
|
+
: undefined;
|
|
681
|
+
const refreshed = await refreshAccessToken(this.authServerUrl, this.authServerMetadata, this.stored.clientInfo, this.stored.tokens.refresh_token, this.resource, refreshScope, this.stored.tokens.scope, signal);
|
|
259
682
|
if (refreshed) {
|
|
260
683
|
this.stored.tokens = refreshed;
|
|
261
684
|
this.stored.tokenSavedAt = new Date().toISOString();
|
|
@@ -267,36 +690,56 @@ export class OAuthClient {
|
|
|
267
690
|
if (!this.authServerUrl) {
|
|
268
691
|
throw new Error('Failed to discover authorization server');
|
|
269
692
|
}
|
|
693
|
+
if (!this.authServerMetadata?.code_challenge_methods_supported?.includes('S256')) {
|
|
694
|
+
throw new Error('OAuth authorization server does not advertise PKCE S256 support');
|
|
695
|
+
}
|
|
270
696
|
// Start callback server on a random port
|
|
271
697
|
const callbackHttpServer = createHttpServer();
|
|
272
698
|
const callbackPort = await listenOnRandomPort(callbackHttpServer);
|
|
273
699
|
const redirectUri = `http://127.0.0.1:${String(callbackPort)}/oauth/callback`;
|
|
274
700
|
try {
|
|
275
|
-
//
|
|
276
|
-
if (
|
|
277
|
-
this.stored.clientInfo
|
|
701
|
+
// Do not reuse a registration that was issued by a different authorization server.
|
|
702
|
+
if (this.stored.issuer !== issuer
|
|
703
|
+
|| !this.stored.clientInfo?.redirect_uris?.includes(redirectUri)) {
|
|
704
|
+
this.stored.clientInfo = await registerClient(this.authServerUrl, this.authServerMetadata, redirectUri, signal);
|
|
278
705
|
}
|
|
279
706
|
// PKCE
|
|
280
707
|
const codeVerifier = generateCodeVerifier();
|
|
281
708
|
const codeChallenge = generateCodeChallenge(codeVerifier);
|
|
709
|
+
const state = generateState();
|
|
282
710
|
// Build authorization URL
|
|
283
|
-
const authEndpoint = this.authServerMetadata
|
|
284
|
-
?? new URL('/authorize', this.authServerUrl).toString();
|
|
711
|
+
const authEndpoint = validateOAuthEndpoint(this.authServerMetadata.authorization_endpoint
|
|
712
|
+
?? new URL('/authorize', this.authServerUrl).toString(), 'authorization');
|
|
285
713
|
const authUrl = new URL(authEndpoint);
|
|
286
714
|
authUrl.searchParams.set('response_type', 'code');
|
|
287
715
|
authUrl.searchParams.set('client_id', this.stored.clientInfo.client_id);
|
|
288
716
|
authUrl.searchParams.set('redirect_uri', redirectUri);
|
|
289
717
|
authUrl.searchParams.set('code_challenge', codeChallenge);
|
|
290
718
|
authUrl.searchParams.set('code_challenge_method', 'S256');
|
|
719
|
+
authUrl.searchParams.set('state', state);
|
|
720
|
+
authUrl.searchParams.set('resource', this.resource ?? normalizeResourceUrl(this.serverUrl));
|
|
291
721
|
if (scope)
|
|
292
722
|
authUrl.searchParams.set('scope', scope);
|
|
293
723
|
// Open browser and wait for callback
|
|
294
724
|
process.stderr.write(`Opening browser for authorization...\n`);
|
|
725
|
+
const callback = waitForOAuthCallback(callbackHttpServer, callbackPort, state, issuer, this.authServerMetadata.authorization_response_iss_parameter_supported === true, signal);
|
|
295
726
|
openBrowser(authUrl.toString());
|
|
296
|
-
const code = await
|
|
727
|
+
const code = await callback;
|
|
297
728
|
// Exchange code for tokens
|
|
298
|
-
const tokens = await exchangeCodeForTokens(this.authServerUrl, this.authServerMetadata, this.stored.clientInfo, code, codeVerifier, redirectUri);
|
|
729
|
+
const tokens = await exchangeCodeForTokens(this.authServerUrl, this.authServerMetadata, this.stored.clientInfo, code, codeVerifier, redirectUri, this.resource ?? normalizeResourceUrl(this.serverUrl), signal);
|
|
730
|
+
// The token response may omit scope when it matches the authorization
|
|
731
|
+
// request. Persist that effective grant rather than losing it.
|
|
732
|
+
if (!tokens.scope && scope)
|
|
733
|
+
tokens.scope = scope;
|
|
734
|
+
if (tokens.scope && scope && !isScopeSubset(tokens.scope, scope)) {
|
|
735
|
+
throw new Error('OAuth token response scope exceeds the authorization request');
|
|
736
|
+
}
|
|
299
737
|
this.stored.tokens = tokens;
|
|
738
|
+
this.stored.issuer = issuer;
|
|
739
|
+
if (scope)
|
|
740
|
+
this.stored.requestedScope = scope;
|
|
741
|
+
else
|
|
742
|
+
delete this.stored.requestedScope;
|
|
300
743
|
this.stored.tokenSavedAt = new Date().toISOString();
|
|
301
744
|
await saveStoredAuth(this.hash, this.stored);
|
|
302
745
|
return tokens.access_token;
|
|
@@ -305,13 +748,49 @@ export class OAuthClient {
|
|
|
305
748
|
callbackHttpServer.close();
|
|
306
749
|
}
|
|
307
750
|
}
|
|
308
|
-
async discover() {
|
|
309
|
-
|
|
751
|
+
async discover(signal, challengedMetadataUrl) {
|
|
752
|
+
// A 401 challenge can name a current protected-resource metadata document
|
|
753
|
+
// after an earlier cached-token lookup populated discovery state. Re-read
|
|
754
|
+
// and validate it so its authorization server and scopes govern this flow.
|
|
755
|
+
if (this.authServerUrl && !challengedMetadataUrl)
|
|
310
756
|
return;
|
|
311
|
-
const resourceMeta = await discoverProtectedResource(this.serverUrl, this.headers);
|
|
757
|
+
const resourceMeta = await discoverProtectedResource(this.serverUrl, signal, this.headers, challengedMetadataUrl);
|
|
312
758
|
const firstServer = resourceMeta?.authorization_servers?.[0];
|
|
313
|
-
|
|
314
|
-
|
|
759
|
+
const authServerUrl = firstServer === undefined
|
|
760
|
+
? validateAuthorizationServerIssuer(new URL(this.serverUrl).origin)
|
|
761
|
+
: validateAuthorizationServerIssuer(firstServer);
|
|
762
|
+
const resource = resourceMeta?.resource ?? normalizeResourceUrl(this.serverUrl);
|
|
763
|
+
const authServerMetadata = await discoverAuthServer(authServerUrl, signal);
|
|
764
|
+
throwIfAborted(signal);
|
|
765
|
+
this.authServerUrl = authServerUrl;
|
|
766
|
+
this.resource = resource;
|
|
767
|
+
this.protectedResourceScope = metadataScopes(resourceMeta?.scopes_supported);
|
|
768
|
+
this.authServerMetadata = authServerMetadata;
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* A Bearer challenge is authoritative. For a step-up request retain the
|
|
772
|
+
* existing grant/request and add the challenged scopes. On the first flow,
|
|
773
|
+
* protected-resource metadata supplies the only discovery fallback.
|
|
774
|
+
*/
|
|
775
|
+
effectiveScope(challengedScope, issuer) {
|
|
776
|
+
const sameIssuer = this.stored?.issuer === issuer;
|
|
777
|
+
if (challengedScope) {
|
|
778
|
+
return sameIssuer
|
|
779
|
+
? unionScopes(this.stored?.tokens?.scope, this.stored?.requestedScope, challengedScope)
|
|
780
|
+
: challengedScope;
|
|
781
|
+
}
|
|
782
|
+
return sameIssuer
|
|
783
|
+
? unionScopes(this.stored?.tokens?.scope, this.stored?.requestedScope)
|
|
784
|
+
: this.protectedResourceScope;
|
|
785
|
+
}
|
|
786
|
+
getAuthServerIssuer() {
|
|
787
|
+
if (!this.authServerUrl) {
|
|
788
|
+
throw new Error('Failed to discover authorization server');
|
|
789
|
+
}
|
|
790
|
+
if (!this.authServerMetadata?.issuer) {
|
|
791
|
+
throw new Error('OAuth authorization-server metadata has no issuer');
|
|
792
|
+
}
|
|
793
|
+
return this.authServerMetadata.issuer;
|
|
315
794
|
}
|
|
316
795
|
}
|
|
317
796
|
//# sourceMappingURL=oauth.js.map
|