tiny-http-mcp-server 0.1.21 → 0.1.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/composition.json +1 -1
- package/node_modules/mcp-oauth/README.md +31 -1
- package/node_modules/mcp-oauth/dist/client/default-oauth-client-provider.js +63 -14
- package/node_modules/mcp-oauth/dist/client/loopback-authorization.d.ts +5 -0
- package/node_modules/mcp-oauth/dist/client/loopback-authorization.js +134 -72
- package/node_modules/mcp-oauth/dist/client/token-endpoint.d.ts +2 -0
- package/node_modules/mcp-oauth/dist/client/token-endpoint.js +5 -1
- package/node_modules/mcp-oauth/dist/client/types.d.ts +14 -1
- package/node_modules/tiny-mcp-client/README.md +3 -1
- package/node_modules/tiny-mcp-client/dist/index.d.ts +17 -2
- package/node_modules/tiny-mcp-client/dist/index.js +273 -116
- package/package.json +1 -1
package/dist/composition.json
CHANGED
|
@@ -40,10 +40,15 @@ const verifier = createJwksTokenVerifier({
|
|
|
40
40
|
- `client`
|
|
41
41
|
- `mode: "dynamic"` with optional `metadata`
|
|
42
42
|
- `mode: "static"` with `clientId`, optional `clientSecret`, optional `metadata`
|
|
43
|
-
- `
|
|
43
|
+
- `allowInteractive: false` prevents interactive login while retaining cached tokens and silent refresh
|
|
44
|
+
- `initialGrant: { resource, tokens }` optionally imports an existing Bearer grant for one HTTP resource; requires the original client ID
|
|
45
|
+
- `browser.openBrowser(url)` optional
|
|
44
46
|
- `browser.readLine()` optional
|
|
45
47
|
- `browser.createServer()` optional
|
|
46
48
|
- `browser.landingPage` optional
|
|
49
|
+
- `browser.redirectUri` optional exact registered HTTP loopback callback with a fixed port
|
|
50
|
+
- `browser.signal` optional cancellation signal
|
|
51
|
+
- `browser.timeoutMs` optional authorization deadline (default 120,000 ms)
|
|
47
52
|
- `sessionStore` optional
|
|
48
53
|
- `authStore` optional `auth-store` backend config for the default session store
|
|
49
54
|
- `now()` optional clock override
|
|
@@ -62,6 +67,31 @@ const verifier = createJwksTokenVerifier({
|
|
|
62
67
|
| `requireAccessTokenType` | `boolean` | `false` | Require the JWT `typ` protected header to be `at+jwt`. |
|
|
63
68
|
| `fetch` | `typeof fetch` | global `fetch` | Custom fetch implementation. |
|
|
64
69
|
|
|
70
|
+
Fixed redirects support `localhost`, `127.0.0.1`, and `::1` over HTTP. Their
|
|
71
|
+
exact spelling, port, path and query are preserved through registration,
|
|
72
|
+
authorization and code exchange. Credentials, fragments, port zero and reserved
|
|
73
|
+
OAuth callback query parameters are rejected before binding a listener. Omit
|
|
74
|
+
`redirectUri` to allocate a random loopback port. Standalone callback sessions
|
|
75
|
+
accept the same `redirectUri`, `signal` and `timeoutMs` options. Cancellation,
|
|
76
|
+
timeout and explicit close settle pending code waits and release listeners.
|
|
77
|
+
Always close a successful standalone session in `finally`.
|
|
78
|
+
|
|
79
|
+
Provider request inputs accept an optional `signal`. It reaches callback waits,
|
|
80
|
+
registration, token requests and bounded token-body reads. Cancellation retains
|
|
81
|
+
its original reason and does not retry authorization. Custom providers should
|
|
82
|
+
observe the supplied signal and pass it to any work they start.
|
|
83
|
+
|
|
84
|
+
Configure `client.metadata.scope` to request a precise scope set; broader
|
|
85
|
+
discovery metadata does not override it.
|
|
86
|
+
|
|
87
|
+
Imported `initialGrant.tokens` use `accessToken`, optional `refreshToken`,
|
|
88
|
+
`tokenType: "Bearer"`, `expiresAt` (Unix epoch milliseconds or `null` if unknown),
|
|
89
|
+
and optional `scope`. A fresh imported token is used only for its resource.
|
|
90
|
+
Discovery binds an expired or explicitly rejected grant before silent refresh,
|
|
91
|
+
using the original configured client. Persisted sessions take precedence,
|
|
92
|
+
including sessions whose tokens have been cleared; an import cannot revive them.
|
|
93
|
+
Input tokens are copied and invalid expiry values fail before authorization.
|
|
94
|
+
|
|
65
95
|
`createAuthStoreSessionStore(options)` accepts the standard `auth-store` config.
|
|
66
96
|
|
|
67
97
|
## Environment Variables
|
|
@@ -21,12 +21,36 @@ export function createDefaultOAuthClientProvider(options) {
|
|
|
21
21
|
const registeredClients = new Map();
|
|
22
22
|
const refreshPromises = new Map();
|
|
23
23
|
const authorizationPromises = new Map();
|
|
24
|
+
if (options.initialGrant !== undefined) {
|
|
25
|
+
let resource;
|
|
26
|
+
try {
|
|
27
|
+
resource = new URL(options.initialGrant.resource);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
throw new Error("OAuth initial grant resource must be an absolute HTTP URL");
|
|
31
|
+
}
|
|
32
|
+
if ((resource.protocol !== "http:" && resource.protocol !== "https:") || resource.username || resource.password || resource.hash)
|
|
33
|
+
throw new Error("OAuth initial grant resource must be an HTTP URL without credentials or fragments");
|
|
34
|
+
}
|
|
35
|
+
const initialGrant = options.initialGrant === undefined ? undefined : {
|
|
36
|
+
resource: canonicalizeResourceIndicator(options.initialGrant.resource),
|
|
37
|
+
tokens: normalizeStoredTokens(options.initialGrant.tokens),
|
|
38
|
+
client: normalizeConfiguredClient(options.client)
|
|
39
|
+
};
|
|
40
|
+
if (initialGrant !== undefined && (initialGrant.tokens === undefined || initialGrant.client === null))
|
|
41
|
+
throw new Error("OAuth initial grant requires valid tokens and the original client ID");
|
|
42
|
+
let initialGrantConsumed = false;
|
|
24
43
|
return {
|
|
25
44
|
async authorizeRequest(input) {
|
|
26
45
|
assertNoAccessTokenInUrl(input.requestUrl, "Protected resource request URL");
|
|
27
46
|
const requestUrl = canonicalizeResourceIndicator(input.requestUrl);
|
|
28
|
-
const session = await ensureAuthorizedSession(requestUrl, undefined, input.fetch, false);
|
|
47
|
+
const session = await ensureAuthorizedSession(requestUrl, undefined, input.fetch, false, false, input.signal);
|
|
29
48
|
const accessToken = session?.tokens?.accessToken;
|
|
49
|
+
if (session === null && !initialGrantConsumed && initialGrant?.resource === requestUrl &&
|
|
50
|
+
initialGrant.tokens !== undefined && !isExpired(initialGrant.tokens, now)) {
|
|
51
|
+
input.headers.set("Authorization", `Bearer ${initialGrant.tokens.accessToken}`);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
30
54
|
if (session === null ||
|
|
31
55
|
accessToken === undefined ||
|
|
32
56
|
session.tokens === undefined ||
|
|
@@ -42,18 +66,19 @@ export function createDefaultOAuthClientProvider(options) {
|
|
|
42
66
|
const requestUrl = canonicalizeResourceIndicator(input.requestUrl);
|
|
43
67
|
const resource = canonicalizeResourceIndicator(input.discovery.resource);
|
|
44
68
|
assertRequestMatchesResource(requestUrl, resource);
|
|
45
|
-
const forceRefresh = hasCachedAccessToken(await loadSession(resource)) &&
|
|
69
|
+
const forceRefresh = (hasCachedAccessToken(await loadSession(resource)) || (!initialGrantConsumed && initialGrant?.resource === resource)) &&
|
|
46
70
|
input.challenge?.params.error === "invalid_token";
|
|
47
71
|
const session = await ensureAuthorizedSession(resource, {
|
|
48
72
|
...input.discovery,
|
|
49
73
|
resource
|
|
50
|
-
}, input.fetch, true, forceRefresh);
|
|
74
|
+
}, input.fetch, true, forceRefresh, input.signal);
|
|
51
75
|
if (session?.tokens?.accessToken === undefined) {
|
|
52
76
|
return { action: "fail" };
|
|
53
77
|
}
|
|
54
78
|
return { action: "retry" };
|
|
55
79
|
}
|
|
56
80
|
catch (error) {
|
|
81
|
+
input.signal?.throwIfAborted();
|
|
57
82
|
return {
|
|
58
83
|
action: "fail",
|
|
59
84
|
error: error instanceof Error ? error : new Error(String(error))
|
|
@@ -61,9 +86,13 @@ export function createDefaultOAuthClientProvider(options) {
|
|
|
61
86
|
}
|
|
62
87
|
}
|
|
63
88
|
};
|
|
64
|
-
async function ensureAuthorizedSession(resource, discovery, fetch, allowInteractive, forceRefresh = false) {
|
|
89
|
+
async function ensureAuthorizedSession(resource, discovery, fetch, allowInteractive, forceRefresh = false, signal) {
|
|
90
|
+
signal?.throwIfAborted();
|
|
65
91
|
const canonicalResource = canonicalizeResourceIndicator(resource);
|
|
66
92
|
let session = await loadSession(canonicalResource);
|
|
93
|
+
if (session !== null && initialGrant?.resource === canonicalResource)
|
|
94
|
+
initialGrantConsumed = true;
|
|
95
|
+
signal?.throwIfAborted();
|
|
67
96
|
if (discovery !== undefined && getOwnString(discovery.authorizationServerMetadata, "issuer") !== discovery.authorizationServer) {
|
|
68
97
|
throw new Error("OAuth discovery authorization-server issuer mismatch");
|
|
69
98
|
}
|
|
@@ -73,6 +102,15 @@ export function createDefaultOAuthClientProvider(options) {
|
|
|
73
102
|
await clearSession(canonicalResource);
|
|
74
103
|
session = null;
|
|
75
104
|
}
|
|
105
|
+
if (session === null && discovery !== undefined && !initialGrantConsumed && initialGrant?.resource === canonicalResource &&
|
|
106
|
+
initialGrant.tokens !== undefined && initialGrant.client !== null) {
|
|
107
|
+
assertSecureOAuthFlowEndpoints(discovery.authorizationServerMetadata);
|
|
108
|
+
session = { resource: canonicalResource, authorizationServer: discovery.authorizationServer,
|
|
109
|
+
client: initialGrant.client, tokens: initialGrant.tokens, discovery: toStoredDiscovery(discovery) };
|
|
110
|
+
await saveSession(canonicalResource, session);
|
|
111
|
+
initialGrantConsumed = true;
|
|
112
|
+
signal?.throwIfAborted();
|
|
113
|
+
}
|
|
76
114
|
const sessionDiscovery = resolveDiscovery(discovery, session);
|
|
77
115
|
if (session?.tokens !== undefined && !forceRefresh && !isExpired(session.tokens, now)) {
|
|
78
116
|
return session;
|
|
@@ -80,7 +118,7 @@ export function createDefaultOAuthClientProvider(options) {
|
|
|
80
118
|
if (session?.tokens?.refreshToken !== undefined &&
|
|
81
119
|
sessionDiscovery !== undefined &&
|
|
82
120
|
(forceRefresh || isExpired(session.tokens, now))) {
|
|
83
|
-
session = await refreshSession(canonicalResource, session, sessionDiscovery, fetch);
|
|
121
|
+
session = await refreshSession(canonicalResource, session, sessionDiscovery, fetch, signal);
|
|
84
122
|
if (session?.tokens !== undefined && !isExpired(session.tokens, now)) {
|
|
85
123
|
return session;
|
|
86
124
|
}
|
|
@@ -92,9 +130,12 @@ export function createDefaultOAuthClientProvider(options) {
|
|
|
92
130
|
if (!allowInteractive || sessionDiscovery === undefined) {
|
|
93
131
|
return session;
|
|
94
132
|
}
|
|
95
|
-
|
|
133
|
+
if (options.allowInteractive === false)
|
|
134
|
+
throw new Error("OAuth interactive authorization is disabled");
|
|
135
|
+
return authorizeSession(canonicalResource, session, sessionDiscovery, fetch, signal);
|
|
96
136
|
}
|
|
97
|
-
async function refreshSession(resource, session, discovery, fetch) {
|
|
137
|
+
async function refreshSession(resource, session, discovery, fetch, signal) {
|
|
138
|
+
signal?.throwIfAborted();
|
|
98
139
|
assertSecureOAuthFlowEndpoints(discovery.authorizationServerMetadata);
|
|
99
140
|
const inFlight = refreshPromises.get(resource);
|
|
100
141
|
if (inFlight !== undefined) {
|
|
@@ -115,12 +156,13 @@ export function createDefaultOAuthClientProvider(options) {
|
|
|
115
156
|
clientSecret: session.client.clientSecret,
|
|
116
157
|
refreshToken: session.tokens.refreshToken,
|
|
117
158
|
resource,
|
|
118
|
-
fetch,
|
|
159
|
+
fetch, signal,
|
|
119
160
|
now
|
|
120
161
|
});
|
|
121
162
|
break;
|
|
122
163
|
}
|
|
123
164
|
catch (error) {
|
|
165
|
+
signal?.throwIfAborted();
|
|
124
166
|
if (error instanceof OAuthError && error.error === "invalid_grant") {
|
|
125
167
|
const clearedSession = clearSessionTokens(session);
|
|
126
168
|
await saveSession(resource, clearedSession);
|
|
@@ -156,7 +198,8 @@ export function createDefaultOAuthClientProvider(options) {
|
|
|
156
198
|
refreshPromises.set(resource, promise);
|
|
157
199
|
return promise;
|
|
158
200
|
}
|
|
159
|
-
async function authorizeSession(resource, existingSession, discovery, fetch) {
|
|
201
|
+
async function authorizeSession(resource, existingSession, discovery, fetch, signal) {
|
|
202
|
+
signal?.throwIfAborted();
|
|
160
203
|
const inFlight = authorizationPromises.get(resource);
|
|
161
204
|
if (inFlight !== undefined) {
|
|
162
205
|
return inFlight;
|
|
@@ -172,11 +215,14 @@ export function createDefaultOAuthClientProvider(options) {
|
|
|
172
215
|
openBrowser: options.browser.openBrowser,
|
|
173
216
|
readLine: options.browser.readLine,
|
|
174
217
|
createServer: options.browser.createServer,
|
|
175
|
-
landingPage: options.browser.landingPage
|
|
218
|
+
landingPage: options.browser.landingPage,
|
|
219
|
+
redirectUri: options.browser.redirectUri,
|
|
220
|
+
signal: options.browser.signal === undefined ? signal : signal === undefined ? options.browser.signal : AbortSignal.any([signal, options.browser.signal]),
|
|
221
|
+
timeoutMs: options.browser.timeoutMs
|
|
176
222
|
});
|
|
177
223
|
let resolvedClient = null;
|
|
178
224
|
try {
|
|
179
|
-
resolvedClient = await resolveClient(currentSession, discovery, loopback.redirectUri, fetch);
|
|
225
|
+
resolvedClient = await resolveClient(currentSession, discovery, loopback.redirectUri, fetch, signal);
|
|
180
226
|
const sessionWithoutTokens = {
|
|
181
227
|
resource,
|
|
182
228
|
authorizationServer: discovery.authorizationServer,
|
|
@@ -203,7 +249,7 @@ export function createDefaultOAuthClientProvider(options) {
|
|
|
203
249
|
codeVerifier: verifier,
|
|
204
250
|
redirectUri: loopback.redirectUri,
|
|
205
251
|
resource,
|
|
206
|
-
fetch,
|
|
252
|
+
fetch, signal,
|
|
207
253
|
now
|
|
208
254
|
});
|
|
209
255
|
const session = {
|
|
@@ -214,6 +260,7 @@ export function createDefaultOAuthClientProvider(options) {
|
|
|
214
260
|
return session;
|
|
215
261
|
}
|
|
216
262
|
catch (error) {
|
|
263
|
+
signal?.throwIfAborted();
|
|
217
264
|
if (shouldReRegisterStoredDynamicClient(error, resolvedClient, reRegistrationAttempted)) {
|
|
218
265
|
reRegistrationAttempted = true;
|
|
219
266
|
await clearRegisteredClient(discovery.authorizationServer);
|
|
@@ -240,7 +287,8 @@ export function createDefaultOAuthClientProvider(options) {
|
|
|
240
287
|
authorizationPromises.set(resource, finalPromise);
|
|
241
288
|
return finalPromise;
|
|
242
289
|
}
|
|
243
|
-
async function resolveClient(existingSession, discovery, redirectUri, fetch) {
|
|
290
|
+
async function resolveClient(existingSession, discovery, redirectUri, fetch, parentSignal) {
|
|
291
|
+
parentSignal?.throwIfAborted();
|
|
244
292
|
const configuredClient = normalizeConfiguredClient(options.client);
|
|
245
293
|
if (options.client.mode === "static") {
|
|
246
294
|
if (configuredClient === null) {
|
|
@@ -292,7 +340,8 @@ export function createDefaultOAuthClientProvider(options) {
|
|
|
292
340
|
}
|
|
293
341
|
}
|
|
294
342
|
const registrationBody = buildClientRegistrationBody(getClientMetadata(options.client), redirectUri);
|
|
295
|
-
const
|
|
343
|
+
const deadline = AbortSignal.timeout(30_000);
|
|
344
|
+
const signal = parentSignal === undefined ? deadline : AbortSignal.any([parentSignal, deadline]);
|
|
296
345
|
const response = await fetchMcpResponse(fetch, registrationEndpoint, {
|
|
297
346
|
method: "POST",
|
|
298
347
|
headers: {
|
|
@@ -9,6 +9,11 @@ export interface LoopbackAuthorizationOptions {
|
|
|
9
9
|
createServer?: () => http.Server;
|
|
10
10
|
landingPage?: OAuthLandingPage;
|
|
11
11
|
callbackPath?: string;
|
|
12
|
+
/** Exact registered HTTP loopback redirect, including its fixed port and query. */
|
|
13
|
+
redirectUri?: string;
|
|
14
|
+
signal?: AbortSignal;
|
|
15
|
+
/** Bounds listener setup and authorization; defaults to two minutes. */
|
|
16
|
+
timeoutMs?: number;
|
|
12
17
|
}
|
|
13
18
|
export interface LoopbackAuthorizationSession {
|
|
14
19
|
redirectUri: string;
|
|
@@ -1,77 +1,150 @@
|
|
|
1
1
|
import http from "node:http";
|
|
2
2
|
import { parseAuthorizationState } from "./authorization-state.js";
|
|
3
3
|
export async function createLoopbackAuthorizationSession(options = {}) {
|
|
4
|
-
|
|
4
|
+
options.signal?.throwIfAborted();
|
|
5
|
+
const timeoutMs = options.timeoutMs ?? 120_000;
|
|
6
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 2_147_483_647)
|
|
7
|
+
throw new Error("OAuth authorization timeoutMs must be a positive supported timer interval");
|
|
8
|
+
const target = loopbackTarget(options);
|
|
5
9
|
const server = options.createServer ? options.createServer() : http.createServer();
|
|
6
|
-
const
|
|
7
|
-
|
|
10
|
+
const controller = new AbortController();
|
|
11
|
+
let closed = false;
|
|
12
|
+
let used = false;
|
|
13
|
+
const callerAbort = () => controller.abort(options.signal?.reason);
|
|
14
|
+
const teardown = () => {
|
|
15
|
+
if (closed)
|
|
16
|
+
return;
|
|
17
|
+
closed = true;
|
|
18
|
+
clearTimeout(timer);
|
|
19
|
+
options.signal?.removeEventListener("abort", callerAbort);
|
|
20
|
+
server.closeAllConnections?.();
|
|
21
|
+
server.close();
|
|
22
|
+
};
|
|
23
|
+
const timer = setTimeout(() => controller.abort(new Error("OAuth authorization timed out")), timeoutMs);
|
|
24
|
+
timer.unref?.();
|
|
25
|
+
controller.signal.addEventListener("abort", teardown, { once: true });
|
|
26
|
+
options.signal?.addEventListener("abort", callerAbort, { once: true });
|
|
27
|
+
if (options.signal?.aborted)
|
|
28
|
+
callerAbort();
|
|
29
|
+
let port;
|
|
30
|
+
try {
|
|
31
|
+
port = await startServer(server, target.port, target.host, controller.signal);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
controller.abort(error);
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
const redirectUri = options.redirectUri ?? `http://127.0.0.1:${port}${target.callbackPath}`;
|
|
8
38
|
return {
|
|
9
39
|
redirectUri,
|
|
10
40
|
async waitForCode(authorizationUrl) {
|
|
11
|
-
|
|
41
|
+
controller.signal.throwIfAborted();
|
|
42
|
+
if (used)
|
|
43
|
+
throw new Error("OAuth authorization session has already been used");
|
|
44
|
+
used = true;
|
|
45
|
+
try {
|
|
46
|
+
return await waitForAuthorizationCode(server, authorizationUrl, options, target.callbackPath, controller.signal);
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
}
|
|
12
51
|
},
|
|
13
52
|
close() {
|
|
14
|
-
|
|
15
|
-
server.close();
|
|
53
|
+
controller.abort(new Error("OAuth authorization session closed"));
|
|
16
54
|
}
|
|
17
55
|
};
|
|
18
56
|
}
|
|
19
|
-
|
|
57
|
+
function loopbackTarget(options) {
|
|
58
|
+
if (options.redirectUri !== undefined) {
|
|
59
|
+
let url;
|
|
60
|
+
try {
|
|
61
|
+
url = new URL(options.redirectUri);
|
|
62
|
+
}
|
|
63
|
+
catch (cause) {
|
|
64
|
+
throw new Error("Invalid OAuth loopback redirect URI", { cause });
|
|
65
|
+
}
|
|
66
|
+
const forbiddenQuery = ["code", "state", "error", "error_description", "iss"];
|
|
67
|
+
if (url.protocol !== "http:" || !["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)
|
|
68
|
+
|| url.username || url.password || url.hash || url.port === "0"
|
|
69
|
+
|| forbiddenQuery.some(name => url.searchParams.has(name))
|
|
70
|
+
|| [...options.redirectUri].some(char => char.codePointAt(0) <= 32)
|
|
71
|
+
|| (options.callbackPath !== undefined && options.callbackPath !== url.pathname))
|
|
72
|
+
throw new Error("Invalid OAuth loopback redirect URI");
|
|
73
|
+
return { port: url.port ? Number(url.port) : 80, host: url.hostname === "[::1]" ? "::1" : url.hostname, callbackPath: url.pathname };
|
|
74
|
+
}
|
|
75
|
+
const callbackPath = options.callbackPath ?? "/callback";
|
|
76
|
+
const parsed = new URL(callbackPath, "http://127.0.0.1");
|
|
77
|
+
if (!callbackPath.startsWith("/") || parsed.origin !== "http://127.0.0.1" || parsed.pathname !== callbackPath || parsed.search || parsed.hash)
|
|
78
|
+
throw new Error("Invalid OAuth loopback callback path");
|
|
79
|
+
return { port: 0, host: "127.0.0.1", callbackPath };
|
|
80
|
+
}
|
|
81
|
+
async function startServer(server, port, host, signal) {
|
|
82
|
+
signal.throwIfAborted();
|
|
20
83
|
return new Promise((resolve, reject) => {
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
};
|
|
84
|
+
const cleanup = () => { server.off("error", handleError); signal.removeEventListener("abort", aborted); };
|
|
85
|
+
const handleError = (error) => { cleanup(); reject(error); };
|
|
86
|
+
const aborted = () => { cleanup(); reject(signal.reason); };
|
|
25
87
|
server.once("error", handleError);
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
88
|
+
signal.addEventListener("abort", aborted, { once: true });
|
|
89
|
+
try {
|
|
90
|
+
server.listen(port, host, () => {
|
|
91
|
+
cleanup();
|
|
92
|
+
if (signal.aborted) {
|
|
93
|
+
server.close();
|
|
94
|
+
reject(signal.reason);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const address = server.address();
|
|
98
|
+
if (address === null || typeof address === "string") {
|
|
99
|
+
reject(new Error("OAuth listener has no TCP address"));
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
resolve(address.port);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
cleanup();
|
|
107
|
+
reject(error);
|
|
108
|
+
}
|
|
31
109
|
});
|
|
32
110
|
}
|
|
33
|
-
function waitForAuthorizationCode(server, authorizationUrl, options, callbackPath) {
|
|
111
|
+
function waitForAuthorizationCode(server, authorizationUrl, options, callbackPath, signal) {
|
|
112
|
+
signal.throwIfAborted();
|
|
34
113
|
const expectedAuthorization = readExpectedAuthorizationCallback(authorizationUrl);
|
|
35
114
|
return new Promise((resolve, reject) => {
|
|
36
115
|
let settled = false;
|
|
37
116
|
const settle = (fn) => {
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
117
|
+
if (settled)
|
|
118
|
+
return;
|
|
119
|
+
settled = true;
|
|
120
|
+
server.off("request", request);
|
|
121
|
+
signal.removeEventListener("abort", aborted);
|
|
122
|
+
fn();
|
|
42
123
|
};
|
|
43
|
-
|
|
44
|
-
|
|
124
|
+
const aborted = () => settle(() => reject(signal.reason));
|
|
125
|
+
const request = (req, res) => {
|
|
126
|
+
let url;
|
|
127
|
+
try {
|
|
128
|
+
url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
res.writeHead(400);
|
|
132
|
+
res.end("Invalid callback URL");
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
45
135
|
if (url.pathname !== callbackPath) {
|
|
46
136
|
res.writeHead(404);
|
|
47
137
|
res.end("Not found");
|
|
48
138
|
return;
|
|
49
139
|
}
|
|
50
140
|
const callbackParameters = {
|
|
51
|
-
code: url.searchParams.get("code"),
|
|
52
|
-
|
|
53
|
-
errorDescription: url.searchParams.get("error_description"),
|
|
54
|
-
state: url.searchParams.get("state"),
|
|
55
|
-
iss: url.searchParams.get("iss")
|
|
141
|
+
code: url.searchParams.get("code"), error: url.searchParams.get("error"),
|
|
142
|
+
errorDescription: url.searchParams.get("error_description"), state: url.searchParams.get("state"), iss: url.searchParams.get("iss")
|
|
56
143
|
};
|
|
57
144
|
try {
|
|
58
145
|
validateAuthorizationCallbackBinding(callbackParameters, expectedAuthorization);
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
res.writeHead(400);
|
|
62
|
-
res.end(error instanceof Error ? error.message : "Invalid OAuth callback");
|
|
63
|
-
settle(() => reject(error instanceof Error ? error : new Error(String(error))));
|
|
64
|
-
return;
|
|
65
|
-
}
|
|
66
|
-
const authorizationError = callbackParameters.error;
|
|
67
|
-
if (authorizationError !== null) {
|
|
68
|
-
const description = callbackParameters.errorDescription ?? authorizationError;
|
|
69
|
-
res.writeHead(400);
|
|
70
|
-
res.end(`Authorization failed: ${description}`);
|
|
71
|
-
settle(() => reject(createAuthorizationError(authorizationError, description)));
|
|
72
|
-
return;
|
|
73
|
-
}
|
|
74
|
-
try {
|
|
146
|
+
if (callbackParameters.error !== null)
|
|
147
|
+
throw createAuthorizationError(callbackParameters.error, callbackParameters.errorDescription ?? callbackParameters.error);
|
|
75
148
|
const code = validateAuthorizationCallbackParameters(callbackParameters, expectedAuthorization);
|
|
76
149
|
res.writeHead(200, { "Content-Type": "text/html" });
|
|
77
150
|
res.end(buildSuccessPage(options.landingPage));
|
|
@@ -80,39 +153,28 @@ function waitForAuthorizationCode(server, authorizationUrl, options, callbackPat
|
|
|
80
153
|
catch (error) {
|
|
81
154
|
res.writeHead(400);
|
|
82
155
|
res.end(error instanceof Error ? error.message : "Invalid OAuth callback");
|
|
83
|
-
settle(() => reject(error
|
|
156
|
+
settle(() => reject(error));
|
|
84
157
|
}
|
|
85
|
-
}
|
|
158
|
+
};
|
|
159
|
+
server.on("request", request);
|
|
160
|
+
signal.addEventListener("abort", aborted, { once: true });
|
|
86
161
|
if (options.readLine !== undefined) {
|
|
87
|
-
options
|
|
88
|
-
|
|
89
|
-
.then((input) => {
|
|
90
|
-
const callbackParameters = extractCallbackParametersFromInput(input);
|
|
91
|
-
if (callbackParameters === null) {
|
|
92
|
-
settle(() => reject(new Error("OAuth callback missing authorization code")));
|
|
162
|
+
void Promise.resolve().then(() => settled ? undefined : options.readLine()).then(input => {
|
|
163
|
+
if (settled)
|
|
93
164
|
return;
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
104
|
-
catch (error) {
|
|
105
|
-
settle(() => reject(error instanceof Error ? error : new Error(String(error))));
|
|
106
|
-
}
|
|
107
|
-
})
|
|
108
|
-
.catch((error) => {
|
|
109
|
-
settle(() => reject(error instanceof Error ? error : new Error(String(error))));
|
|
110
|
-
});
|
|
165
|
+
const callbackParameters = extractCallbackParametersFromInput(input);
|
|
166
|
+
if (callbackParameters === null)
|
|
167
|
+
throw new Error("OAuth callback missing authorization code");
|
|
168
|
+
validateAuthorizationCallbackBinding(callbackParameters, expectedAuthorization);
|
|
169
|
+
if (callbackParameters.error !== null)
|
|
170
|
+
throw createAuthorizationError(callbackParameters.error, callbackParameters.errorDescription ?? callbackParameters.error);
|
|
171
|
+
const code = validateAuthorizationCallbackParameters(callbackParameters, expectedAuthorization);
|
|
172
|
+
settle(() => resolve(code));
|
|
173
|
+
}).catch(error => settle(() => reject(error)));
|
|
111
174
|
}
|
|
112
175
|
if (options.openBrowser !== undefined) {
|
|
113
|
-
|
|
114
|
-
settle(() => reject(error));
|
|
115
|
-
});
|
|
176
|
+
void Promise.resolve().then(() => settled ? undefined : options.openBrowser(authorizationUrl))
|
|
177
|
+
.catch(error => settle(() => reject(error)));
|
|
116
178
|
}
|
|
117
179
|
});
|
|
118
180
|
}
|
|
@@ -25,6 +25,7 @@ export declare function exchangeAuthorizationCode(input: {
|
|
|
25
25
|
redirectUri: string;
|
|
26
26
|
resource: string;
|
|
27
27
|
fetch: OAuthMetadataFetch;
|
|
28
|
+
signal?: AbortSignal;
|
|
28
29
|
now: () => number;
|
|
29
30
|
}): Promise<StoredOAuthTokens>;
|
|
30
31
|
export declare function refreshAccessToken(input: {
|
|
@@ -34,6 +35,7 @@ export declare function refreshAccessToken(input: {
|
|
|
34
35
|
refreshToken: string;
|
|
35
36
|
resource: string;
|
|
36
37
|
fetch: OAuthMetadataFetch;
|
|
38
|
+
signal?: AbortSignal;
|
|
37
39
|
now: () => number;
|
|
38
40
|
}): Promise<StoredOAuthTokens>;
|
|
39
41
|
export declare function readOAuthJsonObjectResponse(response: Response, signal?: AbortSignal): Promise<Record<string, unknown>>;
|
|
@@ -44,6 +44,7 @@ export async function exchangeAuthorizationCode(input) {
|
|
|
44
44
|
resource
|
|
45
45
|
},
|
|
46
46
|
fetch: input.fetch,
|
|
47
|
+
signal: input.signal,
|
|
47
48
|
now: input.now
|
|
48
49
|
});
|
|
49
50
|
}
|
|
@@ -59,6 +60,7 @@ export async function refreshAccessToken(input) {
|
|
|
59
60
|
resource
|
|
60
61
|
},
|
|
61
62
|
fetch: input.fetch,
|
|
63
|
+
signal: input.signal,
|
|
62
64
|
now: input.now
|
|
63
65
|
});
|
|
64
66
|
}
|
|
@@ -70,7 +72,9 @@ async function requestTokens(input) {
|
|
|
70
72
|
if (input.clientSecret !== undefined) {
|
|
71
73
|
body.set("client_secret", input.clientSecret);
|
|
72
74
|
}
|
|
73
|
-
|
|
75
|
+
input.signal?.throwIfAborted();
|
|
76
|
+
const deadline = AbortSignal.timeout(30_000);
|
|
77
|
+
const signal = input.signal === undefined ? deadline : AbortSignal.any([input.signal, deadline]);
|
|
74
78
|
const response = await fetchMcpResponse(input.fetch, input.tokenEndpoint, {
|
|
75
79
|
method: "POST",
|
|
76
80
|
headers: {
|
|
@@ -32,6 +32,7 @@ export interface OAuthClientProvider {
|
|
|
32
32
|
requestUrl: URL;
|
|
33
33
|
headers: Headers;
|
|
34
34
|
fetch: OAuthMetadataFetch;
|
|
35
|
+
signal?: AbortSignal;
|
|
35
36
|
}): Promise<void> | void;
|
|
36
37
|
handleUnauthorized(input: {
|
|
37
38
|
requestUrl: URL;
|
|
@@ -39,6 +40,7 @@ export interface OAuthClientProvider {
|
|
|
39
40
|
challenge: OAuthUnauthorizedChallenge | null;
|
|
40
41
|
discovery: OAuthDiscoveryResult;
|
|
41
42
|
fetch: OAuthMetadataFetch;
|
|
43
|
+
signal?: AbortSignal;
|
|
42
44
|
}): Promise<{
|
|
43
45
|
action: "retry";
|
|
44
46
|
} | {
|
|
@@ -95,8 +97,19 @@ export interface DefaultOAuthClientProviderOptions {
|
|
|
95
97
|
clientSecret?: string;
|
|
96
98
|
metadata?: OAuthClientMetadata;
|
|
97
99
|
};
|
|
100
|
+
/** Disable interactive authorization while allowing cached tokens and silent refresh. */
|
|
101
|
+
allowInteractive?: boolean;
|
|
102
|
+
/** Import an existing grant for one resource. Persisted sessions take precedence. */
|
|
103
|
+
initialGrant?: {
|
|
104
|
+
resource: string;
|
|
105
|
+
tokens: StoredOAuthTokens;
|
|
106
|
+
};
|
|
98
107
|
browser: {
|
|
99
|
-
openBrowser(url: string): Promise<void>;
|
|
108
|
+
openBrowser?(url: string): Promise<void>;
|
|
109
|
+
/** Exact registered HTTP loopback redirect URI. */
|
|
110
|
+
redirectUri?: string;
|
|
111
|
+
signal?: AbortSignal;
|
|
112
|
+
timeoutMs?: number;
|
|
100
113
|
readLine?: () => Promise<string>;
|
|
101
114
|
createServer?: () => http.Server;
|
|
102
115
|
landingPage?: {
|
|
@@ -66,7 +66,9 @@ const transport = new HttpTransport({
|
|
|
66
66
|
});
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
-
You can also call `discoverOAuthMetadata(resourceUrl, options)` directly, or instantiate `OAuthMetadataDiscovery` with a custom `fetch` implementation and shared cache.
|
|
69
|
+
You can also call `discoverOAuthMetadata(resourceUrl, options)` directly, or instantiate `OAuthMetadataDiscovery` with a custom `fetch` implementation and shared cache. Lookup options accept `signal`; cancellation stops metadata fetches and body reads without trying another discovery candidate.
|
|
70
|
+
|
|
71
|
+
OAuth provider inputs receive the originating request's `signal`, covering header authorization and unauthorized handling. Modern request cancellation stops its OAuth work while leaving other requests usable; transport disposal cancels all pending OAuth operations. The default provider propagates this signal to callback, registration and token work. Custom providers must observe it for their own operations.
|
|
70
72
|
|
|
71
73
|
## Testing helpers
|
|
72
74
|
|