claude-ide-bridge 2.4.2 → 2.4.4
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 +8 -6
- package/dist/bridge.js +5 -0
- package/dist/bridge.js.map +1 -1
- package/dist/config.d.ts +8 -0
- package/dist/config.js +33 -1
- package/dist/config.js.map +1 -1
- package/dist/index.js +0 -0
- package/dist/oauth.d.ts +47 -32
- package/dist/oauth.js +331 -275
- package/dist/oauth.js.map +1 -1
- package/dist/server.d.ts +13 -0
- package/dist/server.js +93 -54
- package/dist/server.js.map +1 -1
- package/dist/tools/getBufferContent.js +2 -1
- package/dist/tools/getBufferContent.js.map +1 -1
- package/dist/tools/getDiagnostics.js +2 -1
- package/dist/tools/getDiagnostics.js.map +1 -1
- package/dist/tools/git-utils.js.map +1 -1
- package/dist/tools/handoffNote.d.ts +1 -0
- package/dist/tools/handoffNote.js +1 -1
- package/dist/tools/handoffNote.js.map +1 -1
- package/dist/tools/lsp.js +7 -2
- package/dist/tools/lsp.js.map +1 -1
- package/dist/tools/openFile.js +3 -1
- package/dist/tools/openFile.js.map +1 -1
- package/dist/tools/openInBrowser.js +49 -0
- package/dist/tools/openInBrowser.js.map +1 -1
- package/dist/tools/searchAndReplace.js.map +1 -1
- package/dist/tools/searchWorkspace.js.map +1 -1
- package/dist/tools/terminal.js +8 -7
- package/dist/tools/terminal.js.map +1 -1
- package/package.json +3 -3
- package/dist/tools/aiComments.d.ts +0 -26
- package/dist/tools/aiComments.js +0 -196
- package/dist/tools/aiComments.js.map +0 -1
- package/dist/tools/diffDebugger.d.ts +0 -62
- package/dist/tools/diffDebugger.js +0 -245
- package/dist/tools/diffDebugger.js.map +0 -1
- package/dist/tools/flowGuardian.d.ts +0 -61
- package/dist/tools/flowGuardian.js +0 -311
- package/dist/tools/flowGuardian.js.map +0 -1
- package/dist/tools/formatFile.d.ts +0 -28
- package/dist/tools/formatFile.js +0 -110
- package/dist/tools/formatFile.js.map +0 -1
- package/dist/tools/github/review.d.ts +0 -101
- package/dist/tools/github/review.js +0 -292
- package/dist/tools/github/review.js.map +0 -1
- package/dist/tools/github.d.ts +0 -308
- package/dist/tools/github.js +0 -656
- package/dist/tools/github.js.map +0 -1
- package/dist/tools/notebook.d.ts +0 -93
- package/dist/tools/notebook.js +0 -207
- package/dist/tools/notebook.js.map +0 -1
- package/dist/tools/tasks.d.ts +0 -56
- package/dist/tools/tasks.js +0 -170
- package/dist/tools/tasks.js.map +0 -1
- package/dist/tools/workspaceSnapshots.d.ts +0 -174
- package/dist/tools/workspaceSnapshots.js +0 -474
- package/dist/tools/workspaceSnapshots.js.map +0 -1
package/dist/oauth.js
CHANGED
|
@@ -1,265 +1,148 @@
|
|
|
1
|
-
import crypto from "node:crypto";
|
|
2
|
-
import http from "node:http";
|
|
3
|
-
import { parse as parseQs } from "node:querystring";
|
|
4
1
|
/**
|
|
5
|
-
* OAuth 2.
|
|
2
|
+
* OAuth 2.0 Authorization Server for claude-ide-bridge.
|
|
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)
|
|
6
8
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
9
|
+
* Design
|
|
10
|
+
* All state is in-memory. The bridge's static bearer token is the resource owner
|
|
11
|
+
* credential: only someone who knows it can open an OAuth flow via the approval page.
|
|
12
|
+
* Issued access tokens are opaque base64url strings stored in a TTL map.
|
|
13
|
+
* resolveBearerToken() is called by server.ts to admit OAuth-issued tokens alongside
|
|
14
|
+
* the static bridge token (backward compat).
|
|
15
|
+
* Refresh tokens are not issued.
|
|
13
16
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
17
|
+
* Security
|
|
18
|
+
* PKCE S256 mandatory. Auth codes single-use, 5 min TTL. Access tokens 1 h TTL.
|
|
19
|
+
* All string comparisons via crypto.timingSafeEqual. HTML output attribute-escaped.
|
|
16
20
|
*/
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
]
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
.
|
|
33
|
-
.replace(
|
|
34
|
-
.
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
.
|
|
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"`;
|
|
21
|
+
import crypto from "node:crypto";
|
|
22
|
+
import { URL } from "node:url";
|
|
23
|
+
// ── Constants ─────────────────────────────────────────────────────────────────
|
|
24
|
+
const CODE_TTL_MS = 5 * 60 * 1_000; // 5 min
|
|
25
|
+
const TOKEN_TTL_MS = 60 * 60 * 1_000; // 1 hour
|
|
26
|
+
const DEFAULT_SCOPE = "mcp";
|
|
27
|
+
const SUPPORTED_SCOPES = ["mcp"];
|
|
28
|
+
// ── OAuthServerImpl ───────────────────────────────────────────────────────────
|
|
29
|
+
export class OAuthServerImpl {
|
|
30
|
+
bridgeToken;
|
|
31
|
+
issuerUrl;
|
|
32
|
+
authCodes = new Map();
|
|
33
|
+
accessTokens = new Map();
|
|
34
|
+
gcTimer;
|
|
35
|
+
constructor(bridgeToken, issuerUrl) {
|
|
36
|
+
this.bridgeToken = bridgeToken;
|
|
37
|
+
this.issuerUrl = issuerUrl.replace(/\/$/, "");
|
|
38
|
+
this.gcTimer = setInterval(() => {
|
|
39
|
+
const now = Date.now();
|
|
40
|
+
for (const [k, v] of this.authCodes)
|
|
41
|
+
if (v.expiresAt < now)
|
|
42
|
+
this.authCodes.delete(k);
|
|
43
|
+
for (const [k, v] of this.accessTokens)
|
|
44
|
+
if (v.expiresAt < now)
|
|
45
|
+
this.accessTokens.delete(k);
|
|
46
|
+
}, 10 * 60 * 1_000);
|
|
47
|
+
this.gcTimer.unref();
|
|
162
48
|
}
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
});
|
|
49
|
+
destroy() {
|
|
50
|
+
clearInterval(this.gcTimer);
|
|
174
51
|
}
|
|
175
|
-
//
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
authorization_endpoint: `${base}/authorize`,
|
|
183
|
-
token_endpoint: `${base}/token`,
|
|
52
|
+
// ── RFC 8414 discovery ────────────────────────────────────────────────────
|
|
53
|
+
handleDiscovery(res) {
|
|
54
|
+
this.sendJson(res, 200, {
|
|
55
|
+
issuer: this.issuerUrl,
|
|
56
|
+
authorization_endpoint: `${this.issuerUrl}/oauth/authorize`,
|
|
57
|
+
token_endpoint: `${this.issuerUrl}/oauth/token`,
|
|
58
|
+
revocation_endpoint: `${this.issuerUrl}/oauth/revoke`,
|
|
184
59
|
response_types_supported: ["code"],
|
|
185
60
|
grant_types_supported: ["authorization_code"],
|
|
186
61
|
code_challenge_methods_supported: ["S256"],
|
|
187
62
|
token_endpoint_auth_methods_supported: ["none"],
|
|
188
|
-
scopes_supported:
|
|
63
|
+
scopes_supported: SUPPORTED_SCOPES,
|
|
189
64
|
});
|
|
190
65
|
}
|
|
191
|
-
//
|
|
192
|
-
// GET /authorize — show approval page
|
|
193
|
-
// POST /authorize — process approval form
|
|
194
|
-
// ──────────────────────────────────────────────────────────
|
|
66
|
+
// ── Authorization endpoint ────────────────────────────────────────────────
|
|
195
67
|
async handleAuthorize(req, res) {
|
|
196
|
-
|
|
197
|
-
|
|
68
|
+
const method = req.method ?? "GET";
|
|
69
|
+
if (method === "GET") {
|
|
70
|
+
this.authorizeGet(req, res);
|
|
198
71
|
}
|
|
199
|
-
if (
|
|
200
|
-
|
|
72
|
+
else if (method === "POST") {
|
|
73
|
+
await this.authorizePost(req, res);
|
|
74
|
+
}
|
|
75
|
+
else {
|
|
76
|
+
res.writeHead(405, { "Content-Type": "text/plain", Allow: "GET, POST" });
|
|
77
|
+
res.end("Method Not Allowed");
|
|
201
78
|
}
|
|
202
|
-
res.writeHead(405, { Allow: "GET, POST" });
|
|
203
|
-
res.end("Method Not Allowed");
|
|
204
79
|
}
|
|
205
|
-
|
|
206
|
-
const url = new URL(req.url ?? "/",
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
if (err) {
|
|
80
|
+
authorizeGet(req, res) {
|
|
81
|
+
const url = new URL(req.url ?? "/", this.issuerUrl);
|
|
82
|
+
const { error, clientId, redirectUri, codeChallenge, scope, state } = this.parseAuthorizeParams(url);
|
|
83
|
+
if (error) {
|
|
210
84
|
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
211
|
-
res.end(
|
|
85
|
+
res.end(error);
|
|
212
86
|
return;
|
|
213
87
|
}
|
|
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
|
-
});
|
|
221
88
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
222
|
-
res.end(
|
|
89
|
+
res.end(this.approvalPage({
|
|
90
|
+
clientId: clientId,
|
|
91
|
+
redirectUri: redirectUri,
|
|
92
|
+
codeChallenge: codeChallenge,
|
|
93
|
+
scope: scope ?? DEFAULT_SCOPE,
|
|
94
|
+
state: state ?? "",
|
|
95
|
+
}));
|
|
223
96
|
}
|
|
224
|
-
async
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
}
|
|
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) {
|
|
97
|
+
async authorizePost(req, res) {
|
|
98
|
+
const body = await this.readBody(req);
|
|
99
|
+
const action = body.get("action");
|
|
100
|
+
const clientId = body.get("client_id") ?? "";
|
|
101
|
+
const redirectUri = body.get("redirect_uri") ?? "";
|
|
102
|
+
const codeChallenge = body.get("code_challenge") ?? "";
|
|
103
|
+
const scope = body.get("scope") ?? DEFAULT_SCOPE;
|
|
104
|
+
const state = body.get("state") ?? "";
|
|
105
|
+
if (!clientId || !redirectUri || !codeChallenge) {
|
|
239
106
|
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
240
|
-
res.end(
|
|
107
|
+
res.end("missing parameters");
|
|
241
108
|
return;
|
|
242
109
|
}
|
|
243
|
-
if (
|
|
244
|
-
const
|
|
245
|
-
|
|
110
|
+
if (action === "deny") {
|
|
111
|
+
const u = new URL(redirectUri);
|
|
112
|
+
u.searchParams.set("error", "access_denied");
|
|
246
113
|
if (state)
|
|
247
|
-
|
|
248
|
-
res.writeHead(302, { Location:
|
|
114
|
+
u.searchParams.set("state", state);
|
|
115
|
+
res.writeHead(302, { Location: u.toString() });
|
|
249
116
|
res.end();
|
|
250
117
|
return;
|
|
251
118
|
}
|
|
252
|
-
if (
|
|
253
|
-
res.writeHead(
|
|
254
|
-
res.end("
|
|
119
|
+
if (action !== "approve") {
|
|
120
|
+
res.writeHead(400, { "Content-Type": "text/plain" });
|
|
121
|
+
res.end("invalid action");
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
// Verify bridge token on approve
|
|
125
|
+
const presentedToken = body.get("bridge_token") ?? "";
|
|
126
|
+
if (!this.safeEqual(presentedToken, this.bridgeToken)) {
|
|
127
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
128
|
+
res.end(this.approvalPage({
|
|
129
|
+
clientId,
|
|
130
|
+
redirectUri,
|
|
131
|
+
codeChallenge,
|
|
132
|
+
scope,
|
|
133
|
+
state,
|
|
134
|
+
tokenError: true,
|
|
135
|
+
}));
|
|
255
136
|
return;
|
|
256
137
|
}
|
|
257
|
-
const code =
|
|
258
|
-
this.
|
|
259
|
-
|
|
138
|
+
const code = this.randomToken(32);
|
|
139
|
+
this.authCodes.set(code, {
|
|
140
|
+
clientId,
|
|
260
141
|
redirectUri,
|
|
261
|
-
|
|
142
|
+
codeChallenge,
|
|
143
|
+
scope,
|
|
262
144
|
expiresAt: Date.now() + CODE_TTL_MS,
|
|
145
|
+
used: false,
|
|
263
146
|
});
|
|
264
147
|
const dest = new URL(redirectUri);
|
|
265
148
|
dest.searchParams.set("code", code);
|
|
@@ -268,67 +151,240 @@ export class OAuthServer {
|
|
|
268
151
|
res.writeHead(302, { Location: dest.toString() });
|
|
269
152
|
res.end();
|
|
270
153
|
}
|
|
271
|
-
|
|
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
|
-
// ──────────────────────────────────────────────────────────
|
|
154
|
+
// ── Token endpoint ────────────────────────────────────────────────────────
|
|
285
155
|
async handleToken(req, res) {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
156
|
+
const body = await this.readBody(req);
|
|
157
|
+
if (body.get("grant_type") !== "authorization_code") {
|
|
158
|
+
this.sendError(res, 400, "unsupported_grant_type");
|
|
289
159
|
return;
|
|
290
160
|
}
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
161
|
+
const code = body.get("code") ?? "";
|
|
162
|
+
const redirectUri = body.get("redirect_uri") ?? "";
|
|
163
|
+
const clientId = body.get("client_id") ?? "";
|
|
164
|
+
const verifier = body.get("code_verifier") ?? "";
|
|
165
|
+
if (!code || !redirectUri || !clientId || !verifier) {
|
|
166
|
+
this.sendError(res, 400, "invalid_request", "missing required parameters");
|
|
167
|
+
return;
|
|
294
168
|
}
|
|
295
|
-
|
|
296
|
-
|
|
169
|
+
const record = this.authCodes.get(code);
|
|
170
|
+
if (!record) {
|
|
171
|
+
this.sendError(res, 400, "invalid_grant", "authorization code not found or expired");
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
if (record.used) {
|
|
175
|
+
this.sendError(res, 400, "invalid_grant", "authorization code already used");
|
|
297
176
|
return;
|
|
298
177
|
}
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
178
|
+
if (record.expiresAt < Date.now()) {
|
|
179
|
+
this.authCodes.delete(code);
|
|
180
|
+
this.sendError(res, 400, "invalid_grant", "authorization code expired");
|
|
302
181
|
return;
|
|
303
182
|
}
|
|
304
|
-
|
|
305
|
-
|
|
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");
|
|
183
|
+
if (!this.safeEqual(record.clientId, clientId)) {
|
|
184
|
+
this.sendError(res, 400, "invalid_grant", "client_id mismatch");
|
|
311
185
|
return;
|
|
312
186
|
}
|
|
313
|
-
if (
|
|
314
|
-
|
|
187
|
+
if (!this.safeEqual(record.redirectUri, redirectUri)) {
|
|
188
|
+
this.sendError(res, 400, "invalid_grant", "redirect_uri mismatch");
|
|
315
189
|
return;
|
|
316
190
|
}
|
|
317
|
-
if (!
|
|
318
|
-
|
|
191
|
+
if (!this.pkceVerify(verifier, record.codeChallenge)) {
|
|
192
|
+
this.sendError(res, 400, "invalid_grant", "code_verifier mismatch");
|
|
319
193
|
return;
|
|
320
194
|
}
|
|
321
|
-
|
|
322
|
-
this.
|
|
323
|
-
|
|
324
|
-
|
|
195
|
+
record.used = true;
|
|
196
|
+
const accessToken = this.randomToken(32);
|
|
197
|
+
this.accessTokens.set(accessToken, {
|
|
198
|
+
clientId,
|
|
199
|
+
scope: record.scope,
|
|
200
|
+
expiresAt: Date.now() + TOKEN_TTL_MS,
|
|
201
|
+
});
|
|
202
|
+
this.sendJson(res, 200, {
|
|
203
|
+
access_token: accessToken,
|
|
325
204
|
token_type: "Bearer",
|
|
326
|
-
expires_in:
|
|
327
|
-
scope:
|
|
205
|
+
expires_in: Math.floor(TOKEN_TTL_MS / 1_000),
|
|
206
|
+
scope: record.scope,
|
|
328
207
|
});
|
|
329
208
|
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
209
|
+
// ── Revocation endpoint (RFC 7009) ────────────────────────────────────────
|
|
210
|
+
async handleRevoke(req, res) {
|
|
211
|
+
try {
|
|
212
|
+
const body = await this.readBody(req);
|
|
213
|
+
const token = body.get("token");
|
|
214
|
+
if (token) {
|
|
215
|
+
this.accessTokens.delete(token);
|
|
216
|
+
this.authCodes.delete(token);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
// RFC 7009: always 200
|
|
221
|
+
}
|
|
222
|
+
res.writeHead(200, {
|
|
223
|
+
"Content-Type": "application/json",
|
|
224
|
+
"Cache-Control": "no-store",
|
|
225
|
+
});
|
|
226
|
+
res.end("{}");
|
|
227
|
+
}
|
|
228
|
+
// ── Bearer resolution (called by server.ts) ───────────────────────────────
|
|
229
|
+
resolveBearerToken(token) {
|
|
230
|
+
const record = this.accessTokens.get(token);
|
|
231
|
+
if (!record)
|
|
232
|
+
return null;
|
|
233
|
+
if (record.expiresAt < Date.now()) {
|
|
234
|
+
this.accessTokens.delete(token);
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
return this.bridgeToken;
|
|
238
|
+
}
|
|
239
|
+
// ── Private helpers ───────────────────────────────────────────────────────
|
|
240
|
+
randomToken(bytes) {
|
|
241
|
+
return crypto.randomBytes(bytes).toString("base64url");
|
|
242
|
+
}
|
|
243
|
+
safeEqual(a, b) {
|
|
244
|
+
const ab = Buffer.from(a);
|
|
245
|
+
const bb = Buffer.from(b);
|
|
246
|
+
const lenA = Buffer.allocUnsafe(4);
|
|
247
|
+
const lenB = Buffer.allocUnsafe(4);
|
|
248
|
+
lenA.writeUInt32BE(ab.length, 0);
|
|
249
|
+
lenB.writeUInt32BE(bb.length, 0);
|
|
250
|
+
const lenOk = crypto.timingSafeEqual(lenA, lenB);
|
|
251
|
+
const len = Math.max(ab.length, bb.length);
|
|
252
|
+
const padA = Buffer.alloc(len);
|
|
253
|
+
const padB = Buffer.alloc(len);
|
|
254
|
+
ab.copy(padA);
|
|
255
|
+
bb.copy(padB);
|
|
256
|
+
return crypto.timingSafeEqual(padA, padB) && lenOk;
|
|
257
|
+
}
|
|
258
|
+
pkceVerify(verifier, challenge) {
|
|
259
|
+
const hash = crypto
|
|
260
|
+
.createHash("sha256")
|
|
261
|
+
.update(verifier)
|
|
262
|
+
.digest("base64url");
|
|
263
|
+
return this.safeEqual(hash, challenge);
|
|
264
|
+
}
|
|
265
|
+
readBody(req) {
|
|
266
|
+
return new Promise((resolve, reject) => {
|
|
267
|
+
let data = "";
|
|
268
|
+
req.on("data", (chunk) => {
|
|
269
|
+
data += chunk.toString();
|
|
270
|
+
if (data.length > 8_192)
|
|
271
|
+
reject(new Error("Request body too large"));
|
|
272
|
+
});
|
|
273
|
+
req.on("end", () => resolve(new URLSearchParams(data)));
|
|
274
|
+
req.on("error", reject);
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
sendJson(res, status, body) {
|
|
278
|
+
res.writeHead(status, {
|
|
279
|
+
"Content-Type": "application/json",
|
|
280
|
+
"Cache-Control": "no-store",
|
|
281
|
+
Pragma: "no-cache",
|
|
282
|
+
});
|
|
283
|
+
res.end(JSON.stringify(body));
|
|
284
|
+
}
|
|
285
|
+
sendError(res, status, error, description) {
|
|
286
|
+
this.sendJson(res, status, {
|
|
287
|
+
error,
|
|
288
|
+
...(description ? { error_description: description } : {}),
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
parseAuthorizeParams(url) {
|
|
292
|
+
const responseType = url.searchParams.get("response_type");
|
|
293
|
+
const clientId = url.searchParams.get("client_id");
|
|
294
|
+
const redirectUri = url.searchParams.get("redirect_uri");
|
|
295
|
+
const codeChallenge = url.searchParams.get("code_challenge");
|
|
296
|
+
const codeChallengeMethod = url.searchParams.get("code_challenge_method");
|
|
297
|
+
const state = url.searchParams.get("state");
|
|
298
|
+
if (responseType !== "code")
|
|
299
|
+
return { error: "unsupported_response_type" };
|
|
300
|
+
if (!clientId || !redirectUri || !codeChallenge)
|
|
301
|
+
return { error: "invalid_request" };
|
|
302
|
+
if (codeChallengeMethod !== "S256")
|
|
303
|
+
return { error: "invalid_request" };
|
|
304
|
+
return {
|
|
305
|
+
clientId,
|
|
306
|
+
redirectUri,
|
|
307
|
+
codeChallenge,
|
|
308
|
+
scope: url.searchParams.get("scope") ?? DEFAULT_SCOPE,
|
|
309
|
+
state: state ?? "",
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
// ── Approval page HTML ────────────────────────────────────────────────────
|
|
313
|
+
approvalPage(opts) {
|
|
314
|
+
const e = (s) => s
|
|
315
|
+
.replace(/&/g, "&")
|
|
316
|
+
.replace(/</g, "<")
|
|
317
|
+
.replace(/>/g, ">")
|
|
318
|
+
.replace(/"/g, """)
|
|
319
|
+
.replace(/'/g, "'");
|
|
320
|
+
return `<!DOCTYPE html>
|
|
321
|
+
<html lang="en">
|
|
322
|
+
<head>
|
|
323
|
+
<meta charset="UTF-8">
|
|
324
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
325
|
+
<title>Authorize \u2014 Claude IDE Bridge</title>
|
|
326
|
+
<style>
|
|
327
|
+
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
|
|
328
|
+
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
|
|
329
|
+
background:#0f1117;color:#e2e8f0;display:flex;align-items:center;
|
|
330
|
+
justify-content:center;min-height:100vh;padding:2rem}
|
|
331
|
+
.card{background:#1a1d27;border:1px solid #2d3148;border-radius:12px;
|
|
332
|
+
padding:2rem;max-width:420px;width:100%;box-shadow:0 4px 32px rgba(0,0,0,.4)}
|
|
333
|
+
.logo{font-size:1.5rem;font-weight:700;color:#818cf8;margin-bottom:1.5rem}
|
|
334
|
+
h1{font-size:1.1rem;margin-bottom:.5rem}
|
|
335
|
+
.client{font-size:.9rem;color:#94a3b8;margin-bottom:1.5rem;word-break:break-all}
|
|
336
|
+
.scope{background:#12141e;border:1px solid #2d3148;border-radius:8px;
|
|
337
|
+
padding:1rem;margin-bottom:1.5rem;font-size:.875rem;color:#94a3b8}
|
|
338
|
+
.scope strong{color:#e2e8f0;display:block;margin-bottom:.5rem}
|
|
339
|
+
.item::before{content:"\u2713 ";color:#34d399}
|
|
340
|
+
.token-field{margin-bottom:1.25rem}
|
|
341
|
+
.token-field label{display:block;font-size:.8rem;color:#94a3b8;margin-bottom:.4rem}
|
|
342
|
+
.token-field input{width:100%;padding:.5rem .75rem;background:#12141e;border:1px solid #2d3148;
|
|
343
|
+
border-radius:6px;color:#e2e8f0;font-size:.875rem;font-family:monospace}
|
|
344
|
+
.token-field input.err{border-color:#f87171}
|
|
345
|
+
.token-err{color:#f87171;font-size:.8rem;margin-top:.3rem}
|
|
346
|
+
.actions{display:flex;gap:.75rem}
|
|
347
|
+
button{flex:1;padding:.65rem 1rem;border:none;border-radius:8px;
|
|
348
|
+
font-size:.95rem;font-weight:600;cursor:pointer;transition:opacity .15s}
|
|
349
|
+
button:hover{opacity:.85}
|
|
350
|
+
.approve{background:#818cf8;color:#0f1117}
|
|
351
|
+
.deny{background:#2d3148;color:#94a3b8}
|
|
352
|
+
footer{margin-top:1.25rem;font-size:.75rem;color:#475569;text-align:center}
|
|
353
|
+
</style>
|
|
354
|
+
</head>
|
|
355
|
+
<body>
|
|
356
|
+
<div class="card">
|
|
357
|
+
<div class="logo">\u29ed Claude IDE Bridge</div>
|
|
358
|
+
<h1>Authorization Request</h1>
|
|
359
|
+
<p class="client">Client: <strong>${e(opts.clientId)}</strong></p>
|
|
360
|
+
<div class="scope">
|
|
361
|
+
<strong>Requested permissions</strong>
|
|
362
|
+
<div class="item">Full MCP tool access (read, write, execute)</div>
|
|
363
|
+
</div>
|
|
364
|
+
<form method="POST" action="/oauth/authorize">
|
|
365
|
+
<input type="hidden" name="client_id" value="${e(opts.clientId)}">
|
|
366
|
+
<input type="hidden" name="redirect_uri" value="${e(opts.redirectUri)}">
|
|
367
|
+
<input type="hidden" name="code_challenge" value="${e(opts.codeChallenge)}">
|
|
368
|
+
<input type="hidden" name="scope" value="${e(opts.scope)}">
|
|
369
|
+
<input type="hidden" name="state" value="${e(opts.state)}">
|
|
370
|
+
<div class="token-field">
|
|
371
|
+
<label for="bridge_token">Bridge Token</label>
|
|
372
|
+
<input id="bridge_token" type="password" name="bridge_token" placeholder="Paste your bridge token"
|
|
373
|
+
class="${opts.tokenError ? "err" : ""}" autocomplete="off" required>
|
|
374
|
+
${opts.tokenError ? '<div class="token-err">Incorrect token — check your bridge token and try again.</div>' : ""}
|
|
375
|
+
</div>
|
|
376
|
+
<div class="actions">
|
|
377
|
+
<button class="approve" type="submit" name="action" value="approve">Authorize</button>
|
|
378
|
+
<button class="deny" type="submit" name="action" value="deny">Deny</button>
|
|
379
|
+
</div>
|
|
380
|
+
</form>
|
|
381
|
+
<footer>
|
|
382
|
+
Issuer: ${e(this.issuerUrl)}<br>
|
|
383
|
+
Only approve if you initiated this from your MCP client.
|
|
384
|
+
</footer>
|
|
385
|
+
</div>
|
|
386
|
+
</body>
|
|
387
|
+
</html>`;
|
|
388
|
+
}
|
|
333
389
|
}
|
|
334
390
|
//# sourceMappingURL=oauth.js.map
|