Skip to content

Commit ada6cdd

Browse files
committed
feat(oauth): use asExternalUrl to get the correct callback
1 parent b3f4b82 commit ada6cdd

File tree

15 files changed

+411
-129
lines changed

15 files changed

+411
-129
lines changed

CONTRIBUTING.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,6 @@ Continue is quickly adding features, and we'd love to hear which are the most im
6060
an enhancement are:
6161

6262
- Create an issue
63-
6463
- First, check whether a similar proposal has already been made
6564
- If not, [create an issue](https://github.com/continuedev/continue/issues)
6665
- Please describe the enhancement in as much detail as you can, and why it would be useful
@@ -146,7 +145,6 @@ npm i -g vite
146145
`install-all-dependencies`
147146

148147
2. Start debugging:
149-
150148
1. Switch to Run and Debug view
151149
2. Select `Launch extension` from drop down
152150
3. Hit play button

binary/package-lock.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

core/config/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -692,6 +692,8 @@ declare global {
692692
693693
openUrl(url: string): Promise<void>;
694694
695+
getExternalUri?(uri: string): Promise<string>;
696+
695697
runCommand(command: string): Promise<void>;
696698
697699
saveFile(filepath: string): Promise<void>;

core/context/mcp/MCPOauth.ts

Lines changed: 193 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -12,65 +12,121 @@ import { IDE, MCPServerStatus, SSEOptions } from "../..";
1212

1313
import http from "http";
1414
import url from "url";
15+
import crypto from "crypto";
1516
import { GlobalContext, GlobalContextType } from "../../util/GlobalContext";
1617

17-
let authenticatingMCPContext = null as {
18-
authenticatingServer: MCPServerStatus;
19-
ide: IDE;
20-
} | null;
18+
// Use a Map to support concurrent authentications for different servers
19+
const authenticatingContexts = new Map<
20+
string,
21+
{
22+
authenticatingServer: MCPServerStatus;
23+
ide: IDE;
24+
state?: string;
25+
}
26+
>();
27+
28+
// Map state parameters to server URLs for OAuth callback matching
29+
const stateToServerUrl = new Map<string, string>();
2130

2231
const PORT = 3000;
2332

24-
const server = http.createServer((req, res) => {
25-
try {
26-
if (!req.url) {
27-
throw new Error("no url found");
28-
}
33+
let serverInstance: http.Server | null = null;
2934

30-
const parsedUrl = url.parse(req.url, true);
31-
if (!parsedUrl.query["code"]) {
32-
throw new Error("no query params found");
33-
}
35+
const createServerForOAuth = () =>
36+
http.createServer((req, res) => {
37+
try {
38+
if (!req.url) {
39+
throw new Error("no url found");
40+
}
41+
42+
const parsedUrl = url.parse(req.url, true);
43+
if (!parsedUrl.query["code"]) {
44+
throw new Error("no query params found");
45+
}
46+
47+
const code = parsedUrl.query["code"] as string;
48+
const state = parsedUrl.query["state"] as string | undefined;
3449

35-
void handleMCPOauthCode(parsedUrl.query["code"] as string);
50+
void handleMCPOauthCode(code, state);
3651

37-
const html = `
52+
const html = `
3853
<!DOCTYPE html>
3954
<html>
4055
<head><title>Authentication Complete</title></head>
4156
<body>Authentication Complete. You can close this page now.</body>
4257
</html>`;
4358

44-
res.writeHead(200, {
45-
"Content-Type": "text/html",
46-
});
47-
res.end(html);
48-
} catch (error) {
49-
res.writeHead(400, { "Content-Type": "text/plain" });
50-
res.end(`Unexpected redirect error: ${(error as Error).message}`);
51-
}
52-
});
59+
res.writeHead(200, {
60+
"Content-Type": "text/html",
61+
});
62+
res.end(html);
63+
} catch (error) {
64+
res.writeHead(400, { "Content-Type": "text/plain" });
65+
res.end(`Unexpected redirect error: ${(error as Error).message}`);
66+
}
67+
});
5368

5469
type MCPOauthStorage = GlobalContextType["mcpOauthStorage"][string];
5570
type MCPOauthStorageKey = keyof MCPOauthStorage;
5671

5772
class MCPConnectionOauthProvider implements OAuthClientProvider {
5873
private globalContext: GlobalContext;
74+
private _redirectUrl: string;
75+
private _redirectUrlInitialized: Promise<void>;
5976

6077
constructor(
6178
public oauthServerUrl: string,
6279
private ide: IDE,
6380
) {
6481
this.globalContext = new GlobalContext();
82+
// Set default redirect URL immediately for synchronous access
83+
this._redirectUrl = `http://localhost:${PORT}`;
84+
// Initialize actual redirect URL asynchronously
85+
this._redirectUrlInitialized = this._initializeRedirectUrl();
86+
}
87+
88+
private async _initializeRedirectUrl(): Promise<void> {
89+
try {
90+
// If IDE supports getExternalUri (VS Code extension), use it for dynamic redirect URI
91+
if (this.ide.getExternalUri) {
92+
const localUri = `http://localhost:${PORT}`;
93+
const externalUri = await this.ide.getExternalUri(localUri);
94+
this._redirectUrl = externalUri;
95+
}
96+
// Otherwise keep the default localhost URL
97+
} catch (error) {
98+
console.error("Failed to initialize redirect URL:", error);
99+
// Keep default URL on error
100+
}
65101
}
66102

67103
get redirectUrl() {
68-
return `http://localhost:${PORT}`; // TODO: this has to be a hub url or should we spin up a server?
104+
// Always return a valid URL, even if initialization is pending
105+
return this._redirectUrl;
106+
}
107+
108+
getRedirectUrlWithState(state: string): string {
109+
// Add state parameter to redirect URL
110+
const urlObj = new URL(this._redirectUrl);
111+
urlObj.searchParams.set("state", state);
112+
return urlObj.toString();
113+
}
114+
115+
async ensureRedirectUrl(): Promise<string> {
116+
// Wait for initialization to complete before returning
117+
await this._redirectUrlInitialized;
118+
return this._redirectUrl;
69119
}
70120

71121
get clientMetadata() {
122+
// Generate state parameter if needed
123+
const state = authenticatingContexts.get(this.oauthServerUrl)?.state;
124+
const redirectUri = state
125+
? this.getRedirectUrlWithState(state)
126+
: this.redirectUrl;
127+
72128
return {
73-
redirect_uris: [this.redirectUrl],
129+
redirect_uris: [redirectUri],
74130
token_endpoint_auth_method: "none",
75131
grant_types: ["authorization_code", "refresh_token"],
76132
response_types: ["code"],
@@ -151,14 +207,28 @@ class MCPConnectionOauthProvider implements OAuthClientProvider {
151207
}
152208

153209
async redirectToAuthorization(authorizationUrl: URL) {
154-
if (!server.listening) {
155-
server.listen(PORT, () => {
156-
console.debug(
157-
`Server started for MCP Oauth process at http://localhost:${PORT}/`,
158-
);
159-
});
210+
// Ensure redirect URL is initialized before proceeding
211+
await this.ensureRedirectUrl();
212+
213+
// Only create and start local server if using localhost redirect
214+
// For web-based VS Code, the redirect will be handled by VS Code's built-in mechanism
215+
if (!this.ide.getExternalUri || this._redirectUrl.includes("localhost")) {
216+
if (!serverInstance) {
217+
serverInstance = createServerForOAuth();
218+
}
219+
if (!serverInstance.listening) {
220+
await new Promise<void>((resolve, reject) => {
221+
serverInstance!.listen(PORT, () => {
222+
console.debug(
223+
`Server started for MCP Oauth process at http://localhost:${PORT}/`,
224+
);
225+
resolve();
226+
});
227+
serverInstance!.on("error", reject);
228+
});
229+
}
160230
}
161-
void this.ide.openUrl(authorizationUrl.toString());
231+
await this.ide.openUrl(authorizationUrl.toString());
162232
}
163233
}
164234

@@ -175,49 +245,109 @@ export async function getOauthToken(mcpServerUrl: string, ide: IDE) {
175245
export async function performAuth(mcpServer: MCPServerStatus, ide: IDE) {
176246
const mcpServerUrl = (mcpServer.transport as SSEOptions).url;
177247
const authProvider = new MCPConnectionOauthProvider(mcpServerUrl, ide);
178-
authenticatingMCPContext = {
248+
// Ensure redirect URL is ready before starting auth
249+
await authProvider.ensureRedirectUrl();
250+
251+
// Generate a unique state parameter for this auth flow
252+
const state = crypto.randomUUID();
253+
254+
// Store context for this specific server with state
255+
authenticatingContexts.set(mcpServerUrl, {
179256
authenticatingServer: mcpServer,
180257
ide,
181-
};
182-
return await auth(authProvider, {
183-
serverUrl: mcpServerUrl,
258+
state,
184259
});
260+
261+
// Map state to server URL for callback matching
262+
stateToServerUrl.set(state, mcpServerUrl);
263+
264+
try {
265+
return await auth(authProvider, {
266+
serverUrl: mcpServerUrl,
267+
});
268+
} catch (error) {
269+
// Clean up on error
270+
authenticatingContexts.delete(mcpServerUrl);
271+
stateToServerUrl.delete(state);
272+
throw error;
273+
}
185274
}
186275

187276
/**
188277
* handle the authentication code received from the oauth redirect
189278
*/
190-
async function handleMCPOauthCode(authorizationCode: string) {
191-
if (!authenticatingMCPContext) {
192-
return;
193-
}
194-
const { ide, authenticatingServer } = authenticatingMCPContext;
195-
const serverUrl = (authenticatingServer.transport as SSEOptions).url;
279+
async function handleMCPOauthCode(authorizationCode: string, state?: string) {
280+
let serverUrl: string | undefined;
281+
let context:
282+
| { authenticatingServer: MCPServerStatus; ide: IDE; state?: string }
283+
| undefined;
196284

197-
if (!serverUrl) {
198-
void ide.showToast("error", "No MCP server url found for authentication");
199-
return;
285+
if (state) {
286+
// Use state parameter to find the correct server
287+
serverUrl = stateToServerUrl.get(state);
288+
if (serverUrl) {
289+
context = authenticatingContexts.get(serverUrl);
290+
}
291+
} else {
292+
// Fallback: if no state or single context, use the first one
293+
const contexts = Array.from(authenticatingContexts.entries());
294+
if (contexts.length === 1) {
295+
[serverUrl, context] = contexts[0];
296+
}
200297
}
201-
if (!authorizationCode) {
202-
void ide.showToast(
203-
"error",
204-
`No MCP authorization code found for ${serverUrl}`,
205-
);
298+
299+
if (!context || !serverUrl) {
300+
console.error("No matching authenticating context found for state:", state);
206301
return;
207302
}
208-
server.close(() => console.debug("Server for MCP Oauth process was closed"));
209-
const authProvider = new MCPConnectionOauthProvider(serverUrl, ide);
210-
const authStatus = await auth(authProvider, {
211-
serverUrl,
212-
authorizationCode,
213-
});
214-
if (authStatus === "AUTHORIZED") {
215-
const { MCPManagerSingleton } = await import("./MCPManagerSingleton"); // put dynamic import to avoid cyclic imports
216-
await MCPManagerSingleton.getInstance().refreshConnection(
217-
authenticatingServer.id,
218-
);
303+
304+
const { ide, authenticatingServer } = context;
305+
306+
try {
307+
if (!serverUrl) {
308+
throw new Error("No MCP server url found for authentication");
309+
}
310+
if (!authorizationCode) {
311+
throw new Error(`No MCP authorization code found for ${serverUrl}`);
312+
}
313+
314+
// Close the server before processing auth
315+
if (serverInstance) {
316+
await new Promise<void>((resolve) => {
317+
serverInstance!.close(() => {
318+
console.debug("Server for MCP Oauth process was closed");
319+
serverInstance = null;
320+
resolve();
321+
});
322+
});
323+
}
324+
325+
const authProvider = new MCPConnectionOauthProvider(serverUrl, ide);
326+
await authProvider.ensureRedirectUrl();
327+
const authStatus = await auth(authProvider, {
328+
serverUrl,
329+
authorizationCode,
330+
});
331+
332+
if (authStatus === "AUTHORIZED") {
333+
const { MCPManagerSingleton } = await import("./MCPManagerSingleton"); // put dynamic import to avoid cyclic imports
334+
await MCPManagerSingleton.getInstance().refreshConnection(
335+
authenticatingServer.id,
336+
);
337+
}
338+
} catch (error) {
339+
const errorMessage = error instanceof Error ? error.message : String(error);
340+
console.error("OAuth authorization failed:", errorMessage);
341+
if (context?.ide) {
342+
await context.ide.showToast("error", `OAuth failed: ${errorMessage}`);
343+
}
344+
} finally {
345+
// Always clean up the context and state mapping
346+
authenticatingContexts.delete(serverUrl);
347+
if (state) {
348+
stateToServerUrl.delete(state);
349+
}
219350
}
220-
authenticatingMCPContext = null;
221351
}
222352

223353
export function removeMCPAuth(mcpServer: MCPServerStatus, ide: IDE) {

0 commit comments

Comments
 (0)