oauth-callback 1.2.5 → 2.0.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 +33 -19
- package/dist/auth/browser-auth.d.ts +3 -1
- package/dist/auth/browser-auth.d.ts.map +1 -1
- package/dist/index.d.ts +10 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +57 -713
- package/dist/mcp-types.d.ts +3 -2
- package/dist/mcp-types.d.ts.map +1 -1
- package/dist/mcp.js +58 -713
- package/dist/server.d.ts.map +1 -1
- package/dist/types.d.ts +14 -5
- package/dist/types.d.ts.map +1 -1
- package/package.json +13 -11
- package/src/auth/browser-auth.ts +56 -116
- package/src/index.ts +13 -28
- package/src/mcp-types.ts +4 -4
- package/src/server.ts +15 -22
- package/src/types.ts +14 -5
package/dist/server.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAOH;;GAEG;AACH,MAAM,WAAW,cAAc;IAC7B,oDAAoD;IACpD,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,0CAA0C;IAC1C,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,kEAAkE;IAClE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,uCAAuC;IACvC,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,4CAA4C;IAC5C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,sDAAsD;IACtD,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC;CACnC;AAED;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,wCAAwC;IACxC,IAAI,EAAE,MAAM,CAAC;IACb,4DAA4D;IAC5D,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,uDAAuD;IACvD,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,mHAAmH;IACnH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,sDAAsD;IACtD,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB,oFAAoF;IACpF,SAAS,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,IAAI,CAAC;CACpC;AAED;;GAEG;AACH,MAAM,WAAW,cAAc;IAC7B,mDAAmD;IACnD,KAAK,CAAC,OAAO,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7C,iEAAiE;IACjE,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,CAAC,CAAC;IACxE,4CAA4C;IAC5C,IAAI,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACvB;AAsRD;;;;GAIG;AACH,wBAAgB,oBAAoB,IAAI,cAAc,CAKrD"}
|
package/dist/types.d.ts
CHANGED
|
@@ -27,16 +27,25 @@ export interface GetAuthCodeOptions {
|
|
|
27
27
|
callbackPath?: string;
|
|
28
28
|
/**
|
|
29
29
|
* Timeout in milliseconds to wait for OAuth callback.
|
|
30
|
-
*
|
|
30
|
+
* Starts when the callback server is ready; launch timing does not delay it.
|
|
31
31
|
* @default 30000
|
|
32
32
|
*/
|
|
33
33
|
timeout?: number;
|
|
34
34
|
/**
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
35
|
+
* Optional callback to launch the authorization URL.
|
|
36
|
+
* Called after the callback server starts, best-effort (errors are swallowed).
|
|
37
|
+
* If omitted, the library does nothing — caller is responsible for opening the URL.
|
|
38
|
+
*
|
|
39
|
+
* Returns `unknown` (not `void`) to accept any launcher without casting—e.g.,
|
|
40
|
+
* the `open` package returns `Promise<ChildProcess>`. Return value is ignored.
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* ```typescript
|
|
44
|
+
* import open from "open";
|
|
45
|
+
* await getAuthCode({ authorizationUrl: url, launch: open });
|
|
46
|
+
* ```
|
|
38
47
|
*/
|
|
39
|
-
|
|
48
|
+
launch?: (url: string) => unknown;
|
|
40
49
|
/**
|
|
41
50
|
* Custom HTML content to display when authorization is successful.
|
|
42
51
|
* If not provided, a default success page with auto-close functionality is used.
|
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAGA;;GAEG;AACH,MAAM,WAAW,kBAAkB;IACjC;;;OAGG;IACH,gBAAgB,EAAE,MAAM,CAAC;IAEzB;;;;OAIG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IAEd;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAElB;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IAEtB;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IAEjB
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAGA;;GAEG;AACH,MAAM,WAAW,kBAAkB;IACjC;;;OAGG;IACH,gBAAgB,EAAE,MAAM,CAAC;IAEzB;;;;OAIG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IAEd;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAElB;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,CAAC;IAEtB;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IAEjB;;;;;;;;;;;;;OAaG;IACH,MAAM,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;IAElC;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IAErB;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IAEnB;;;OAGG;IACH,MAAM,CAAC,EAAE,WAAW,CAAC;IAErB;;;;OAIG;IACH,SAAS,CAAC,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,IAAI,CAAC;CACpC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "oauth-callback",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Lightweight OAuth 2.0 callback handler for Node.js, Deno, and Bun with built-in browser flow and MCP SDK integration",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"oauth",
|
|
@@ -52,6 +52,10 @@
|
|
|
52
52
|
"module": "dist/index.js",
|
|
53
53
|
"types": "dist/index.d.ts",
|
|
54
54
|
"type": "module",
|
|
55
|
+
"sideEffects": false,
|
|
56
|
+
"engines": {
|
|
57
|
+
"node": ">=18"
|
|
58
|
+
},
|
|
55
59
|
"exports": {
|
|
56
60
|
".": {
|
|
57
61
|
"types": "./dist/index.d.ts",
|
|
@@ -63,9 +67,6 @@
|
|
|
63
67
|
},
|
|
64
68
|
"./package.json": "./package.json"
|
|
65
69
|
},
|
|
66
|
-
"dependencies": {
|
|
67
|
-
"open": "^11.0.0"
|
|
68
|
-
},
|
|
69
70
|
"peerDependencies": {
|
|
70
71
|
"@modelcontextprotocol/sdk": ">=1.17.0 <2",
|
|
71
72
|
"typescript": ">=5 <6"
|
|
@@ -79,17 +80,18 @@
|
|
|
79
80
|
}
|
|
80
81
|
},
|
|
81
82
|
"devDependencies": {
|
|
82
|
-
"@modelcontextprotocol/sdk": "^1.25.
|
|
83
|
+
"@modelcontextprotocol/sdk": "^1.25.3",
|
|
84
|
+
"open": "^11.0.0",
|
|
83
85
|
"@types/bun": "^1.3.6",
|
|
84
86
|
"@types/deno": "^2.5.0",
|
|
85
|
-
"@types/node": "^
|
|
86
|
-
"@types/open": "^6.2.1",
|
|
87
|
+
"@types/node": "^25.0.10",
|
|
87
88
|
"bun-types": "^1.2.20",
|
|
88
89
|
"mermaid": "^11.12.2",
|
|
89
|
-
"prettier": "^3.8.
|
|
90
|
-
"publint": "^0.3.
|
|
90
|
+
"prettier": "^3.8.1",
|
|
91
|
+
"publint": "^0.3.17",
|
|
92
|
+
"srcpack": "^0.1.13",
|
|
91
93
|
"typescript": "^5.9.3",
|
|
92
|
-
"vitepress": "^
|
|
94
|
+
"vitepress": "^2.0.0-alpha.15",
|
|
93
95
|
"vitepress-plugin-mermaid": "^2.0.17"
|
|
94
96
|
},
|
|
95
97
|
"prettier": {
|
|
@@ -118,6 +120,6 @@
|
|
|
118
120
|
"docs:dev": "vitepress dev docs",
|
|
119
121
|
"docs:build": "vitepress build docs",
|
|
120
122
|
"docs:preview": "vitepress preview docs",
|
|
121
|
-
"docs:
|
|
123
|
+
"docs:deploy": "bunx gh-pages -d docs/.vitepress/dist"
|
|
122
124
|
}
|
|
123
125
|
}
|
package/src/auth/browser-auth.ts
CHANGED
|
@@ -13,7 +13,6 @@ import type {
|
|
|
13
13
|
import { calculateExpiry } from "../utils/token";
|
|
14
14
|
import { inMemoryStore } from "../storage/memory";
|
|
15
15
|
import { getAuthCode } from "../index";
|
|
16
|
-
import { OAuthError } from "../errors";
|
|
17
16
|
import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
18
17
|
import type {
|
|
19
18
|
OAuthClientInformation,
|
|
@@ -30,9 +29,11 @@ import type {
|
|
|
30
29
|
*
|
|
31
30
|
* @example
|
|
32
31
|
* ```typescript
|
|
32
|
+
* import open from "open";
|
|
33
|
+
*
|
|
33
34
|
* const transport = new StreamableHTTPClientTransport(
|
|
34
35
|
* new URL("https://mcp.notion.com/mcp"),
|
|
35
|
-
* { authProvider: browserAuth() }
|
|
36
|
+
* { authProvider: browserAuth({ launch: open }) }
|
|
36
37
|
* );
|
|
37
38
|
* ```
|
|
38
39
|
*/
|
|
@@ -44,6 +45,7 @@ export function browserAuth(
|
|
|
44
45
|
|
|
45
46
|
/**
|
|
46
47
|
* Browser-based OAuth provider for MCP SDK.
|
|
48
|
+
* @invariant PKCE is always enabled (SDK calls saveCodeVerifier/codeVerifier).
|
|
47
49
|
* @invariant addClientAuthentication() must remain undefined (SDK constraint).
|
|
48
50
|
* @invariant Concurrent auth/refresh attempts are serialized.
|
|
49
51
|
*/
|
|
@@ -54,8 +56,7 @@ class BrowserOAuthProvider implements OAuthClientProvider {
|
|
|
54
56
|
private readonly _hostname: string;
|
|
55
57
|
private readonly _callbackPath: string;
|
|
56
58
|
private readonly _authTimeout: number;
|
|
57
|
-
private readonly
|
|
58
|
-
private readonly _openBrowser: boolean | string;
|
|
59
|
+
private readonly _launch?: (url: string) => unknown;
|
|
59
60
|
private readonly _clientId?: string;
|
|
60
61
|
private readonly _clientSecret?: string;
|
|
61
62
|
private readonly _scope?: string;
|
|
@@ -73,7 +74,6 @@ class BrowserOAuthProvider implements OAuthClientProvider {
|
|
|
73
74
|
private _tokensLoaded = false;
|
|
74
75
|
private _loadingTokens?: Promise<void>;
|
|
75
76
|
private _authInProgress?: Promise<void>;
|
|
76
|
-
private _refreshInProgress?: Promise<void>;
|
|
77
77
|
|
|
78
78
|
constructor(options: BrowserAuthOptions = {}) {
|
|
79
79
|
this._store = options.store ?? inMemoryStore();
|
|
@@ -82,9 +82,7 @@ class BrowserOAuthProvider implements OAuthClientProvider {
|
|
|
82
82
|
this._hostname = options.hostname ?? "localhost";
|
|
83
83
|
this._callbackPath = options.callbackPath ?? "/callback";
|
|
84
84
|
this._authTimeout = options.authTimeout ?? 300000;
|
|
85
|
-
this.
|
|
86
|
-
this._openBrowser = options.openBrowser ?? true;
|
|
87
|
-
|
|
85
|
+
this._launch = options.launch;
|
|
88
86
|
this._clientId = options.clientId;
|
|
89
87
|
this._clientSecret = options.clientSecret;
|
|
90
88
|
this._scope = options.scope;
|
|
@@ -123,7 +121,7 @@ class BrowserOAuthProvider implements OAuthClientProvider {
|
|
|
123
121
|
// Load client info if using extended store
|
|
124
122
|
if (this._isOAuthStore(this._store)) {
|
|
125
123
|
const clientInfo = await this._store.getClient(this._storeKey);
|
|
126
|
-
if (clientInfo) {
|
|
124
|
+
if (clientInfo?.clientId) {
|
|
127
125
|
this._clientInfo = {
|
|
128
126
|
client_id: clientInfo.clientId,
|
|
129
127
|
client_secret: clientInfo.clientSecret,
|
|
@@ -135,20 +133,24 @@ class BrowserOAuthProvider implements OAuthClientProvider {
|
|
|
135
133
|
|
|
136
134
|
// Load session state
|
|
137
135
|
const session = await this._store.getSession(this._storeKey);
|
|
138
|
-
if (session) {
|
|
136
|
+
if (session?.codeVerifier) {
|
|
139
137
|
this._codeVerifier = session.codeVerifier;
|
|
140
138
|
}
|
|
141
139
|
}
|
|
142
140
|
|
|
143
141
|
this._tokensLoaded = true;
|
|
144
|
-
} catch
|
|
145
|
-
|
|
142
|
+
} catch {
|
|
143
|
+
// Ignore store errors; fallback to fresh auth
|
|
146
144
|
this._tokensLoaded = true;
|
|
147
145
|
}
|
|
148
146
|
}
|
|
149
147
|
|
|
150
148
|
private _isOAuthStore(store: any): store is OAuthStore {
|
|
151
|
-
return
|
|
149
|
+
return (
|
|
150
|
+
typeof store.getClient === "function" &&
|
|
151
|
+
typeof store.setClient === "function" &&
|
|
152
|
+
typeof store.getSession === "function"
|
|
153
|
+
);
|
|
152
154
|
}
|
|
153
155
|
|
|
154
156
|
get redirectUrl(): string {
|
|
@@ -218,20 +220,9 @@ class BrowserOAuthProvider implements OAuthClientProvider {
|
|
|
218
220
|
|
|
219
221
|
// Check expiry using stored expiresAt from initial token response
|
|
220
222
|
const stored = await this._store.get(this._storeKey);
|
|
221
|
-
if (stored?.expiresAt) {
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
if (this._tokens.refresh_token) {
|
|
225
|
-
try {
|
|
226
|
-
await this._refreshTokens();
|
|
227
|
-
return this._tokens;
|
|
228
|
-
} catch (error) {
|
|
229
|
-
console.warn("Token refresh failed:", error);
|
|
230
|
-
return undefined;
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
return undefined;
|
|
234
|
-
}
|
|
223
|
+
if (stored?.expiresAt && Date.now() >= stored.expiresAt - 60000) {
|
|
224
|
+
// Token expired (with 60s buffer). Refresh not yet implemented — trigger re-auth.
|
|
225
|
+
return undefined;
|
|
235
226
|
}
|
|
236
227
|
|
|
237
228
|
return this._tokens;
|
|
@@ -269,59 +260,29 @@ class BrowserOAuthProvider implements OAuthClientProvider {
|
|
|
269
260
|
}
|
|
270
261
|
|
|
271
262
|
private async _doAuthorization(authorizationUrl: URL): Promise<void> {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
this._pendingAuthCode =
|
|
292
|
-
this._pendingAuthState =
|
|
293
|
-
|
|
294
|
-
/** Auto-cleanup stale auth codes after timeout to prevent leaks. */
|
|
295
|
-
setTimeout(() => {
|
|
296
|
-
if (this._pendingAuthCode === result.code) {
|
|
297
|
-
this._pendingAuthCode = undefined;
|
|
298
|
-
this._pendingAuthState = undefined;
|
|
299
|
-
}
|
|
300
|
-
}, this._authTimeout);
|
|
301
|
-
|
|
302
|
-
return;
|
|
303
|
-
} catch (error) {
|
|
304
|
-
lastError = error instanceof Error ? error : new Error(String(error));
|
|
305
|
-
|
|
306
|
-
if (error instanceof OAuthError) {
|
|
307
|
-
throw error; // OAuth errors are user-actionable, don't retry
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
if (attempt < maxRetries) {
|
|
311
|
-
console.warn(
|
|
312
|
-
`Auth attempt ${attempt + 1} failed, retrying...`,
|
|
313
|
-
error,
|
|
314
|
-
);
|
|
315
|
-
await new Promise((resolve) =>
|
|
316
|
-
setTimeout(resolve, 1000 * (attempt + 1)),
|
|
317
|
-
);
|
|
318
|
-
}
|
|
263
|
+
const result = await getAuthCode({
|
|
264
|
+
authorizationUrl: authorizationUrl.href,
|
|
265
|
+
port: this._port,
|
|
266
|
+
hostname: this._hostname,
|
|
267
|
+
callbackPath: this._callbackPath,
|
|
268
|
+
timeout: this._authTimeout,
|
|
269
|
+
launch: this._launch,
|
|
270
|
+
successHtml: this._successHtml,
|
|
271
|
+
errorHtml: this._errorHtml,
|
|
272
|
+
onRequest: this._onRequest,
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
/** Cache auth code for SDK's separate token exchange call. */
|
|
276
|
+
this._pendingAuthCode = result.code;
|
|
277
|
+
this._pendingAuthState = result.state;
|
|
278
|
+
|
|
279
|
+
/** Auto-cleanup stale auth codes after timeout to prevent leaks. */
|
|
280
|
+
setTimeout(() => {
|
|
281
|
+
if (this._pendingAuthCode === result.code) {
|
|
282
|
+
this._pendingAuthCode = undefined;
|
|
283
|
+
this._pendingAuthState = undefined;
|
|
319
284
|
}
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
throw new Error(
|
|
323
|
-
`OAuth authorization failed after ${maxRetries + 1} attempts: ${lastError?.message}`,
|
|
324
|
-
);
|
|
285
|
+
}, this._authTimeout);
|
|
325
286
|
}
|
|
326
287
|
|
|
327
288
|
async saveCodeVerifier(codeVerifier: string): Promise<void> {
|
|
@@ -348,8 +309,18 @@ class BrowserOAuthProvider implements OAuthClientProvider {
|
|
|
348
309
|
scope: "all" | "client" | "tokens" | "verifier",
|
|
349
310
|
): Promise<void> {
|
|
350
311
|
/**
|
|
351
|
-
*
|
|
352
|
-
*
|
|
312
|
+
* SDK behavioral dependency: The MCP SDK may call invalidate("all") during
|
|
313
|
+
* token exchange if the authorization server returns an error. The call
|
|
314
|
+
* sequence we protect against:
|
|
315
|
+
*
|
|
316
|
+
* 1. SDK calls getPendingAuthCode() → sets _isExchangingCode = true
|
|
317
|
+
* 2. SDK calls codeVerifier() to build token request
|
|
318
|
+
* 3. SDK sends token exchange request to authorization server
|
|
319
|
+
* 4. Server returns error → SDK calls invalidateCredentials("all")
|
|
320
|
+
* 5. SDK retries from step 2, but verifier is gone → permanent failure
|
|
321
|
+
*
|
|
322
|
+
* Without this guard, step 4 would clear the verifier needed for step 5.
|
|
323
|
+
* The flag is reset when SDK calls invalidate("client") after exchange.
|
|
353
324
|
*/
|
|
354
325
|
if (scope === "all" && this._isExchangingCode) {
|
|
355
326
|
/** Only clear tokens; preserve client and verifier for ongoing exchange. */
|
|
@@ -373,9 +344,8 @@ class BrowserOAuthProvider implements OAuthClientProvider {
|
|
|
373
344
|
case "client":
|
|
374
345
|
this._clientInfo = undefined;
|
|
375
346
|
if (this._isOAuthStore(this._store)) {
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
});
|
|
347
|
+
// Empty clientId signals deletion (OAuthStore has no deleteClient method)
|
|
348
|
+
await this._store.setClient(this._storeKey, { clientId: "" });
|
|
379
349
|
}
|
|
380
350
|
break;
|
|
381
351
|
case "tokens":
|
|
@@ -385,6 +355,7 @@ class BrowserOAuthProvider implements OAuthClientProvider {
|
|
|
385
355
|
case "verifier":
|
|
386
356
|
this._codeVerifier = undefined;
|
|
387
357
|
if (this._isOAuthStore(this._store)) {
|
|
358
|
+
// Empty session signals deletion (OAuthStore has no deleteSession method)
|
|
388
359
|
await this._store.setSession(this._storeKey, {});
|
|
389
360
|
}
|
|
390
361
|
break;
|
|
@@ -411,7 +382,7 @@ class BrowserOAuthProvider implements OAuthClientProvider {
|
|
|
411
382
|
state: this._pendingAuthState,
|
|
412
383
|
};
|
|
413
384
|
|
|
414
|
-
/**
|
|
385
|
+
/** Protect verifier from SDK's invalidate("all") during exchange. */
|
|
415
386
|
this._isExchangingCode = true;
|
|
416
387
|
|
|
417
388
|
this._pendingAuthCode = undefined;
|
|
@@ -422,36 +393,5 @@ class BrowserOAuthProvider implements OAuthClientProvider {
|
|
|
422
393
|
return undefined;
|
|
423
394
|
}
|
|
424
395
|
|
|
425
|
-
private async _refreshTokens(): Promise<void> {
|
|
426
|
-
/** Serialize refresh attempts to prevent token corruption. */
|
|
427
|
-
if (this._refreshInProgress) {
|
|
428
|
-
await this._refreshInProgress;
|
|
429
|
-
return;
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
this._refreshInProgress = this._doRefreshTokens();
|
|
433
|
-
try {
|
|
434
|
-
await this._refreshInProgress;
|
|
435
|
-
} finally {
|
|
436
|
-
this._refreshInProgress = undefined;
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
private async _doRefreshTokens(): Promise<void> {
|
|
441
|
-
if (!this._tokens?.refresh_token) {
|
|
442
|
-
throw new Error("No refresh token available");
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
const clientInfo = await this.clientInformation();
|
|
446
|
-
if (!clientInfo?.client_id) {
|
|
447
|
-
throw new Error("No client information available for refresh");
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
/** TODO: Implement refresh when token endpoint URL is available from server metadata. */
|
|
451
|
-
throw new Error(
|
|
452
|
-
"Token refresh not yet implemented - requires token endpoint URL",
|
|
453
|
-
);
|
|
454
|
-
}
|
|
455
|
-
|
|
456
396
|
/** SDK constraint: addClientAuthentication() must not exist on this class. */
|
|
457
397
|
}
|
package/src/index.ts
CHANGED
|
@@ -6,13 +6,12 @@
|
|
|
6
6
|
* Creates a temporary localhost server to capture OAuth callbacks for CLI/desktop apps.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import open from "open";
|
|
10
9
|
import { OAuthError } from "./errors";
|
|
11
10
|
import { createCallbackServer, type CallbackResult } from "./server";
|
|
12
11
|
import type { GetAuthCodeOptions } from "./types";
|
|
13
12
|
|
|
14
13
|
export type { CallbackResult, CallbackServer, ServerOptions } from "./server";
|
|
15
|
-
export { OAuthError } from "./errors";
|
|
14
|
+
export { OAuthError, TimeoutError } from "./errors";
|
|
16
15
|
export type { GetAuthCodeOptions } from "./types";
|
|
17
16
|
|
|
18
17
|
// Storage implementations (backward compatibility)
|
|
@@ -25,7 +24,7 @@ export { mcp };
|
|
|
25
24
|
|
|
26
25
|
/**
|
|
27
26
|
* Captures OAuth authorization code via localhost callback.
|
|
28
|
-
*
|
|
27
|
+
* Starts a temporary server, optionally launches auth URL, waits for redirect.
|
|
29
28
|
*
|
|
30
29
|
* @param input - Auth URL string or GetAuthCodeOptions with config
|
|
31
30
|
* @returns Promise<CallbackResult> with code and params
|
|
@@ -34,17 +33,18 @@ export { mcp };
|
|
|
34
33
|
*
|
|
35
34
|
* @example
|
|
36
35
|
* ```typescript
|
|
37
|
-
*
|
|
38
|
-
* const result = await getAuthCode('https://oauth.example.com/authorize?...');
|
|
39
|
-
* console.log('Code:', result.code);
|
|
36
|
+
* import open from "open";
|
|
40
37
|
*
|
|
41
|
-
* //
|
|
38
|
+
* // With browser launch
|
|
42
39
|
* const result = await getAuthCode({
|
|
43
40
|
* authorizationUrl: 'https://oauth.example.com/authorize?...',
|
|
44
|
-
*
|
|
45
|
-
* timeout: 60000,
|
|
46
|
-
* onRequest: (req) => console.log('Request:', req.url)
|
|
41
|
+
* launch: open,
|
|
47
42
|
* });
|
|
43
|
+
*
|
|
44
|
+
* // Headless (print URL, let user open manually)
|
|
45
|
+
* const url = 'https://oauth.example.com/authorize?...';
|
|
46
|
+
* console.log('Open:', url);
|
|
47
|
+
* const result = await getAuthCode({ authorizationUrl: url });
|
|
48
48
|
* ```
|
|
49
49
|
*/
|
|
50
50
|
export async function getAuthCode(
|
|
@@ -57,13 +57,13 @@ export async function getAuthCode(
|
|
|
57
57
|
authorizationUrl,
|
|
58
58
|
port = 3000,
|
|
59
59
|
hostname = "localhost",
|
|
60
|
-
openBrowser = true,
|
|
61
60
|
timeout = 30000,
|
|
62
61
|
callbackPath = "/callback",
|
|
63
62
|
successHtml,
|
|
64
63
|
errorHtml,
|
|
65
64
|
signal,
|
|
66
65
|
onRequest,
|
|
66
|
+
launch,
|
|
67
67
|
} = options;
|
|
68
68
|
|
|
69
69
|
const server = createCallbackServer();
|
|
@@ -78,23 +78,8 @@ export async function getAuthCode(
|
|
|
78
78
|
onRequest,
|
|
79
79
|
});
|
|
80
80
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
} else {
|
|
84
|
-
// Test mode: trigger mock provider redirect without browser
|
|
85
|
-
fetch(authorizationUrl)
|
|
86
|
-
.then(async (response) => {
|
|
87
|
-
if (response.status === 302 || response.status === 301) {
|
|
88
|
-
const location = response.headers.get("Location");
|
|
89
|
-
if (location) {
|
|
90
|
-
await fetch(location);
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
})
|
|
94
|
-
.catch(() => {
|
|
95
|
-
// Ignore - tests may lack mock provider
|
|
96
|
-
});
|
|
97
|
-
}
|
|
81
|
+
// Best-effort launch: fire-and-forget, swallow errors
|
|
82
|
+
if (launch) void Promise.resolve(launch(authorizationUrl)).catch(() => {});
|
|
98
83
|
|
|
99
84
|
const result = await server.waitForCallback(callbackPath, timeout);
|
|
100
85
|
|
package/src/mcp-types.ts
CHANGED
|
@@ -73,14 +73,14 @@ export interface BrowserAuthOptions {
|
|
|
73
73
|
callbackPath?: string; // Default: "/callback"
|
|
74
74
|
|
|
75
75
|
store?: TokenStore; // Default: in-memory (lost on restart). Use OAuthStore for persistence.
|
|
76
|
-
storeKey?: string; // Storage key
|
|
76
|
+
storeKey?: string; // Storage key for token isolation. Default: "mcp-tokens"
|
|
77
77
|
|
|
78
|
-
|
|
78
|
+
/** Callback to launch the authorization URL. Omit for headless mode.
|
|
79
|
+
* Returns `unknown` to accept any launcher (e.g., `open` → `Promise<ChildProcess>`). */
|
|
80
|
+
launch?: (url: string) => unknown;
|
|
79
81
|
|
|
80
82
|
authTimeout?: number; // Max wait for user authorization. Default: 300000ms (5 min)
|
|
81
83
|
|
|
82
|
-
usePKCE?: boolean; // Enable PKCE (RFC 7636). Default: true. Required for public clients.
|
|
83
|
-
|
|
84
84
|
/** Custom HTML templates for callback pages. Supports {{placeholders}}. */
|
|
85
85
|
successHtml?: string;
|
|
86
86
|
errorHtml?: string;
|
package/src/server.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import type { Server as HttpServer } from "node:http";
|
|
9
9
|
import type { IncomingMessage } from "node:http";
|
|
10
|
+
import { TimeoutError } from "./errors";
|
|
10
11
|
import { successTemplate, renderError } from "./templates";
|
|
11
12
|
|
|
12
13
|
/**
|
|
@@ -161,36 +162,36 @@ abstract class BaseCallbackServer implements CallbackServer {
|
|
|
161
162
|
path: string,
|
|
162
163
|
timeout: number,
|
|
163
164
|
): Promise<CallbackResult> {
|
|
164
|
-
if (
|
|
165
|
+
if (!path) throw new Error("Callback path is required");
|
|
166
|
+
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
|
167
|
+
|
|
168
|
+
if (this.callbackListeners.has(normalizedPath))
|
|
165
169
|
return Promise.reject(
|
|
166
|
-
new Error(
|
|
170
|
+
new Error(
|
|
171
|
+
`A listener for the path "${normalizedPath}" is already active.`,
|
|
172
|
+
),
|
|
167
173
|
);
|
|
168
174
|
|
|
169
175
|
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
170
176
|
|
|
171
177
|
try {
|
|
172
|
-
// Race a promise that waits for the callback against a promise that rejects on timeout.
|
|
173
178
|
return await Promise.race([
|
|
174
|
-
// This promise is resolved or rejected by the handleRequest method.
|
|
175
179
|
new Promise<CallbackResult>((resolve, reject) => {
|
|
176
|
-
this.callbackListeners.set(
|
|
180
|
+
this.callbackListeners.set(normalizedPath, { resolve, reject });
|
|
177
181
|
}),
|
|
178
|
-
// This promise rejects after the specified timeout.
|
|
179
182
|
new Promise<CallbackResult>((_, reject) => {
|
|
180
183
|
timeoutId = setTimeout(() => {
|
|
181
184
|
reject(
|
|
182
|
-
new
|
|
183
|
-
`OAuth callback timeout after ${timeout}ms waiting for ${
|
|
185
|
+
new TimeoutError(
|
|
186
|
+
`OAuth callback timeout after ${timeout}ms waiting for ${normalizedPath}`,
|
|
184
187
|
),
|
|
185
188
|
);
|
|
186
189
|
}, timeout);
|
|
187
190
|
}),
|
|
188
191
|
]);
|
|
189
192
|
} finally {
|
|
190
|
-
// CRITICAL: Always clean up the listener and timeout to prevent memory leaks
|
|
191
|
-
// and allow the process to exit cleanly.
|
|
192
193
|
if (timeoutId) clearTimeout(timeoutId);
|
|
193
|
-
this.callbackListeners.delete(
|
|
194
|
+
this.callbackListeners.delete(normalizedPath);
|
|
194
195
|
}
|
|
195
196
|
}
|
|
196
197
|
|
|
@@ -250,11 +251,6 @@ class DenoCallbackServer extends BaseCallbackServer {
|
|
|
250
251
|
const { port, hostname = "localhost" } = options;
|
|
251
252
|
this.abortController = new AbortController();
|
|
252
253
|
|
|
253
|
-
// The user's signal will abort our internal controller.
|
|
254
|
-
options.signal?.addEventListener("abort", () =>
|
|
255
|
-
this.abortController?.abort(),
|
|
256
|
-
);
|
|
257
|
-
|
|
258
254
|
Deno.serve(
|
|
259
255
|
{ port, hostname, signal: this.abortController.signal },
|
|
260
256
|
(request: Request) => this.handleRequest(request),
|
|
@@ -285,22 +281,20 @@ class NodeCallbackServer extends BaseCallbackServer {
|
|
|
285
281
|
const request = this.nodeToWebRequest(req, port, hostname);
|
|
286
282
|
const response = this.handleRequest(request);
|
|
287
283
|
|
|
284
|
+
res.shouldKeepAlive = false;
|
|
285
|
+
|
|
288
286
|
res.writeHead(
|
|
289
287
|
response.status,
|
|
290
288
|
Object.fromEntries(response.headers.entries()),
|
|
291
289
|
);
|
|
292
290
|
const body = await response.text();
|
|
293
291
|
res.end(body);
|
|
294
|
-
} catch
|
|
292
|
+
} catch {
|
|
295
293
|
res.writeHead(500);
|
|
296
294
|
res.end("Internal Server Error");
|
|
297
295
|
}
|
|
298
296
|
});
|
|
299
297
|
|
|
300
|
-
// Tie server closing to the abort signal if provided.
|
|
301
|
-
if (options.signal)
|
|
302
|
-
options.signal.addEventListener("abort", () => this.server?.close());
|
|
303
|
-
|
|
304
298
|
this.server.listen(port, hostname, () => resolve());
|
|
305
299
|
this.server.on("error", reject);
|
|
306
300
|
});
|
|
@@ -308,7 +302,6 @@ class NodeCallbackServer extends BaseCallbackServer {
|
|
|
308
302
|
|
|
309
303
|
protected async stopServer(): Promise<void> {
|
|
310
304
|
if (!this.server) return;
|
|
311
|
-
this.server.closeAllConnections();
|
|
312
305
|
return new Promise((resolve) => {
|
|
313
306
|
this.server?.close(() => {
|
|
314
307
|
this.server = undefined;
|
package/src/types.ts
CHANGED
|
@@ -34,17 +34,26 @@ export interface GetAuthCodeOptions {
|
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
36
|
* Timeout in milliseconds to wait for OAuth callback.
|
|
37
|
-
*
|
|
37
|
+
* Starts when the callback server is ready; launch timing does not delay it.
|
|
38
38
|
* @default 30000
|
|
39
39
|
*/
|
|
40
40
|
timeout?: number;
|
|
41
41
|
|
|
42
42
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
43
|
+
* Optional callback to launch the authorization URL.
|
|
44
|
+
* Called after the callback server starts, best-effort (errors are swallowed).
|
|
45
|
+
* If omitted, the library does nothing — caller is responsible for opening the URL.
|
|
46
|
+
*
|
|
47
|
+
* Returns `unknown` (not `void`) to accept any launcher without casting—e.g.,
|
|
48
|
+
* the `open` package returns `Promise<ChildProcess>`. Return value is ignored.
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* ```typescript
|
|
52
|
+
* import open from "open";
|
|
53
|
+
* await getAuthCode({ authorizationUrl: url, launch: open });
|
|
54
|
+
* ```
|
|
46
55
|
*/
|
|
47
|
-
|
|
56
|
+
launch?: (url: string) => unknown;
|
|
48
57
|
|
|
49
58
|
/**
|
|
50
59
|
* Custom HTML content to display when authorization is successful.
|