@aiwerk/mcp-bridge 2.6.1 → 2.6.3

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.
@@ -378,6 +378,7 @@ async function cmdAuth(args, logger) {
378
378
  deviceAuthorizationUrl: deviceAuth.deviceAuthorizationUrl,
379
379
  tokenUrl: deviceAuth.tokenUrl,
380
380
  clientId: deviceAuth.clientId,
381
+ clientSecret: deviceAuth.clientSecret,
381
382
  scopes: deviceAuth.scopes,
382
383
  }, logger);
383
384
  }
@@ -29,6 +29,7 @@ export interface DeviceCodeConfig {
29
29
  deviceAuthorizationUrl: string;
30
30
  tokenUrl: string;
31
31
  clientId: string;
32
+ clientSecret?: string;
32
33
  scopes?: string[];
33
34
  /** Skip opening browser (for tests). */
34
35
  skipBrowser?: boolean;
@@ -40,4 +41,4 @@ export interface DeviceCodeConfig {
40
41
  * 2. Display user_code and verification_uri to the user
41
42
  * 3. Poll tokenUrl until the user authorizes or the code expires
42
43
  */
43
- export declare function performDeviceCodeLogin(serverName: string, config: DeviceCodeConfig, logger: Logger): Promise<StoredToken>;
44
+ export declare function performDeviceCodeLogin(serverName: string, config: DeviceCodeConfig, logger: Logger, signal?: AbortSignal): Promise<StoredToken>;
@@ -1,6 +1,6 @@
1
1
  import { createServer } from "http";
2
2
  import { randomBytes, createHash } from "crypto";
3
- import { exec } from "child_process";
3
+ import { execFile } from "child_process";
4
4
  import { platform } from "os";
5
5
  /** Escape HTML special characters to prevent XSS in callback responses. */
6
6
  function escapeHtml(str) {
@@ -35,23 +35,31 @@ export function computeCodeChallenge(verifier) {
35
35
  /**
36
36
  * Open a URL in the default browser using platform-specific commands.
37
37
  */
38
+ /**
39
+ * Open a URL in the default browser.
40
+ * Uses execFile (no shell) to prevent shell injection from untrusted URLs
41
+ * (e.g. verification_uri_complete from an external authorization server).
42
+ */
38
43
  function openBrowser(url, logger) {
39
44
  const os = platform();
40
- let cmd;
41
45
  if (os === "darwin") {
42
- cmd = `open "${url}"`;
46
+ execFile("open", [url], (err) => {
47
+ if (err)
48
+ logger.warn(`[mcp-bridge] Could not open browser automatically. Please visit:\n${url}`);
49
+ });
43
50
  }
44
51
  else if (os === "win32") {
45
- cmd = `start "" "${url}"`;
52
+ execFile("cmd", ["/c", "start", "", url], (err) => {
53
+ if (err)
54
+ logger.warn(`[mcp-bridge] Could not open browser automatically. Please visit:\n${url}`);
55
+ });
46
56
  }
47
57
  else {
48
- cmd = `xdg-open "${url}"`;
58
+ execFile("xdg-open", [url], (err) => {
59
+ if (err)
60
+ logger.warn(`[mcp-bridge] Could not open browser automatically. Please visit:\n${url}`);
61
+ });
49
62
  }
50
- exec(cmd, (err) => {
51
- if (err) {
52
- logger.warn(`[mcp-bridge] Could not open browser automatically. Please visit:\n${url}`);
53
- }
54
- });
55
63
  }
56
64
  const DEFAULT_EXPIRES_IN = 3600;
57
65
  const EXPIRY_BUFFER_SECONDS = 60;
@@ -166,10 +174,12 @@ const SLOW_DOWN_INCREMENT_S = 5;
166
174
  * 2. Display user_code and verification_uri to the user
167
175
  * 3. Poll tokenUrl until the user authorizes or the code expires
168
176
  */
169
- export async function performDeviceCodeLogin(serverName, config, logger) {
177
+ export async function performDeviceCodeLogin(serverName, config, logger, signal) {
170
178
  // Step 1: Request device code
171
179
  const formData = new URLSearchParams();
172
180
  formData.set("client_id", config.clientId);
181
+ if (config.clientSecret)
182
+ formData.set("client_secret", config.clientSecret);
173
183
  if (config.scopes?.length)
174
184
  formData.set("scope", config.scopes.join(" "));
175
185
  const deviceResponse = await fetch(config.deviceAuthorizationUrl, {
@@ -211,17 +221,39 @@ export async function performDeviceCodeLogin(serverName, config, logger) {
211
221
  // Step 3: Poll for token
212
222
  const deadline = Date.now() + expiresInS * 1000;
213
223
  while (Date.now() < deadline) {
224
+ if (signal?.aborted) {
225
+ throw new Error("Device code login aborted");
226
+ }
214
227
  await sleep(intervalS * 1000);
228
+ if (signal?.aborted) {
229
+ throw new Error("Device code login aborted");
230
+ }
215
231
  const tokenForm = new URLSearchParams();
216
232
  tokenForm.set("grant_type", "urn:ietf:params:oauth:grant-type:device_code");
217
233
  tokenForm.set("device_code", deviceCode);
218
234
  tokenForm.set("client_id", config.clientId);
235
+ if (config.clientSecret)
236
+ tokenForm.set("client_secret", config.clientSecret);
219
237
  const tokenResponse = await fetch(config.tokenUrl, {
220
238
  method: "POST",
221
239
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
222
240
  body: tokenForm.toString(),
241
+ signal,
223
242
  });
224
- const tokenPayload = (await tokenResponse.json());
243
+ // Guard against non-JSON responses (e.g. 500 HTML error pages)
244
+ const contentType = tokenResponse.headers.get("content-type") || "";
245
+ if (!tokenResponse.ok && !contentType.includes("json")) {
246
+ logger.warn(`[mcp-bridge] Token poll returned HTTP ${tokenResponse.status} (non-JSON), retrying...`);
247
+ continue;
248
+ }
249
+ let tokenPayload;
250
+ try {
251
+ tokenPayload = (await tokenResponse.json());
252
+ }
253
+ catch {
254
+ logger.warn("[mcp-bridge] Token poll returned invalid JSON, retrying...");
255
+ continue;
256
+ }
225
257
  if (tokenPayload.error) {
226
258
  if (tokenPayload.error === "authorization_pending") {
227
259
  continue;
@@ -18,13 +18,14 @@ export interface DeviceCodeOAuth2Config {
18
18
  grantType: "device_code";
19
19
  tokenUrl: string;
20
20
  clientId: string;
21
+ clientSecret?: string;
21
22
  scopes?: string[];
22
23
  }
23
24
  export declare class OAuth2TokenManager {
24
25
  private readonly logger;
25
26
  private readonly tokenCache;
26
27
  private readonly inflight;
27
- private readonly authCodeInflight;
28
+ private readonly tokenRefreshInflight;
28
29
  private readonly tokenStore?;
29
30
  constructor(logger: Logger, tokenStore?: TokenStore);
30
31
  getToken(config: OAuth2Config): Promise<string>;
@@ -40,9 +41,12 @@ export declare class OAuth2TokenManager {
40
41
  * Checks TokenStore, refreshes if expired, throws if unavailable.
41
42
  */
42
43
  getTokenForDeviceCode(serverName: string, config: DeviceCodeOAuth2Config): Promise<string>;
43
- private doDeviceCodeRefresh;
44
+ /**
45
+ * Shared refresh logic for both auth_code and device_code flows.
46
+ * Takes a refreshFn that performs the actual token exchange.
47
+ */
48
+ private doTokenRefresh;
44
49
  private refreshDeviceCodeToken;
45
- private doAuthCodeRefresh;
46
50
  private refreshAuthCodeToken;
47
51
  private makeKey;
48
52
  private fetchToken;
@@ -4,7 +4,7 @@ export class OAuth2TokenManager {
4
4
  logger;
5
5
  tokenCache = new Map();
6
6
  inflight = new Map();
7
- authCodeInflight = new Map();
7
+ tokenRefreshInflight = new Map();
8
8
  tokenStore;
9
9
  constructor(logger, tokenStore) {
10
10
  this.logger = logger;
@@ -62,17 +62,17 @@ export class OAuth2TokenManager {
62
62
  // Token expired — try refresh with inflight dedup to avoid
63
63
  // concurrent requests both trying to refresh the same token
64
64
  // (the second refresh would fail because the first invalidated the refresh_token)
65
- const existingInflight = this.authCodeInflight.get(serverName);
65
+ const existingInflight = this.tokenRefreshInflight.get(serverName);
66
66
  if (existingInflight) {
67
67
  return existingInflight;
68
68
  }
69
- const refreshPromise = this.doAuthCodeRefresh(serverName, stored, config);
70
- this.authCodeInflight.set(serverName, refreshPromise);
69
+ const refreshPromise = this.doTokenRefresh(serverName, stored, (s) => this.refreshAuthCodeToken(s, config), "Auth code");
70
+ this.tokenRefreshInflight.set(serverName, refreshPromise);
71
71
  try {
72
72
  return await refreshPromise;
73
73
  }
74
74
  finally {
75
- this.authCodeInflight.delete(serverName);
75
+ this.tokenRefreshInflight.delete(serverName);
76
76
  }
77
77
  }
78
78
  /**
@@ -94,28 +94,32 @@ export class OAuth2TokenManager {
94
94
  return stored.accessToken;
95
95
  }
96
96
  // Token expired — try refresh with inflight dedup
97
- const existingInflight = this.authCodeInflight.get(serverName);
97
+ const existingInflight = this.tokenRefreshInflight.get(serverName);
98
98
  if (existingInflight) {
99
99
  return existingInflight;
100
100
  }
101
- const refreshPromise = this.doDeviceCodeRefresh(serverName, stored, config);
102
- this.authCodeInflight.set(serverName, refreshPromise);
101
+ const refreshPromise = this.doTokenRefresh(serverName, stored, (s) => this.refreshDeviceCodeToken(s, config), "Device code");
102
+ this.tokenRefreshInflight.set(serverName, refreshPromise);
103
103
  try {
104
104
  return await refreshPromise;
105
105
  }
106
106
  finally {
107
- this.authCodeInflight.delete(serverName);
107
+ this.tokenRefreshInflight.delete(serverName);
108
108
  }
109
109
  }
110
- async doDeviceCodeRefresh(serverName, stored, config) {
110
+ /**
111
+ * Shared refresh logic for both auth_code and device_code flows.
112
+ * Takes a refreshFn that performs the actual token exchange.
113
+ */
114
+ async doTokenRefresh(serverName, stored, refreshFn, flowName) {
111
115
  if (stored.refreshToken) {
112
116
  try {
113
- const refreshed = await this.refreshDeviceCodeToken(stored, config);
117
+ const refreshed = await refreshFn(stored);
114
118
  this.tokenStore.save(serverName, refreshed);
115
119
  return refreshed.accessToken;
116
120
  }
117
121
  catch (err) {
118
- this.logger.warn("[mcp-bridge] Device code token refresh failed:", err);
122
+ this.logger.warn(`[mcp-bridge] ${flowName} token refresh failed:`, err);
119
123
  }
120
124
  }
121
125
  // Refresh failed or no refresh token
@@ -129,6 +133,8 @@ export class OAuth2TokenManager {
129
133
  formData.set("grant_type", "refresh_token");
130
134
  formData.set("refresh_token", stored.refreshToken);
131
135
  formData.set("client_id", config.clientId);
136
+ if (config.clientSecret)
137
+ formData.set("client_secret", config.clientSecret);
132
138
  if (config.scopes?.length)
133
139
  formData.set("scope", config.scopes.join(" "));
134
140
  const response = await fetch(stored.tokenUrl, {
@@ -156,23 +162,6 @@ export class OAuth2TokenManager {
156
162
  scopes: config.scopes,
157
163
  };
158
164
  }
159
- async doAuthCodeRefresh(serverName, stored, config) {
160
- if (stored.refreshToken) {
161
- try {
162
- const refreshed = await this.refreshAuthCodeToken(stored, config);
163
- this.tokenStore.save(serverName, refreshed);
164
- return refreshed.accessToken;
165
- }
166
- catch (err) {
167
- this.logger.warn("[mcp-bridge] Auth code token refresh failed:", err);
168
- }
169
- }
170
- // Refresh failed or no refresh token
171
- this.tokenStore.remove(serverName);
172
- const error = new Error(`Authentication expired for server "${serverName}". Run: mcp-bridge auth login ${serverName}`);
173
- error.code = -32006;
174
- throw error;
175
- }
176
165
  async refreshAuthCodeToken(stored, config) {
177
166
  const formData = new URLSearchParams();
178
167
  formData.set("grant_type", "refresh_token");
@@ -235,6 +235,7 @@ export function resolveDeviceCodeOAuth2Config(config, extraEnv, envFallback) {
235
235
  grantType: "device_code",
236
236
  tokenUrl: resolveEnvVars(auth.tokenUrl, "oauth2 tokenUrl", extraEnv, envFallback),
237
237
  clientId: resolveEnvVars(auth.clientId, "oauth2 clientId", extraEnv, envFallback),
238
+ ...(auth.clientSecret ? { clientSecret: resolveEnvVars(auth.clientSecret, "oauth2 clientSecret", extraEnv, envFallback) } : {}),
238
239
  ...(scopes && scopes.length > 0 ? { scopes } : {}),
239
240
  };
240
241
  }
@@ -32,6 +32,7 @@ export type HttpAuthConfig = {
32
32
  deviceAuthorizationUrl: string;
33
33
  tokenUrl: string;
34
34
  clientId: string;
35
+ clientSecret?: string;
35
36
  scopes?: string[];
36
37
  };
37
38
  export interface RetryConfig {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiwerk/mcp-bridge",
3
- "version": "2.6.1",
3
+ "version": "2.6.3",
4
4
  "description": "Standalone MCP server that multiplexes multiple MCP servers into one interface",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",