claude-ide-bridge 2.4.2 → 2.4.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.
package/dist/oauth.js CHANGED
@@ -1,265 +1,149 @@
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.1 Authorization Server + Resource Server for claude-ide-bridge.
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
- * 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
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
- * The existing authToken from the lock file is issued as the access token —
15
- * no new token system is needed.
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
- // 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, "&lt;")
34
- .replace(/>/g, "&gt;")
35
- .replace(/"/g, "&quot;")
36
- .replace(/'/g, "&#39;");
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"`;
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
- // 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
- });
49
+ destroy() {
50
+ clearInterval(this.gcTimer);
174
51
  }
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`,
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: ["mcp"],
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
- if (req.method === "GET") {
197
- return this.handleAuthorizeGet(req, res);
68
+ const method = req.method ?? "GET";
69
+ if (method === "GET") {
70
+ this.authorizeGet(req, res);
198
71
  }
199
- if (req.method === "POST") {
200
- return this.handleAuthorizePost(req, res);
72
+ else if (method === "POST") {
73
+ await this.authorizePost(req, res);
201
74
  }
202
- res.writeHead(405, { Allow: "GET, POST" });
203
- res.end("Method Not Allowed");
204
- }
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) {
210
- res.writeHead(400, { "Content-Type": "text/plain" });
211
- res.end(`Bad Request: ${err}`);
212
- return;
75
+ else {
76
+ res.writeHead(405, { "Content-Type": "text/plain", Allow: "GET, POST" });
77
+ res.end("Method Not Allowed");
213
78
  }
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
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
222
- res.end(html);
223
79
  }
224
- async handleAuthorizePost(req, res) {
225
- let body;
226
- try {
227
- body = await readBody(req);
80
+ authorizeGet(req, res) {
81
+ const url = new URL(req.url ?? "/", this.issuerUrl);
82
+ // Authenticate resource owner via bridge token
83
+ const authHeader = req.headers.authorization ?? "";
84
+ const fromHeader = authHeader.startsWith("Bearer ")
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;
228
96
  }
229
- catch {
97
+ const { error, clientId, redirectUri, codeChallenge, scope, state } = this.parseAuthorizeParams(url);
98
+ if (error) {
230
99
  res.writeHead(400, { "Content-Type": "text/plain" });
231
- res.end("Bad Request: could not read body");
100
+ res.end(error);
232
101
  return;
233
102
  }
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) {
103
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
104
+ res.end(this.approvalPage({
105
+ clientId: clientId,
106
+ redirectUri: redirectUri,
107
+ codeChallenge: codeChallenge,
108
+ scope: scope ?? DEFAULT_SCOPE,
109
+ state: state ?? "",
110
+ }));
111
+ }
112
+ async authorizePost(req, res) {
113
+ const body = await this.readBody(req);
114
+ const action = body.get("action");
115
+ const clientId = body.get("client_id") ?? "";
116
+ const redirectUri = body.get("redirect_uri") ?? "";
117
+ const codeChallenge = body.get("code_challenge") ?? "";
118
+ const scope = body.get("scope") ?? DEFAULT_SCOPE;
119
+ const state = body.get("state") ?? "";
120
+ if (!clientId || !redirectUri || !codeChallenge) {
239
121
  res.writeHead(400, { "Content-Type": "text/plain" });
240
- res.end(`Bad Request: ${err}`);
122
+ res.end("missing parameters");
241
123
  return;
242
124
  }
243
- if (p.approve !== "true") {
244
- const dest = new URL(redirectUri);
245
- dest.searchParams.set("error", "access_denied");
125
+ if (action === "deny") {
126
+ const u = new URL(redirectUri);
127
+ u.searchParams.set("error", "access_denied");
246
128
  if (state)
247
- dest.searchParams.set("state", state);
248
- res.writeHead(302, { Location: dest.toString() });
129
+ u.searchParams.set("state", state);
130
+ res.writeHead(302, { Location: u.toString() });
249
131
  res.end();
250
132
  return;
251
133
  }
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");
134
+ if (action !== "approve") {
135
+ res.writeHead(400, { "Content-Type": "text/plain" });
136
+ res.end("invalid action");
255
137
  return;
256
138
  }
257
- const code = crypto.randomBytes(32).toString("hex");
258
- this.codes.set(code, {
259
- challenge: p.code_challenge ?? "",
139
+ const code = this.randomToken(32);
140
+ this.authCodes.set(code, {
141
+ clientId,
260
142
  redirectUri,
261
- clientId: p.client_id ?? "",
143
+ codeChallenge,
144
+ scope,
262
145
  expiresAt: Date.now() + CODE_TTL_MS,
146
+ used: false,
263
147
  });
264
148
  const dest = new URL(redirectUri);
265
149
  dest.searchParams.set("code", code);
@@ -268,67 +152,228 @@ export class OAuthServer {
268
152
  res.writeHead(302, { Location: dest.toString() });
269
153
  res.end();
270
154
  }
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
- // ──────────────────────────────────────────────────────────
155
+ // ── Token endpoint ────────────────────────────────────────────────────────
285
156
  async handleToken(req, res) {
286
- if (req.method !== "POST") {
287
- res.writeHead(405, { Allow: "POST" });
288
- res.end("Method Not Allowed");
157
+ const body = await this.readBody(req);
158
+ if (body.get("grant_type") !== "authorization_code") {
159
+ this.sendError(res, 400, "unsupported_grant_type");
289
160
  return;
290
161
  }
291
- let body;
292
- try {
293
- body = await readBody(req);
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");
168
+ return;
294
169
  }
295
- catch {
296
- oauthError(res, 400, "invalid_request", "Could not read request body");
170
+ const record = this.authCodes.get(code);
171
+ if (!record) {
172
+ this.sendError(res, 400, "invalid_grant", "authorization code not found or expired");
173
+ return;
174
+ }
175
+ if (record.used) {
176
+ this.sendError(res, 400, "invalid_grant", "authorization code already used");
297
177
  return;
298
178
  }
299
- const p = parseQs(body);
300
- if (p.grant_type !== "authorization_code") {
301
- oauthError(res, 400, "unsupported_grant_type");
179
+ if (record.expiresAt < Date.now()) {
180
+ this.authCodes.delete(code);
181
+ this.sendError(res, 400, "invalid_grant", "authorization code expired");
302
182
  return;
303
183
  }
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");
184
+ if (!this.safeEqual(record.clientId, clientId)) {
185
+ this.sendError(res, 400, "invalid_grant", "client_id mismatch");
311
186
  return;
312
187
  }
313
- if (entry.redirectUri !== redirectUri) {
314
- oauthError(res, 400, "invalid_grant", "redirect_uri mismatch");
188
+ if (!this.safeEqual(record.redirectUri, redirectUri)) {
189
+ this.sendError(res, 400, "invalid_grant", "redirect_uri mismatch");
315
190
  return;
316
191
  }
317
- if (!verifyS256(verifier, entry.challenge)) {
318
- oauthError(res, 400, "invalid_grant", "PKCE verification failed");
192
+ if (!this.pkceVerify(verifier, record.codeChallenge)) {
193
+ this.sendError(res, 400, "invalid_grant", "code_verifier mismatch");
319
194
  return;
320
195
  }
321
- // Consume the code — single-use only.
322
- this.codes.delete(code);
323
- sendJson(res, 200, {
324
- access_token: this.authToken,
196
+ record.used = true;
197
+ const accessToken = this.randomToken(32);
198
+ this.accessTokens.set(accessToken, {
199
+ clientId,
200
+ scope: record.scope,
201
+ expiresAt: Date.now() + TOKEN_TTL_MS,
202
+ });
203
+ this.sendJson(res, 200, {
204
+ access_token: accessToken,
325
205
  token_type: "Bearer",
326
- expires_in: 0,
327
- scope: "mcp",
206
+ expires_in: Math.floor(TOKEN_TTL_MS / 1_000),
207
+ scope: record.scope,
328
208
  });
329
209
  }
330
- }
331
- export function createOAuthServer(authToken) {
332
- return new OAuthServer(authToken);
210
+ // ── Revocation endpoint (RFC 7009) ────────────────────────────────────────
211
+ async handleRevoke(req, res) {
212
+ try {
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, "&amp;")
317
+ .replace(/</g, "&lt;")
318
+ .replace(/>/g, "&gt;")
319
+ .replace(/"/g, "&quot;")
320
+ .replace(/'/g, "&#39;");
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
+ }
333
378
  }
334
379
  //# sourceMappingURL=oauth.js.map