@aiwerk/mcp-bridge 2.5.3 → 2.6.1
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 +36 -2
- package/dist/bin/mcp-bridge.js +30 -13
- package/dist/src/cli-auth.d.ts +16 -0
- package/dist/src/cli-auth.js +103 -0
- package/dist/src/oauth2-token-manager.d.ts +13 -0
- package/dist/src/oauth2-token-manager.js +81 -0
- package/dist/src/transport-base.d.ts +7 -1
- package/dist/src/transport-base.js +35 -6
- package/dist/src/types.d.ts +7 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -400,7 +400,7 @@ OAuth2 features: automatic token acquisition, caching with expiry-aware refresh,
|
|
|
400
400
|
|
|
401
401
|
**OAuth2 Authorization Code + PKCE** (interactive browser login):
|
|
402
402
|
|
|
403
|
-
For MCP servers behind enterprise SSO or user-level OAuth2 that require browser-based login:
|
|
403
|
+
For MCP servers behind enterprise SSO or user-level OAuth2 that require browser-based login (desktop/laptop):
|
|
404
404
|
|
|
405
405
|
```json
|
|
406
406
|
{
|
|
@@ -429,6 +429,40 @@ Features:
|
|
|
429
429
|
- **Automatic refresh** — tokens refreshed transparently via `refresh_token` grant
|
|
430
430
|
- **Actionable errors** — expired tokens return error with exact CLI command to re-authenticate
|
|
431
431
|
|
|
432
|
+
**OAuth2 Device Code** (headless environments — VPS, Docker, SSH, CI):
|
|
433
|
+
|
|
434
|
+
For environments without a browser. You authenticate on a separate device using a short code:
|
|
435
|
+
|
|
436
|
+
```json
|
|
437
|
+
{
|
|
438
|
+
"auth": {
|
|
439
|
+
"type": "oauth2",
|
|
440
|
+
"grantType": "device_code",
|
|
441
|
+
"deviceAuthorizationUrl": "https://github.com/login/device/code",
|
|
442
|
+
"tokenUrl": "https://github.com/login/oauth/access_token",
|
|
443
|
+
"clientId": "your-app-id",
|
|
444
|
+
"scopes": ["repo", "read:org"]
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
```bash
|
|
450
|
+
mcp-bridge auth login my-server
|
|
451
|
+
# ──────────────────────────────────────────
|
|
452
|
+
# Device authentication for "my-server"
|
|
453
|
+
#
|
|
454
|
+
# 1. Open: https://github.com/login/device
|
|
455
|
+
# 2. Enter code: ABCD-1234
|
|
456
|
+
# ──────────────────────────────────────────
|
|
457
|
+
# Waiting for authorization...
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
Features:
|
|
461
|
+
- **RFC 8628 compliant** — works with GitHub, Google, Microsoft, Auth0, Okta
|
|
462
|
+
- **No local browser needed** — authenticate from phone/laptop, token received on server
|
|
463
|
+
- **Automatic polling** — respects `interval` and `slow_down` responses
|
|
464
|
+
- **Same token persistence** — stored in `~/.mcp-bridge/tokens/` with auto-refresh
|
|
465
|
+
|
|
432
466
|
### Environment variables
|
|
433
467
|
|
|
434
468
|
Secrets go in `~/.mcp-bridge/.env` (chmod 600 on init):
|
|
@@ -556,7 +590,7 @@ For production deployments with high security requirements, consider adding an e
|
|
|
556
590
|
| ✅ | Configurable retries + graceful shutdown | 2.0.0 |
|
|
557
591
|
| ✅ | OAuth2 Client Credentials | 2.1.0 |
|
|
558
592
|
| ✅ | OAuth2 Authorization Code + PKCE | 2.5.0 |
|
|
559
|
-
|
|
|
593
|
+
| ✅ | OAuth2 Device Code flow (headless) | 2.6.0 |
|
|
560
594
|
| 🔜 | Hosted bridge (bridge.aiwerk.ch) | planned |
|
|
561
595
|
| 🔜 | Remote catalog integration | planned |
|
|
562
596
|
| 🔜 | OpenTelemetry / Prometheus metrics | planned |
|
package/dist/bin/mcp-bridge.js
CHANGED
|
@@ -9,7 +9,7 @@ import { StandaloneServer } from "../src/standalone-server.js";
|
|
|
9
9
|
import { PACKAGE_VERSION } from "../src/protocol.js";
|
|
10
10
|
import { checkForUpdate, runUpdate } from "../src/update-checker.js";
|
|
11
11
|
import { FileTokenStore } from "../src/token-store.js";
|
|
12
|
-
import { performAuthCodeLogin } from "../src/cli-auth.js";
|
|
12
|
+
import { performAuthCodeLogin, performDeviceCodeLogin } from "../src/cli-auth.js";
|
|
13
13
|
const __filename = fileURLToPath(import.meta.url);
|
|
14
14
|
const __dirname = dirname(__filename);
|
|
15
15
|
// After tsc, this file lives at dist/bin/mcp-bridge.js.
|
|
@@ -317,7 +317,7 @@ async function cmdAuth(args, logger) {
|
|
|
317
317
|
status = token.refreshToken ? "expired (refresh available)" : "expired";
|
|
318
318
|
}
|
|
319
319
|
}
|
|
320
|
-
else if (grantType === "authorization_code") {
|
|
320
|
+
else if (grantType === "authorization_code" || grantType === "device_code") {
|
|
321
321
|
status = "not authenticated";
|
|
322
322
|
}
|
|
323
323
|
else {
|
|
@@ -366,19 +366,36 @@ async function cmdAuth(args, logger) {
|
|
|
366
366
|
process.exit(1);
|
|
367
367
|
}
|
|
368
368
|
const auth = serverConfig.auth;
|
|
369
|
-
if (!auth || auth.type !== "oauth2" || !("grantType" in auth)
|
|
370
|
-
logger.error(`Server "${serverName}" is not configured for OAuth2 authorization_code
|
|
369
|
+
if (!auth || auth.type !== "oauth2" || !("grantType" in auth)) {
|
|
370
|
+
logger.error(`Server "${serverName}" is not configured for an interactive OAuth2 flow (authorization_code or device_code)`);
|
|
371
|
+
process.exit(1);
|
|
372
|
+
}
|
|
373
|
+
const grantType = auth.grantType;
|
|
374
|
+
let token;
|
|
375
|
+
if (grantType === "device_code") {
|
|
376
|
+
const deviceAuth = auth;
|
|
377
|
+
token = await performDeviceCodeLogin(serverName, {
|
|
378
|
+
deviceAuthorizationUrl: deviceAuth.deviceAuthorizationUrl,
|
|
379
|
+
tokenUrl: deviceAuth.tokenUrl,
|
|
380
|
+
clientId: deviceAuth.clientId,
|
|
381
|
+
scopes: deviceAuth.scopes,
|
|
382
|
+
}, logger);
|
|
383
|
+
}
|
|
384
|
+
else if (grantType === "authorization_code") {
|
|
385
|
+
const authCodeAuth = auth;
|
|
386
|
+
token = await performAuthCodeLogin(serverName, {
|
|
387
|
+
authorizationUrl: authCodeAuth.authorizationUrl,
|
|
388
|
+
tokenUrl: authCodeAuth.tokenUrl,
|
|
389
|
+
clientId: authCodeAuth.clientId,
|
|
390
|
+
clientSecret: authCodeAuth.clientSecret,
|
|
391
|
+
scopes: authCodeAuth.scopes,
|
|
392
|
+
callbackPort: authCodeAuth.callbackPort,
|
|
393
|
+
}, logger);
|
|
394
|
+
}
|
|
395
|
+
else {
|
|
396
|
+
logger.error(`Server "${serverName}" uses grant type "${grantType}" which does not support interactive login`);
|
|
371
397
|
process.exit(1);
|
|
372
398
|
}
|
|
373
|
-
const authCodeAuth = auth;
|
|
374
|
-
const token = await performAuthCodeLogin(serverName, {
|
|
375
|
-
authorizationUrl: authCodeAuth.authorizationUrl,
|
|
376
|
-
tokenUrl: authCodeAuth.tokenUrl,
|
|
377
|
-
clientId: authCodeAuth.clientId,
|
|
378
|
-
clientSecret: authCodeAuth.clientSecret,
|
|
379
|
-
scopes: authCodeAuth.scopes,
|
|
380
|
-
callbackPort: authCodeAuth.callbackPort,
|
|
381
|
-
}, logger);
|
|
382
399
|
tokenStore.save(serverName, token);
|
|
383
400
|
process.stdout.write(`Authentication successful for ${serverName}. Token stored.\n`);
|
|
384
401
|
}
|
package/dist/src/cli-auth.d.ts
CHANGED
|
@@ -25,3 +25,19 @@ export declare function computeCodeChallenge(verifier: string): string;
|
|
|
25
25
|
* 4. Exchange code for tokens
|
|
26
26
|
*/
|
|
27
27
|
export declare function performAuthCodeLogin(serverName: string, authConfig: AuthCodeConfig, logger: Logger): Promise<StoredToken>;
|
|
28
|
+
export interface DeviceCodeConfig {
|
|
29
|
+
deviceAuthorizationUrl: string;
|
|
30
|
+
tokenUrl: string;
|
|
31
|
+
clientId: string;
|
|
32
|
+
scopes?: string[];
|
|
33
|
+
/** Skip opening browser (for tests). */
|
|
34
|
+
skipBrowser?: boolean;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Perform the OAuth2 Device Authorization Grant (RFC 8628).
|
|
38
|
+
*
|
|
39
|
+
* 1. POST to deviceAuthorizationUrl to obtain device_code + user_code
|
|
40
|
+
* 2. Display user_code and verification_uri to the user
|
|
41
|
+
* 3. Poll tokenUrl until the user authorizes or the code expires
|
|
42
|
+
*/
|
|
43
|
+
export declare function performDeviceCodeLogin(serverName: string, config: DeviceCodeConfig, logger: Logger): Promise<StoredToken>;
|
package/dist/src/cli-auth.js
CHANGED
|
@@ -157,6 +157,109 @@ export async function performAuthCodeLogin(serverName, authConfig, logger) {
|
|
|
157
157
|
});
|
|
158
158
|
});
|
|
159
159
|
}
|
|
160
|
+
const DEFAULT_POLL_INTERVAL_S = 5;
|
|
161
|
+
const SLOW_DOWN_INCREMENT_S = 5;
|
|
162
|
+
/**
|
|
163
|
+
* Perform the OAuth2 Device Authorization Grant (RFC 8628).
|
|
164
|
+
*
|
|
165
|
+
* 1. POST to deviceAuthorizationUrl to obtain device_code + user_code
|
|
166
|
+
* 2. Display user_code and verification_uri to the user
|
|
167
|
+
* 3. Poll tokenUrl until the user authorizes or the code expires
|
|
168
|
+
*/
|
|
169
|
+
export async function performDeviceCodeLogin(serverName, config, logger) {
|
|
170
|
+
// Step 1: Request device code
|
|
171
|
+
const formData = new URLSearchParams();
|
|
172
|
+
formData.set("client_id", config.clientId);
|
|
173
|
+
if (config.scopes?.length)
|
|
174
|
+
formData.set("scope", config.scopes.join(" "));
|
|
175
|
+
const deviceResponse = await fetch(config.deviceAuthorizationUrl, {
|
|
176
|
+
method: "POST",
|
|
177
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
178
|
+
body: formData.toString(),
|
|
179
|
+
});
|
|
180
|
+
if (!deviceResponse.ok) {
|
|
181
|
+
const text = await deviceResponse.text().catch(() => "");
|
|
182
|
+
throw new Error(`Device authorization request failed: HTTP ${deviceResponse.status} ${text}`);
|
|
183
|
+
}
|
|
184
|
+
const devicePayload = (await deviceResponse.json());
|
|
185
|
+
if (devicePayload.error) {
|
|
186
|
+
throw new Error(`Device authorization error: ${devicePayload.error} — ${devicePayload.error_description || ""}`);
|
|
187
|
+
}
|
|
188
|
+
if (!devicePayload.device_code || !devicePayload.user_code || !devicePayload.verification_uri) {
|
|
189
|
+
throw new Error("Device authorization response missing required fields (device_code, user_code, verification_uri)");
|
|
190
|
+
}
|
|
191
|
+
const deviceCode = devicePayload.device_code;
|
|
192
|
+
const userCode = devicePayload.user_code;
|
|
193
|
+
const verificationUri = devicePayload.verification_uri;
|
|
194
|
+
const verificationUriComplete = devicePayload.verification_uri_complete;
|
|
195
|
+
const expiresInS = devicePayload.expires_in ?? 900;
|
|
196
|
+
let intervalS = devicePayload.interval ?? DEFAULT_POLL_INTERVAL_S;
|
|
197
|
+
// Step 2: Display instructions to the user
|
|
198
|
+
logger.info(`[mcp-bridge] ──────────────────────────────────────────`);
|
|
199
|
+
logger.info(`[mcp-bridge] Device authentication for "${serverName}"`);
|
|
200
|
+
logger.info(`[mcp-bridge]`);
|
|
201
|
+
logger.info(`[mcp-bridge] 1. Open: ${verificationUri}`);
|
|
202
|
+
logger.info(`[mcp-bridge] 2. Enter code: ${userCode}`);
|
|
203
|
+
logger.info(`[mcp-bridge] ──────────────────────────────────────────`);
|
|
204
|
+
if (verificationUriComplete) {
|
|
205
|
+
logger.info(`[mcp-bridge] Or open this URL directly: ${verificationUriComplete}`);
|
|
206
|
+
if (!config.skipBrowser) {
|
|
207
|
+
openBrowser(verificationUriComplete, logger);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
logger.info(`[mcp-bridge] Waiting for authorization (expires in ${expiresInS}s)...`);
|
|
211
|
+
// Step 3: Poll for token
|
|
212
|
+
const deadline = Date.now() + expiresInS * 1000;
|
|
213
|
+
while (Date.now() < deadline) {
|
|
214
|
+
await sleep(intervalS * 1000);
|
|
215
|
+
const tokenForm = new URLSearchParams();
|
|
216
|
+
tokenForm.set("grant_type", "urn:ietf:params:oauth:grant-type:device_code");
|
|
217
|
+
tokenForm.set("device_code", deviceCode);
|
|
218
|
+
tokenForm.set("client_id", config.clientId);
|
|
219
|
+
const tokenResponse = await fetch(config.tokenUrl, {
|
|
220
|
+
method: "POST",
|
|
221
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
222
|
+
body: tokenForm.toString(),
|
|
223
|
+
});
|
|
224
|
+
const tokenPayload = (await tokenResponse.json());
|
|
225
|
+
if (tokenPayload.error) {
|
|
226
|
+
if (tokenPayload.error === "authorization_pending") {
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (tokenPayload.error === "slow_down") {
|
|
230
|
+
intervalS += SLOW_DOWN_INCREMENT_S;
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (tokenPayload.error === "expired_token") {
|
|
234
|
+
throw new Error("Device code expired. Please try again.");
|
|
235
|
+
}
|
|
236
|
+
if (tokenPayload.error === "access_denied") {
|
|
237
|
+
throw new Error("Authorization denied by user.");
|
|
238
|
+
}
|
|
239
|
+
throw new Error(`Device code token error: ${tokenPayload.error} — ${tokenPayload.error_description || ""}`);
|
|
240
|
+
}
|
|
241
|
+
if (!tokenPayload.access_token) {
|
|
242
|
+
throw new Error("Device code token response missing access_token");
|
|
243
|
+
}
|
|
244
|
+
const expiresIn = Number.isFinite(tokenPayload.expires_in)
|
|
245
|
+
? Number(tokenPayload.expires_in)
|
|
246
|
+
: DEFAULT_EXPIRES_IN;
|
|
247
|
+
const expiresAt = Date.now() + Math.max(0, expiresIn - EXPIRY_BUFFER_SECONDS) * 1000;
|
|
248
|
+
logger.info(`[mcp-bridge] Authentication successful. Token expires in ${expiresIn}s.`);
|
|
249
|
+
return {
|
|
250
|
+
accessToken: tokenPayload.access_token,
|
|
251
|
+
refreshToken: tokenPayload.refresh_token,
|
|
252
|
+
expiresAt,
|
|
253
|
+
tokenUrl: config.tokenUrl,
|
|
254
|
+
clientId: config.clientId,
|
|
255
|
+
scopes: config.scopes,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
throw new Error("Device code expired (timeout). Please try again.");
|
|
259
|
+
}
|
|
260
|
+
function sleep(ms) {
|
|
261
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
262
|
+
}
|
|
160
263
|
async function exchangeCodeForToken(config, code, redirectUri, codeVerifier, logger) {
|
|
161
264
|
const formData = new URLSearchParams();
|
|
162
265
|
formData.set("grant_type", "authorization_code");
|
|
@@ -14,6 +14,12 @@ export interface AuthCodeOAuth2Config {
|
|
|
14
14
|
clientSecret?: string;
|
|
15
15
|
scopes?: string[];
|
|
16
16
|
}
|
|
17
|
+
export interface DeviceCodeOAuth2Config {
|
|
18
|
+
grantType: "device_code";
|
|
19
|
+
tokenUrl: string;
|
|
20
|
+
clientId: string;
|
|
21
|
+
scopes?: string[];
|
|
22
|
+
}
|
|
17
23
|
export declare class OAuth2TokenManager {
|
|
18
24
|
private readonly logger;
|
|
19
25
|
private readonly tokenCache;
|
|
@@ -29,6 +35,13 @@ export declare class OAuth2TokenManager {
|
|
|
29
35
|
* Checks TokenStore, refreshes if expired, throws if unavailable.
|
|
30
36
|
*/
|
|
31
37
|
getTokenForAuthCode(serverName: string, config: AuthCodeOAuth2Config): Promise<string>;
|
|
38
|
+
/**
|
|
39
|
+
* Get a token for a device_code flow server.
|
|
40
|
+
* Checks TokenStore, refreshes if expired, throws if unavailable.
|
|
41
|
+
*/
|
|
42
|
+
getTokenForDeviceCode(serverName: string, config: DeviceCodeOAuth2Config): Promise<string>;
|
|
43
|
+
private doDeviceCodeRefresh;
|
|
44
|
+
private refreshDeviceCodeToken;
|
|
32
45
|
private doAuthCodeRefresh;
|
|
33
46
|
private refreshAuthCodeToken;
|
|
34
47
|
private makeKey;
|
|
@@ -75,6 +75,87 @@ export class OAuth2TokenManager {
|
|
|
75
75
|
this.authCodeInflight.delete(serverName);
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
|
+
/**
|
|
79
|
+
* Get a token for a device_code flow server.
|
|
80
|
+
* Checks TokenStore, refreshes if expired, throws if unavailable.
|
|
81
|
+
*/
|
|
82
|
+
async getTokenForDeviceCode(serverName, config) {
|
|
83
|
+
if (!this.tokenStore) {
|
|
84
|
+
throw new Error(`Authentication required for server "${serverName}". Run: mcp-bridge auth login ${serverName}`);
|
|
85
|
+
}
|
|
86
|
+
const stored = this.tokenStore.load(serverName);
|
|
87
|
+
if (!stored) {
|
|
88
|
+
const err = new Error(`Authentication required for server "${serverName}". Run: mcp-bridge auth login ${serverName}`);
|
|
89
|
+
err.code = -32007;
|
|
90
|
+
throw err;
|
|
91
|
+
}
|
|
92
|
+
const now = Date.now();
|
|
93
|
+
if (stored.expiresAt > now) {
|
|
94
|
+
return stored.accessToken;
|
|
95
|
+
}
|
|
96
|
+
// Token expired — try refresh with inflight dedup
|
|
97
|
+
const existingInflight = this.authCodeInflight.get(serverName);
|
|
98
|
+
if (existingInflight) {
|
|
99
|
+
return existingInflight;
|
|
100
|
+
}
|
|
101
|
+
const refreshPromise = this.doDeviceCodeRefresh(serverName, stored, config);
|
|
102
|
+
this.authCodeInflight.set(serverName, refreshPromise);
|
|
103
|
+
try {
|
|
104
|
+
return await refreshPromise;
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
this.authCodeInflight.delete(serverName);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
async doDeviceCodeRefresh(serverName, stored, config) {
|
|
111
|
+
if (stored.refreshToken) {
|
|
112
|
+
try {
|
|
113
|
+
const refreshed = await this.refreshDeviceCodeToken(stored, config);
|
|
114
|
+
this.tokenStore.save(serverName, refreshed);
|
|
115
|
+
return refreshed.accessToken;
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
this.logger.warn("[mcp-bridge] Device code token refresh failed:", err);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
// Refresh failed or no refresh token
|
|
122
|
+
this.tokenStore.remove(serverName);
|
|
123
|
+
const error = new Error(`Authentication expired for server "${serverName}". Run: mcp-bridge auth login ${serverName}`);
|
|
124
|
+
error.code = -32006;
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
async refreshDeviceCodeToken(stored, config) {
|
|
128
|
+
const formData = new URLSearchParams();
|
|
129
|
+
formData.set("grant_type", "refresh_token");
|
|
130
|
+
formData.set("refresh_token", stored.refreshToken);
|
|
131
|
+
formData.set("client_id", config.clientId);
|
|
132
|
+
if (config.scopes?.length)
|
|
133
|
+
formData.set("scope", config.scopes.join(" "));
|
|
134
|
+
const response = await fetch(stored.tokenUrl, {
|
|
135
|
+
method: "POST",
|
|
136
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
137
|
+
body: formData.toString(),
|
|
138
|
+
});
|
|
139
|
+
if (!response.ok) {
|
|
140
|
+
throw new Error(`OAuth2 refresh token exchange failed: HTTP ${response.status}`);
|
|
141
|
+
}
|
|
142
|
+
const payload = (await response.json());
|
|
143
|
+
if (!payload.access_token) {
|
|
144
|
+
throw new Error("OAuth2 refresh response missing access_token");
|
|
145
|
+
}
|
|
146
|
+
const expiresIn = Number.isFinite(payload.expires_in)
|
|
147
|
+
? Number(payload.expires_in)
|
|
148
|
+
: DEFAULT_EXPIRES_IN_SECONDS;
|
|
149
|
+
const expiresAt = Date.now() + Math.max(0, expiresIn - EXPIRY_BUFFER_SECONDS) * 1000;
|
|
150
|
+
return {
|
|
151
|
+
accessToken: payload.access_token,
|
|
152
|
+
refreshToken: payload.refresh_token ?? stored.refreshToken,
|
|
153
|
+
expiresAt,
|
|
154
|
+
tokenUrl: stored.tokenUrl,
|
|
155
|
+
clientId: config.clientId,
|
|
156
|
+
scopes: config.scopes,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
78
159
|
async doAuthCodeRefresh(serverName, stored, config) {
|
|
79
160
|
if (stored.refreshToken) {
|
|
80
161
|
try {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { McpTransport, McpRequest, McpResponse, McpServerConfig, McpClientConfig, Logger, JsonRpcMessage, RequestIdGenerator } from "./types.js";
|
|
2
|
-
import type { OAuth2Config, AuthCodeOAuth2Config, OAuth2TokenManager } from "./oauth2-token-manager.js";
|
|
2
|
+
import type { OAuth2Config, AuthCodeOAuth2Config, DeviceCodeOAuth2Config, OAuth2TokenManager } from "./oauth2-token-manager.js";
|
|
3
3
|
export type PendingRequest = {
|
|
4
4
|
resolve: (value: McpResponse) => void;
|
|
5
5
|
reject: (reason: Error) => void;
|
|
@@ -83,8 +83,14 @@ export declare function isAuthCodeOAuth2(auth: {
|
|
|
83
83
|
type: "oauth2";
|
|
84
84
|
grantType?: string;
|
|
85
85
|
}): boolean;
|
|
86
|
+
/** Check whether an oauth2 auth config uses the device_code grant type. */
|
|
87
|
+
export declare function isDeviceCodeOAuth2(auth: {
|
|
88
|
+
type: "oauth2";
|
|
89
|
+
grantType?: string;
|
|
90
|
+
}): boolean;
|
|
86
91
|
export declare function resolveOAuth2Config(config: McpServerConfig, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>): OAuth2Config;
|
|
87
92
|
export declare function resolveAuthCodeOAuth2Config(config: McpServerConfig, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>): AuthCodeOAuth2Config;
|
|
93
|
+
export declare function resolveDeviceCodeOAuth2Config(config: McpServerConfig, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>): DeviceCodeOAuth2Config;
|
|
88
94
|
export declare function resolveAuthHeadersAsync(config: McpServerConfig, tokenManager: OAuth2TokenManager, extraEnv?: Record<string, string | undefined>, envFallback?: () => Record<string, string>, serverName?: string): Promise<Record<string, string>>;
|
|
89
95
|
/**
|
|
90
96
|
* Resolve server headers and merge auth headers (auth takes precedence).
|
|
@@ -185,6 +185,10 @@ export function resolveAuthHeaders(config, extraEnv, envFallback) {
|
|
|
185
185
|
export function isAuthCodeOAuth2(auth) {
|
|
186
186
|
return auth.grantType === "authorization_code";
|
|
187
187
|
}
|
|
188
|
+
/** Check whether an oauth2 auth config uses the device_code grant type. */
|
|
189
|
+
export function isDeviceCodeOAuth2(auth) {
|
|
190
|
+
return auth.grantType === "device_code";
|
|
191
|
+
}
|
|
188
192
|
export function resolveOAuth2Config(config, extraEnv, envFallback) {
|
|
189
193
|
if (!config.auth || config.auth.type !== "oauth2") {
|
|
190
194
|
throw new Error("[mcp-bridge] resolveOAuth2Config called for non-oauth2 auth config");
|
|
@@ -192,14 +196,18 @@ export function resolveOAuth2Config(config, extraEnv, envFallback) {
|
|
|
192
196
|
if (isAuthCodeOAuth2(config.auth)) {
|
|
193
197
|
throw new Error("[mcp-bridge] resolveOAuth2Config called for authorization_code config — use resolveAuthCodeOAuth2Config instead");
|
|
194
198
|
}
|
|
195
|
-
|
|
199
|
+
if (isDeviceCodeOAuth2(config.auth)) {
|
|
200
|
+
throw new Error("[mcp-bridge] resolveOAuth2Config called for device_code config — use resolveDeviceCodeOAuth2Config instead");
|
|
201
|
+
}
|
|
202
|
+
const auth = config.auth;
|
|
203
|
+
const scopes = auth.scopes?.map((scope, index) => resolveEnvVars(scope, `oauth2 scope[${index}]`, extraEnv, envFallback));
|
|
196
204
|
return {
|
|
197
|
-
clientId: resolveEnvVars(
|
|
198
|
-
clientSecret: resolveEnvVars(
|
|
199
|
-
tokenUrl: resolveEnvVars(
|
|
205
|
+
clientId: resolveEnvVars(auth.clientId, "oauth2 clientId", extraEnv, envFallback),
|
|
206
|
+
clientSecret: resolveEnvVars(auth.clientSecret, "oauth2 clientSecret", extraEnv, envFallback),
|
|
207
|
+
tokenUrl: resolveEnvVars(auth.tokenUrl, "oauth2 tokenUrl", extraEnv, envFallback),
|
|
200
208
|
...(scopes && scopes.length > 0 ? { scopes } : {}),
|
|
201
|
-
...(
|
|
202
|
-
? { audience: resolveEnvVars(
|
|
209
|
+
...(auth.audience
|
|
210
|
+
? { audience: resolveEnvVars(auth.audience, "oauth2 audience", extraEnv, envFallback) }
|
|
203
211
|
: {}),
|
|
204
212
|
};
|
|
205
213
|
}
|
|
@@ -217,6 +225,19 @@ export function resolveAuthCodeOAuth2Config(config, extraEnv, envFallback) {
|
|
|
217
225
|
...(scopes && scopes.length > 0 ? { scopes } : {}),
|
|
218
226
|
};
|
|
219
227
|
}
|
|
228
|
+
export function resolveDeviceCodeOAuth2Config(config, extraEnv, envFallback) {
|
|
229
|
+
if (!config.auth || config.auth.type !== "oauth2" || !isDeviceCodeOAuth2(config.auth)) {
|
|
230
|
+
throw new Error("[mcp-bridge] resolveDeviceCodeOAuth2Config called for non-device_code auth config");
|
|
231
|
+
}
|
|
232
|
+
const auth = config.auth;
|
|
233
|
+
const scopes = auth.scopes?.map((scope, index) => resolveEnvVars(scope, `oauth2 scope[${index}]`, extraEnv, envFallback));
|
|
234
|
+
return {
|
|
235
|
+
grantType: "device_code",
|
|
236
|
+
tokenUrl: resolveEnvVars(auth.tokenUrl, "oauth2 tokenUrl", extraEnv, envFallback),
|
|
237
|
+
clientId: resolveEnvVars(auth.clientId, "oauth2 clientId", extraEnv, envFallback),
|
|
238
|
+
...(scopes && scopes.length > 0 ? { scopes } : {}),
|
|
239
|
+
};
|
|
240
|
+
}
|
|
220
241
|
export async function resolveAuthHeadersAsync(config, tokenManager, extraEnv, envFallback, serverName) {
|
|
221
242
|
if (!config.auth)
|
|
222
243
|
return {};
|
|
@@ -229,6 +250,14 @@ export async function resolveAuthHeadersAsync(config, tokenManager, extraEnv, en
|
|
|
229
250
|
const token = await tokenManager.getTokenForAuthCode(serverName, authCodeConfig);
|
|
230
251
|
return { Authorization: `Bearer ${token}` };
|
|
231
252
|
}
|
|
253
|
+
if (isDeviceCodeOAuth2(config.auth)) {
|
|
254
|
+
if (!serverName) {
|
|
255
|
+
throw new Error("[mcp-bridge] serverName is required for device_code OAuth2 flow");
|
|
256
|
+
}
|
|
257
|
+
const deviceCodeConfig = resolveDeviceCodeOAuth2Config(config, extraEnv, envFallback);
|
|
258
|
+
const token = await tokenManager.getTokenForDeviceCode(serverName, deviceCodeConfig);
|
|
259
|
+
return { Authorization: `Bearer ${token}` };
|
|
260
|
+
}
|
|
232
261
|
const oauth2Config = resolveOAuth2Config(config, extraEnv, envFallback);
|
|
233
262
|
const token = await tokenManager.getToken(oauth2Config);
|
|
234
263
|
return { Authorization: `Bearer ${token}` };
|
package/dist/src/types.d.ts
CHANGED
|
@@ -26,6 +26,13 @@ export type HttpAuthConfig = {
|
|
|
26
26
|
clientSecret?: string;
|
|
27
27
|
scopes?: string[];
|
|
28
28
|
callbackPort?: number;
|
|
29
|
+
} | {
|
|
30
|
+
type: "oauth2";
|
|
31
|
+
grantType: "device_code";
|
|
32
|
+
deviceAuthorizationUrl: string;
|
|
33
|
+
tokenUrl: string;
|
|
34
|
+
clientId: string;
|
|
35
|
+
scopes?: string[];
|
|
29
36
|
};
|
|
30
37
|
export interface RetryConfig {
|
|
31
38
|
maxAttempts?: number;
|