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.
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAMH;;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;AA8RD;;;;GAIG;AACH,wBAAgB,oBAAoB,IAAI,cAAc,CAKrD"}
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
- * If no callback is received within this time, the operation will fail.
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
- * Whether to automatically open the authorization URL in the user's default browser.
36
- * Set to false for testing or when you want to handle browser opening manually.
37
- * @default true
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
- openBrowser?: boolean;
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.
@@ -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;;;;OAIG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IAEtB;;;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"}
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": "1.2.5",
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.2",
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": "^24.10.9",
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.0",
90
- "publint": "^0.3.16",
90
+ "prettier": "^3.8.1",
91
+ "publint": "^0.3.17",
92
+ "srcpack": "^0.1.13",
91
93
  "typescript": "^5.9.3",
92
- "vitepress": "^1.6.4",
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:publish": "bunx gh-pages -d docs/.vitepress/dist"
123
+ "docs:deploy": "bunx gh-pages -d docs/.vitepress/dist"
122
124
  }
123
125
  }
@@ -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 _usePKCE: boolean;
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._usePKCE = options.usePKCE ?? true;
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 (error) {
145
- console.warn("Failed to load stored data:", error);
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 typeof store.getClient === "function";
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
- if (Date.now() >= stored.expiresAt - 60000) {
223
- // Expired with 60s buffer
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
- let lastError: Error | undefined;
273
- const maxRetries = 2;
274
-
275
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
276
- try {
277
- const result = await getAuthCode({
278
- authorizationUrl: authorizationUrl.href,
279
- port: this._port,
280
- hostname: this._hostname,
281
- callbackPath: this._callbackPath,
282
- timeout: this._authTimeout,
283
- openBrowser:
284
- typeof this._openBrowser === "boolean" ? this._openBrowser : true,
285
- successHtml: this._successHtml,
286
- errorHtml: this._errorHtml,
287
- onRequest: this._onRequest,
288
- });
289
-
290
- /** Cache auth code for SDK's separate token exchange call. */
291
- this._pendingAuthCode = result.code;
292
- this._pendingAuthState = result.state;
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
- * WORKAROUND: SDK calls invalidate("all") during token exchange.
352
- * Must preserve client info and verifier until exchange completes.
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
- await this._store.setClient(this._storeKey, {
377
- clientId: "",
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
- /** Signal token exchange to protect state in invalidateCredentials(). */
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
- * Opens browser to auth URL, waits for provider redirect to localhost.
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
- * // Simple
38
- * const result = await getAuthCode('https://oauth.example.com/authorize?...');
39
- * console.log('Code:', result.code);
36
+ * import open from "open";
40
37
  *
41
- * // Custom port/timeout
38
+ * // With browser launch
42
39
  * const result = await getAuthCode({
43
40
  * authorizationUrl: 'https://oauth.example.com/authorize?...',
44
- * port: 8080,
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
- if (openBrowser) {
82
- await open(authorizationUrl);
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 prefix. Default: "mcp-tokens"
76
+ storeKey?: string; // Storage key for token isolation. Default: "mcp-tokens"
77
77
 
78
- openBrowser?: boolean | string; // Default: true. Set false for headless/CI environments.
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 (this.callbackListeners.has(path))
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(`A listener for the path "${path}" is already active.`),
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(path, { resolve, reject });
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 Error(
183
- `OAuth callback timeout after ${timeout}ms waiting for ${path}`,
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(path);
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 (error) {
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
- * If no callback is received within this time, the operation will fail.
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
- * Whether to automatically open the authorization URL in the user's default browser.
44
- * Set to false for testing or when you want to handle browser opening manually.
45
- * @default true
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
- openBrowser?: boolean;
56
+ launch?: (url: string) => unknown;
48
57
 
49
58
  /**
50
59
  * Custom HTML content to display when authorization is successful.