claude-ide-bridge 2.4.0 → 2.4.2
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 +5 -5
- package/dist/bridge.d.ts +2 -0
- package/dist/bridge.js +21 -5
- package/dist/bridge.js.map +1 -1
- package/dist/config.d.ts +0 -2
- package/dist/config.js +1 -13
- package/dist/config.js.map +1 -1
- package/dist/extensionClient.js +8 -0
- package/dist/extensionClient.js.map +1 -1
- package/dist/oauth.d.ts +32 -47
- package/dist/oauth.js +275 -320
- package/dist/oauth.js.map +1 -1
- package/dist/server.d.ts +0 -13
- package/dist/server.js +54 -93
- package/dist/server.js.map +1 -1
- package/dist/streamableHttp.d.ts +7 -0
- package/dist/streamableHttp.js +40 -11
- package/dist/streamableHttp.js.map +1 -1
- package/dist/tools/git-utils.js.map +1 -1
- package/dist/tools/handoffNote.d.ts +0 -1
- package/dist/tools/handoffNote.js +1 -1
- package/dist/tools/handoffNote.js.map +1 -1
- package/dist/tools/lsp.js +2 -7
- package/dist/tools/lsp.js.map +1 -1
- package/dist/tools/searchAndReplace.js.map +1 -1
- package/dist/tools/searchWorkspace.js.map +1 -1
- package/package.json +1 -1
package/dist/oauth.js
CHANGED
|
@@ -1,149 +1,265 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import { parse as parseQs } from "node:querystring";
|
|
1
4
|
/**
|
|
2
|
-
* OAuth 2.
|
|
3
|
-
*
|
|
4
|
-
* Implements the MCP OAuth 2.0 profile required for authenticated remote servers:
|
|
5
|
-
* - RFC 8414 Authorization Server Metadata (/.well-known/oauth-authorization-server)
|
|
6
|
-
* - RFC 6749 Authorization Code Grant with PKCE (S256, RFC 7636)
|
|
7
|
-
* - RFC 7009 Token Revocation (/oauth/revoke)
|
|
5
|
+
* OAuth 2.1 Authorization Server + Resource Server for claude-ide-bridge.
|
|
8
6
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* Refresh tokens are not issued.
|
|
7
|
+
* Implements the MCP spec (2025-11-25) authorization requirements:
|
|
8
|
+
* - /.well-known/oauth-protected-resource (RFC 9728)
|
|
9
|
+
* - /.well-known/oauth-authorization-server (RFC 8414)
|
|
10
|
+
* - GET /authorize — approval page
|
|
11
|
+
* - POST /authorize — form submit (issues auth code)
|
|
12
|
+
* - POST /token — code + PKCE verifier → access token
|
|
16
13
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* All string comparisons via crypto.timingSafeEqual. HTML output attribute-escaped.
|
|
14
|
+
* The existing authToken from the lock file is issued as the access token —
|
|
15
|
+
* no new token system is needed.
|
|
20
16
|
*/
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
17
|
+
// Redirect URIs accepted from MCP clients (Claude Code / Claude Desktop).
|
|
18
|
+
export const ALLOWED_REDIRECT_URIS = new Set([
|
|
19
|
+
"https://claude.ai/api/mcp/auth_callback",
|
|
20
|
+
"https://claude.com/api/mcp/auth_callback",
|
|
21
|
+
"http://localhost:6274/oauth/callback",
|
|
22
|
+
"http://localhost:6274/oauth/callback/debug",
|
|
23
|
+
]);
|
|
24
|
+
// Max concurrent in-flight auth codes (anti-stuffing).
|
|
25
|
+
const MAX_PENDING_CODES = 20;
|
|
26
|
+
// Auth code TTL in milliseconds.
|
|
27
|
+
const CODE_TTL_MS = 60_000;
|
|
28
|
+
// Prune interval for expired codes.
|
|
29
|
+
const PRUNE_INTERVAL_MS = 5 * 60_000;
|
|
30
|
+
function escapeHtml(s) {
|
|
31
|
+
return s
|
|
32
|
+
.replace(/&/g, "&")
|
|
33
|
+
.replace(/</g, "<")
|
|
34
|
+
.replace(/>/g, ">")
|
|
35
|
+
.replace(/"/g, """)
|
|
36
|
+
.replace(/'/g, "'");
|
|
37
|
+
}
|
|
38
|
+
function verifyS256(verifier, storedChallenge) {
|
|
39
|
+
// RFC 7636: verifier charset [A-Z a-z 0-9 - . _ ~], length 43-128
|
|
40
|
+
if (!/^[A-Za-z0-9\-._~]{43,128}$/.test(verifier))
|
|
41
|
+
return false;
|
|
42
|
+
const digest = crypto
|
|
43
|
+
.createHash("sha256")
|
|
44
|
+
.update(verifier, "ascii")
|
|
45
|
+
.digest("base64url");
|
|
46
|
+
if (digest.length !== storedChallenge.length)
|
|
47
|
+
return false;
|
|
48
|
+
return crypto.timingSafeEqual(Buffer.from(digest), Buffer.from(storedChallenge));
|
|
49
|
+
}
|
|
50
|
+
function buildApprovalPage(params) {
|
|
51
|
+
const { clientId, redirectUri, codeChallenge, state, port } = params;
|
|
52
|
+
return `<!DOCTYPE html>
|
|
53
|
+
<html lang="en">
|
|
54
|
+
<head>
|
|
55
|
+
<meta charset="utf-8">
|
|
56
|
+
<title>Authorize — Claude IDE Bridge</title>
|
|
57
|
+
<style>
|
|
58
|
+
body { font-family: system-ui, sans-serif; max-width: 480px;
|
|
59
|
+
margin: 80px auto; padding: 0 1rem; color: #1a1a1a; }
|
|
60
|
+
.card { border: 1px solid #ddd; border-radius: 8px; padding: 2rem; }
|
|
61
|
+
h1 { font-size: 1.25rem; margin-top: 0; }
|
|
62
|
+
p { font-size: .9rem; color: #444; }
|
|
63
|
+
.client { font-weight: 600; }
|
|
64
|
+
.actions { display: flex; gap: .75rem; margin-top: 1.5rem; }
|
|
65
|
+
button { flex: 1; padding: .6rem; border-radius: 6px;
|
|
66
|
+
font-size: .95rem; cursor: pointer; border: 1px solid; }
|
|
67
|
+
.allow { background: #1a56db; color: #fff; border-color: #1a56db; }
|
|
68
|
+
.deny { background: #fff; color: #111; border-color: #ccc; }
|
|
69
|
+
</style>
|
|
70
|
+
</head>
|
|
71
|
+
<body>
|
|
72
|
+
<div class="card">
|
|
73
|
+
<h1>Authorize Access</h1>
|
|
74
|
+
<p>
|
|
75
|
+
<span class="client">${escapeHtml(clientId)}</span> wants to connect
|
|
76
|
+
to your local Claude IDE Bridge on port ${port}.
|
|
77
|
+
</p>
|
|
78
|
+
<p>This will grant access to your IDE tools, file system, and terminal.</p>
|
|
79
|
+
<form method="POST" action="/authorize">
|
|
80
|
+
<input type="hidden" name="response_type" value="code">
|
|
81
|
+
<input type="hidden" name="client_id" value="${escapeHtml(clientId)}">
|
|
82
|
+
<input type="hidden" name="redirect_uri" value="${escapeHtml(redirectUri)}">
|
|
83
|
+
<input type="hidden" name="code_challenge" value="${escapeHtml(codeChallenge)}">
|
|
84
|
+
<input type="hidden" name="code_challenge_method" value="S256">
|
|
85
|
+
<input type="hidden" name="state" value="${escapeHtml(state)}">
|
|
86
|
+
<div class="actions">
|
|
87
|
+
<button class="allow" type="submit" name="approve" value="true">Allow</button>
|
|
88
|
+
<button class="deny" type="submit" name="approve" value="false">Deny</button>
|
|
89
|
+
</div>
|
|
90
|
+
</form>
|
|
91
|
+
</div>
|
|
92
|
+
</body>
|
|
93
|
+
</html>`;
|
|
94
|
+
}
|
|
95
|
+
function sendJson(res, status, body, extraHeaders) {
|
|
96
|
+
res.writeHead(status, {
|
|
97
|
+
"Content-Type": "application/json",
|
|
98
|
+
"Cache-Control": "no-store",
|
|
99
|
+
"Access-Control-Allow-Origin": "*",
|
|
100
|
+
...extraHeaders,
|
|
101
|
+
});
|
|
102
|
+
res.end(JSON.stringify(body));
|
|
103
|
+
}
|
|
104
|
+
function oauthError(res, status, error, description) {
|
|
105
|
+
sendJson(res, status, {
|
|
106
|
+
error,
|
|
107
|
+
...(description ? { error_description: description } : {}),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
async function readBody(req) {
|
|
111
|
+
return new Promise((resolve, reject) => {
|
|
112
|
+
const chunks = [];
|
|
113
|
+
req.on("data", (c) => {
|
|
114
|
+
if (chunks.reduce((n, b) => n + b.length, 0) + c.length > 65_536) {
|
|
115
|
+
reject(new Error("Request body too large"));
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
chunks.push(c);
|
|
119
|
+
});
|
|
120
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
|
121
|
+
req.on("error", reject);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
export class OAuthServer {
|
|
125
|
+
authToken;
|
|
126
|
+
port = 0;
|
|
127
|
+
bindAddress = "127.0.0.1";
|
|
128
|
+
codes = new Map();
|
|
129
|
+
pruneTimer = null;
|
|
130
|
+
constructor(authToken) {
|
|
131
|
+
this.authToken = authToken;
|
|
132
|
+
this.pruneTimer = setInterval(() => this.pruneExpiredCodes(), PRUNE_INTERVAL_MS);
|
|
133
|
+
this.pruneTimer.unref();
|
|
134
|
+
}
|
|
135
|
+
setPort(port, bindAddress = "127.0.0.1") {
|
|
136
|
+
this.port = port;
|
|
137
|
+
this.bindAddress = bindAddress;
|
|
138
|
+
}
|
|
139
|
+
close() {
|
|
140
|
+
if (this.pruneTimer) {
|
|
141
|
+
clearInterval(this.pruneTimer);
|
|
142
|
+
this.pruneTimer = null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
baseUrl() {
|
|
146
|
+
const host = this.bindAddress === "0.0.0.0" || this.bindAddress === "::"
|
|
147
|
+
? "localhost"
|
|
148
|
+
: this.bindAddress;
|
|
149
|
+
return `http://${host}:${this.port}`;
|
|
150
|
+
}
|
|
151
|
+
pruneExpiredCodes() {
|
|
152
|
+
const now = Date.now();
|
|
153
|
+
for (const [code, entry] of this.codes) {
|
|
154
|
+
if (entry.expiresAt < now)
|
|
155
|
+
this.codes.delete(code);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/** WWW-Authenticate header value to include on 401 responses. */
|
|
159
|
+
wwwAuthenticate() {
|
|
160
|
+
const base = this.baseUrl();
|
|
161
|
+
return `Bearer realm="${base}", resource_metadata="${base}/.well-known/oauth-protected-resource"`;
|
|
48
162
|
}
|
|
49
|
-
|
|
50
|
-
|
|
163
|
+
// ──────────────────────────────────────────────────────────
|
|
164
|
+
// GET /.well-known/oauth-protected-resource (RFC 9728)
|
|
165
|
+
// ──────────────────────────────────────────────────────────
|
|
166
|
+
handleProtectedResourceMetadata(_req, res) {
|
|
167
|
+
const base = this.baseUrl();
|
|
168
|
+
sendJson(res, 200, {
|
|
169
|
+
resource: base,
|
|
170
|
+
authorization_servers: [base],
|
|
171
|
+
bearer_methods_supported: ["header"],
|
|
172
|
+
resource_documentation: "https://github.com/Oolab-labs/claude-ide-bridge",
|
|
173
|
+
});
|
|
51
174
|
}
|
|
52
|
-
//
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
175
|
+
// ──────────────────────────────────────────────────────────
|
|
176
|
+
// GET /.well-known/oauth-authorization-server (RFC 8414)
|
|
177
|
+
// ──────────────────────────────────────────────────────────
|
|
178
|
+
handleAuthorizationServerMetadata(_req, res) {
|
|
179
|
+
const base = this.baseUrl();
|
|
180
|
+
sendJson(res, 200, {
|
|
181
|
+
issuer: base,
|
|
182
|
+
authorization_endpoint: `${base}/authorize`,
|
|
183
|
+
token_endpoint: `${base}/token`,
|
|
59
184
|
response_types_supported: ["code"],
|
|
60
185
|
grant_types_supported: ["authorization_code"],
|
|
61
186
|
code_challenge_methods_supported: ["S256"],
|
|
62
187
|
token_endpoint_auth_methods_supported: ["none"],
|
|
63
|
-
scopes_supported:
|
|
188
|
+
scopes_supported: ["mcp"],
|
|
64
189
|
});
|
|
65
190
|
}
|
|
66
|
-
//
|
|
191
|
+
// ──────────────────────────────────────────────────────────
|
|
192
|
+
// GET /authorize — show approval page
|
|
193
|
+
// POST /authorize — process approval form
|
|
194
|
+
// ──────────────────────────────────────────────────────────
|
|
67
195
|
async handleAuthorize(req, res) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
this.authorizeGet(req, res);
|
|
196
|
+
if (req.method === "GET") {
|
|
197
|
+
return this.handleAuthorizeGet(req, res);
|
|
71
198
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
-
else {
|
|
76
|
-
res.writeHead(405, { "Content-Type": "text/plain", Allow: "GET, POST" });
|
|
77
|
-
res.end("Method Not Allowed");
|
|
199
|
+
if (req.method === "POST") {
|
|
200
|
+
return this.handleAuthorizePost(req, res);
|
|
78
201
|
}
|
|
202
|
+
res.writeHead(405, { Allow: "GET, POST" });
|
|
203
|
+
res.end("Method Not Allowed");
|
|
79
204
|
}
|
|
80
|
-
|
|
81
|
-
const url = new URL(req.url ?? "/",
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
? authHeader.slice(7)
|
|
86
|
-
: "";
|
|
87
|
-
const fromQuery = url.searchParams.get("bridge_token") ?? "";
|
|
88
|
-
const presented = fromHeader || fromQuery;
|
|
89
|
-
if (!this.safeEqual(presented, this.bridgeToken)) {
|
|
90
|
-
res.writeHead(401, {
|
|
91
|
-
"Content-Type": "text/plain",
|
|
92
|
-
"WWW-Authenticate": "Bearer",
|
|
93
|
-
});
|
|
94
|
-
res.end("Unauthorized: supply bridge token to initiate OAuth");
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
const { error, clientId, redirectUri, codeChallenge, scope, state } = this.parseAuthorizeParams(url);
|
|
98
|
-
if (error) {
|
|
205
|
+
handleAuthorizeGet(req, res) {
|
|
206
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
207
|
+
const p = url.searchParams;
|
|
208
|
+
const err = this.validateAuthorizeParams(p.get("response_type"), p.get("redirect_uri"), p.get("code_challenge"), p.get("code_challenge_method"));
|
|
209
|
+
if (err) {
|
|
99
210
|
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
100
|
-
res.end(
|
|
211
|
+
res.end(`Bad Request: ${err}`);
|
|
101
212
|
return;
|
|
102
213
|
}
|
|
214
|
+
const html = buildApprovalPage({
|
|
215
|
+
clientId: p.get("client_id") ?? "(unknown)",
|
|
216
|
+
redirectUri: p.get("redirect_uri") ?? "",
|
|
217
|
+
codeChallenge: p.get("code_challenge") ?? "",
|
|
218
|
+
state: p.get("state") ?? "",
|
|
219
|
+
port: this.port,
|
|
220
|
+
});
|
|
103
221
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
104
|
-
res.end(
|
|
105
|
-
clientId: clientId,
|
|
106
|
-
redirectUri: redirectUri,
|
|
107
|
-
codeChallenge: codeChallenge,
|
|
108
|
-
scope: scope ?? DEFAULT_SCOPE,
|
|
109
|
-
state: state ?? "",
|
|
110
|
-
}));
|
|
222
|
+
res.end(html);
|
|
111
223
|
}
|
|
112
|
-
async
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const scope = body.get("scope") ?? DEFAULT_SCOPE;
|
|
119
|
-
const state = body.get("state") ?? "";
|
|
120
|
-
if (!clientId || !redirectUri || !codeChallenge) {
|
|
224
|
+
async handleAuthorizePost(req, res) {
|
|
225
|
+
let body;
|
|
226
|
+
try {
|
|
227
|
+
body = await readBody(req);
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
121
230
|
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
122
|
-
res.end("
|
|
231
|
+
res.end("Bad Request: could not read body");
|
|
123
232
|
return;
|
|
124
233
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
234
|
+
const p = parseQs(body);
|
|
235
|
+
const redirectUri = p.redirect_uri ?? "";
|
|
236
|
+
const state = p.state ?? "";
|
|
237
|
+
const err = this.validateAuthorizeParams(p.response_type ?? null, redirectUri, p.code_challenge ?? null, p.code_challenge_method ?? null);
|
|
238
|
+
if (err) {
|
|
239
|
+
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
240
|
+
res.end(`Bad Request: ${err}`);
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (p.approve !== "true") {
|
|
244
|
+
const dest = new URL(redirectUri);
|
|
245
|
+
dest.searchParams.set("error", "access_denied");
|
|
128
246
|
if (state)
|
|
129
|
-
|
|
130
|
-
res.writeHead(302, { Location:
|
|
247
|
+
dest.searchParams.set("state", state);
|
|
248
|
+
res.writeHead(302, { Location: dest.toString() });
|
|
131
249
|
res.end();
|
|
132
250
|
return;
|
|
133
251
|
}
|
|
134
|
-
if (
|
|
135
|
-
res.writeHead(
|
|
136
|
-
res.end("
|
|
252
|
+
if (this.codes.size >= MAX_PENDING_CODES) {
|
|
253
|
+
res.writeHead(503, { "Content-Type": "text/plain" });
|
|
254
|
+
res.end("Service Unavailable: too many pending authorizations");
|
|
137
255
|
return;
|
|
138
256
|
}
|
|
139
|
-
const code =
|
|
140
|
-
this.
|
|
141
|
-
|
|
257
|
+
const code = crypto.randomBytes(32).toString("hex");
|
|
258
|
+
this.codes.set(code, {
|
|
259
|
+
challenge: p.code_challenge ?? "",
|
|
142
260
|
redirectUri,
|
|
143
|
-
|
|
144
|
-
scope,
|
|
261
|
+
clientId: p.client_id ?? "",
|
|
145
262
|
expiresAt: Date.now() + CODE_TTL_MS,
|
|
146
|
-
used: false,
|
|
147
263
|
});
|
|
148
264
|
const dest = new URL(redirectUri);
|
|
149
265
|
dest.searchParams.set("code", code);
|
|
@@ -152,228 +268,67 @@ export class OAuthServerImpl {
|
|
|
152
268
|
res.writeHead(302, { Location: dest.toString() });
|
|
153
269
|
res.end();
|
|
154
270
|
}
|
|
155
|
-
|
|
271
|
+
validateAuthorizeParams(responseType, redirectUri, codeChallenge, codeChallengeMethod) {
|
|
272
|
+
if (responseType !== "code")
|
|
273
|
+
return "response_type must be 'code'";
|
|
274
|
+
if (!redirectUri || !ALLOWED_REDIRECT_URIS.has(redirectUri))
|
|
275
|
+
return "redirect_uri not in allowlist";
|
|
276
|
+
if (codeChallengeMethod !== "S256")
|
|
277
|
+
return "code_challenge_method must be 'S256'";
|
|
278
|
+
if (!codeChallenge || !/^[A-Za-z0-9\-._~]{43,128}$/.test(codeChallenge))
|
|
279
|
+
return "code_challenge missing or invalid";
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
// ──────────────────────────────────────────────────────────
|
|
283
|
+
// POST /token — exchange auth code + PKCE for access token
|
|
284
|
+
// ──────────────────────────────────────────────────────────
|
|
156
285
|
async handleToken(req, res) {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
|
-
const code = body.get("code") ?? "";
|
|
163
|
-
const redirectUri = body.get("redirect_uri") ?? "";
|
|
164
|
-
const clientId = body.get("client_id") ?? "";
|
|
165
|
-
const verifier = body.get("code_verifier") ?? "";
|
|
166
|
-
if (!code || !redirectUri || !clientId || !verifier) {
|
|
167
|
-
this.sendError(res, 400, "invalid_request", "missing required parameters");
|
|
286
|
+
if (req.method !== "POST") {
|
|
287
|
+
res.writeHead(405, { Allow: "POST" });
|
|
288
|
+
res.end("Method Not Allowed");
|
|
168
289
|
return;
|
|
169
290
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
return;
|
|
291
|
+
let body;
|
|
292
|
+
try {
|
|
293
|
+
body = await readBody(req);
|
|
174
294
|
}
|
|
175
|
-
|
|
176
|
-
|
|
295
|
+
catch {
|
|
296
|
+
oauthError(res, 400, "invalid_request", "Could not read request body");
|
|
177
297
|
return;
|
|
178
298
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
299
|
+
const p = parseQs(body);
|
|
300
|
+
if (p.grant_type !== "authorization_code") {
|
|
301
|
+
oauthError(res, 400, "unsupported_grant_type");
|
|
182
302
|
return;
|
|
183
303
|
}
|
|
184
|
-
|
|
185
|
-
|
|
304
|
+
const code = p.code ?? "";
|
|
305
|
+
const verifier = p.code_verifier ?? "";
|
|
306
|
+
const redirectUri = p.redirect_uri ?? "";
|
|
307
|
+
const entry = this.codes.get(code);
|
|
308
|
+
if (!entry || entry.expiresAt < Date.now()) {
|
|
309
|
+
this.codes.delete(code);
|
|
310
|
+
oauthError(res, 400, "invalid_grant", "Auth code expired or already used");
|
|
186
311
|
return;
|
|
187
312
|
}
|
|
188
|
-
if (
|
|
189
|
-
|
|
313
|
+
if (entry.redirectUri !== redirectUri) {
|
|
314
|
+
oauthError(res, 400, "invalid_grant", "redirect_uri mismatch");
|
|
190
315
|
return;
|
|
191
316
|
}
|
|
192
|
-
if (!
|
|
193
|
-
|
|
317
|
+
if (!verifyS256(verifier, entry.challenge)) {
|
|
318
|
+
oauthError(res, 400, "invalid_grant", "PKCE verification failed");
|
|
194
319
|
return;
|
|
195
320
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
scope: record.scope,
|
|
201
|
-
expiresAt: Date.now() + TOKEN_TTL_MS,
|
|
202
|
-
});
|
|
203
|
-
this.sendJson(res, 200, {
|
|
204
|
-
access_token: accessToken,
|
|
321
|
+
// Consume the code — single-use only.
|
|
322
|
+
this.codes.delete(code);
|
|
323
|
+
sendJson(res, 200, {
|
|
324
|
+
access_token: this.authToken,
|
|
205
325
|
token_type: "Bearer",
|
|
206
|
-
expires_in:
|
|
207
|
-
scope:
|
|
326
|
+
expires_in: 0,
|
|
327
|
+
scope: "mcp",
|
|
208
328
|
});
|
|
209
329
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
const body = await this.readBody(req);
|
|
214
|
-
const token = body.get("token");
|
|
215
|
-
if (token) {
|
|
216
|
-
this.accessTokens.delete(token);
|
|
217
|
-
this.authCodes.delete(token);
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
catch {
|
|
221
|
-
// RFC 7009: always 200
|
|
222
|
-
}
|
|
223
|
-
res.writeHead(200, {
|
|
224
|
-
"Content-Type": "application/json",
|
|
225
|
-
"Cache-Control": "no-store",
|
|
226
|
-
});
|
|
227
|
-
res.end("{}");
|
|
228
|
-
}
|
|
229
|
-
// ── Bearer resolution (called by server.ts) ───────────────────────────────
|
|
230
|
-
resolveBearerToken(token) {
|
|
231
|
-
const record = this.accessTokens.get(token);
|
|
232
|
-
if (!record)
|
|
233
|
-
return null;
|
|
234
|
-
if (record.expiresAt < Date.now()) {
|
|
235
|
-
this.accessTokens.delete(token);
|
|
236
|
-
return null;
|
|
237
|
-
}
|
|
238
|
-
return this.bridgeToken;
|
|
239
|
-
}
|
|
240
|
-
// ── Private helpers ───────────────────────────────────────────────────────
|
|
241
|
-
randomToken(bytes) {
|
|
242
|
-
return crypto.randomBytes(bytes).toString("base64url");
|
|
243
|
-
}
|
|
244
|
-
safeEqual(a, b) {
|
|
245
|
-
const ab = Buffer.from(a);
|
|
246
|
-
const bb = Buffer.from(b);
|
|
247
|
-
const lenA = Buffer.allocUnsafe(4);
|
|
248
|
-
const lenB = Buffer.allocUnsafe(4);
|
|
249
|
-
lenA.writeUInt32BE(ab.length, 0);
|
|
250
|
-
lenB.writeUInt32BE(bb.length, 0);
|
|
251
|
-
const lenOk = crypto.timingSafeEqual(lenA, lenB);
|
|
252
|
-
const len = Math.max(ab.length, bb.length);
|
|
253
|
-
const padA = Buffer.alloc(len);
|
|
254
|
-
const padB = Buffer.alloc(len);
|
|
255
|
-
ab.copy(padA);
|
|
256
|
-
bb.copy(padB);
|
|
257
|
-
return crypto.timingSafeEqual(padA, padB) && lenOk;
|
|
258
|
-
}
|
|
259
|
-
pkceVerify(verifier, challenge) {
|
|
260
|
-
const hash = crypto
|
|
261
|
-
.createHash("sha256")
|
|
262
|
-
.update(verifier)
|
|
263
|
-
.digest("base64url");
|
|
264
|
-
return this.safeEqual(hash, challenge);
|
|
265
|
-
}
|
|
266
|
-
readBody(req) {
|
|
267
|
-
return new Promise((resolve, reject) => {
|
|
268
|
-
let data = "";
|
|
269
|
-
req.on("data", (chunk) => {
|
|
270
|
-
data += chunk.toString();
|
|
271
|
-
if (data.length > 8_192)
|
|
272
|
-
reject(new Error("Request body too large"));
|
|
273
|
-
});
|
|
274
|
-
req.on("end", () => resolve(new URLSearchParams(data)));
|
|
275
|
-
req.on("error", reject);
|
|
276
|
-
});
|
|
277
|
-
}
|
|
278
|
-
sendJson(res, status, body) {
|
|
279
|
-
res.writeHead(status, {
|
|
280
|
-
"Content-Type": "application/json",
|
|
281
|
-
"Cache-Control": "no-store",
|
|
282
|
-
Pragma: "no-cache",
|
|
283
|
-
});
|
|
284
|
-
res.end(JSON.stringify(body));
|
|
285
|
-
}
|
|
286
|
-
sendError(res, status, error, description) {
|
|
287
|
-
this.sendJson(res, status, {
|
|
288
|
-
error,
|
|
289
|
-
...(description ? { error_description: description } : {}),
|
|
290
|
-
});
|
|
291
|
-
}
|
|
292
|
-
parseAuthorizeParams(url) {
|
|
293
|
-
const responseType = url.searchParams.get("response_type");
|
|
294
|
-
const clientId = url.searchParams.get("client_id");
|
|
295
|
-
const redirectUri = url.searchParams.get("redirect_uri");
|
|
296
|
-
const codeChallenge = url.searchParams.get("code_challenge");
|
|
297
|
-
const codeChallengeMethod = url.searchParams.get("code_challenge_method");
|
|
298
|
-
const state = url.searchParams.get("state");
|
|
299
|
-
if (responseType !== "code")
|
|
300
|
-
return { error: "unsupported_response_type" };
|
|
301
|
-
if (!clientId || !redirectUri || !codeChallenge)
|
|
302
|
-
return { error: "invalid_request" };
|
|
303
|
-
if (codeChallengeMethod !== "S256")
|
|
304
|
-
return { error: "invalid_request" };
|
|
305
|
-
return {
|
|
306
|
-
clientId,
|
|
307
|
-
redirectUri,
|
|
308
|
-
codeChallenge,
|
|
309
|
-
scope: url.searchParams.get("scope") ?? DEFAULT_SCOPE,
|
|
310
|
-
state: state ?? "",
|
|
311
|
-
};
|
|
312
|
-
}
|
|
313
|
-
// ── Approval page HTML ────────────────────────────────────────────────────
|
|
314
|
-
approvalPage(opts) {
|
|
315
|
-
const e = (s) => s
|
|
316
|
-
.replace(/&/g, "&")
|
|
317
|
-
.replace(/</g, "<")
|
|
318
|
-
.replace(/>/g, ">")
|
|
319
|
-
.replace(/"/g, """)
|
|
320
|
-
.replace(/'/g, "'");
|
|
321
|
-
return `<!DOCTYPE html>
|
|
322
|
-
<html lang="en">
|
|
323
|
-
<head>
|
|
324
|
-
<meta charset="UTF-8">
|
|
325
|
-
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
326
|
-
<title>Authorize \u2014 Claude IDE Bridge</title>
|
|
327
|
-
<style>
|
|
328
|
-
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
|
329
|
-
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
|
|
330
|
-
background:#0f1117;color:#e2e8f0;display:flex;align-items:center;
|
|
331
|
-
justify-content:center;min-height:100vh;padding:2rem}
|
|
332
|
-
.card{background:#1a1d27;border:1px solid #2d3148;border-radius:12px;
|
|
333
|
-
padding:2rem;max-width:420px;width:100%;box-shadow:0 4px 32px rgba(0,0,0,.4)}
|
|
334
|
-
.logo{font-size:1.5rem;font-weight:700;color:#818cf8;margin-bottom:1.5rem}
|
|
335
|
-
h1{font-size:1.1rem;margin-bottom:.5rem}
|
|
336
|
-
.client{font-size:.9rem;color:#94a3b8;margin-bottom:1.5rem;word-break:break-all}
|
|
337
|
-
.scope{background:#12141e;border:1px solid #2d3148;border-radius:8px;
|
|
338
|
-
padding:1rem;margin-bottom:1.5rem;font-size:.875rem;color:#94a3b8}
|
|
339
|
-
.scope strong{color:#e2e8f0;display:block;margin-bottom:.5rem}
|
|
340
|
-
.item::before{content:"\u2713 ";color:#34d399}
|
|
341
|
-
.actions{display:flex;gap:.75rem}
|
|
342
|
-
button{flex:1;padding:.65rem 1rem;border:none;border-radius:8px;
|
|
343
|
-
font-size:.95rem;font-weight:600;cursor:pointer;transition:opacity .15s}
|
|
344
|
-
button:hover{opacity:.85}
|
|
345
|
-
.approve{background:#818cf8;color:#0f1117}
|
|
346
|
-
.deny{background:#2d3148;color:#94a3b8}
|
|
347
|
-
footer{margin-top:1.25rem;font-size:.75rem;color:#475569;text-align:center}
|
|
348
|
-
</style>
|
|
349
|
-
</head>
|
|
350
|
-
<body>
|
|
351
|
-
<div class="card">
|
|
352
|
-
<div class="logo">\u29ed Claude IDE Bridge</div>
|
|
353
|
-
<h1>Authorization Request</h1>
|
|
354
|
-
<p class="client">Client: <strong>${e(opts.clientId)}</strong></p>
|
|
355
|
-
<div class="scope">
|
|
356
|
-
<strong>Requested permissions</strong>
|
|
357
|
-
<div class="item">Full MCP tool access (read, write, execute)</div>
|
|
358
|
-
</div>
|
|
359
|
-
<form method="POST" action="/oauth/authorize">
|
|
360
|
-
<input type="hidden" name="client_id" value="${e(opts.clientId)}">
|
|
361
|
-
<input type="hidden" name="redirect_uri" value="${e(opts.redirectUri)}">
|
|
362
|
-
<input type="hidden" name="code_challenge" value="${e(opts.codeChallenge)}">
|
|
363
|
-
<input type="hidden" name="scope" value="${e(opts.scope)}">
|
|
364
|
-
<input type="hidden" name="state" value="${e(opts.state)}">
|
|
365
|
-
<div class="actions">
|
|
366
|
-
<button class="approve" type="submit" name="action" value="approve">Authorize</button>
|
|
367
|
-
<button class="deny" type="submit" name="action" value="deny">Deny</button>
|
|
368
|
-
</div>
|
|
369
|
-
</form>
|
|
370
|
-
<footer>
|
|
371
|
-
Issuer: ${e(this.issuerUrl)}<br>
|
|
372
|
-
Only approve if you initiated this from your MCP client.
|
|
373
|
-
</footer>
|
|
374
|
-
</div>
|
|
375
|
-
</body>
|
|
376
|
-
</html>`;
|
|
377
|
-
}
|
|
330
|
+
}
|
|
331
|
+
export function createOAuthServer(authToken) {
|
|
332
|
+
return new OAuthServer(authToken);
|
|
378
333
|
}
|
|
379
334
|
//# sourceMappingURL=oauth.js.map
|