Skip to content

Commit 6ddabb7

Browse files
Handle OAuth errors and validate redirect URLs (#631)
1 parent 2a87ef2 commit 6ddabb7

File tree

6 files changed

+151
-9
lines changed

6 files changed

+151
-9
lines changed

.changeset/happy-students-prove.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"agents": patch
3+
---
4+
5+
Handle OAuth errors and validate redirect URLs

packages/agents/src/index.ts

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1669,17 +1669,31 @@ export class Agent<
16691669

16701670
// Redirect to success URL if configured
16711671
if (config?.successRedirect && result.authSuccess) {
1672-
return Response.redirect(
1673-
new URL(config.successRedirect, baseOrigin).href
1674-
);
1672+
try {
1673+
return Response.redirect(
1674+
new URL(config.successRedirect, baseOrigin).href
1675+
);
1676+
} catch (e) {
1677+
console.error(
1678+
"Invalid successRedirect URL:",
1679+
config.successRedirect,
1680+
e
1681+
);
1682+
return Response.redirect(baseOrigin);
1683+
}
16751684
}
16761685

16771686
// Redirect to error URL if configured
16781687
if (config?.errorRedirect && !result.authSuccess) {
1679-
const errorUrl = `${config.errorRedirect}?error=${encodeURIComponent(
1680-
result.authError || "Unknown error"
1681-
)}`;
1682-
return Response.redirect(new URL(errorUrl, baseOrigin).href);
1688+
try {
1689+
const errorUrl = `${config.errorRedirect}?error=${encodeURIComponent(
1690+
result.authError || "Unknown error"
1691+
)}`;
1692+
return Response.redirect(new URL(errorUrl, baseOrigin).href);
1693+
} catch (e) {
1694+
console.error("Invalid errorRedirect URL:", config.errorRedirect, e);
1695+
return Response.redirect(baseOrigin);
1696+
}
16831697
}
16841698

16851699
// Default: redirect to base URL

packages/agents/src/mcp/client.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,8 +218,20 @@ export class MCPClientManager {
218218
}
219219
const code = url.searchParams.get("code");
220220
const state = url.searchParams.get("state");
221+
const error = url.searchParams.get("error");
222+
const errorDescription = url.searchParams.get("error_description");
221223
const urlParams = urlMatch.split("/");
222224
const serverId = urlParams[urlParams.length - 1];
225+
226+
// Handle OAuth error responses from the provider
227+
if (error) {
228+
return {
229+
serverId,
230+
authSuccess: false,
231+
authError: errorDescription || error
232+
};
233+
}
234+
223235
if (!code) {
224236
throw new Error("Unauthorized: no code provided");
225237
}

packages/agents/src/tests/mcp/client-manager.test.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,11 +174,26 @@ describe("MCPClientManager OAuth Integration", () => {
174174
).rejects.toThrow("No callback URI match found");
175175
});
176176

177-
it("should throw error for callback without code", async () => {
177+
it("should handle OAuth error response from provider", async () => {
178178
const callbackUrl = "http://localhost:3000/callback/server1";
179179
manager.registerCallbackUrl(callbackUrl);
180180

181-
const callbackRequest = new Request(`${callbackUrl}?error=access_denied`);
181+
const callbackRequest = new Request(
182+
`${callbackUrl}?error=access_denied&error_description=User%20denied%20access`
183+
);
184+
185+
const result = await manager.handleCallbackRequest(callbackRequest);
186+
187+
expect(result.serverId).toBe("server1");
188+
expect(result.authSuccess).toBe(false);
189+
expect(result.authError).toBe("User denied access");
190+
});
191+
192+
it("should throw error for callback without code or error", async () => {
193+
const callbackUrl = "http://localhost:3000/callback/server1";
194+
manager.registerCallbackUrl(callbackUrl);
195+
196+
const callbackRequest = new Request(`${callbackUrl}?state=test`);
182197

183198
await expect(
184199
manager.handleCallbackRequest(callbackRequest)

packages/agents/src/tests/mcp/oauth2-mcp-client.test.ts

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,4 +290,92 @@ describe("OAuth2 MCP Client", () => {
290290
expect(response.status).toBeGreaterThanOrEqual(400);
291291
});
292292
});
293+
294+
describe("OAuth Redirect Behavior", () => {
295+
async function setupOAuthTest(config: {
296+
successRedirect?: string;
297+
errorRedirect?: string;
298+
origin?: string;
299+
}) {
300+
const agentId = env.TestOAuthAgent.newUniqueId();
301+
const agentStub = env.TestOAuthAgent.get(agentId);
302+
await agentStub.setName("default");
303+
await agentStub.onStart();
304+
await agentStub.configureOAuthForTest(config);
305+
306+
const serverId = nanoid(8);
307+
const origin = config.origin || "http://example.com";
308+
const callbackBaseUrl = `${origin}/agents/oauth/${agentId.toString()}/callback`;
309+
310+
agentStub.sql`
311+
INSERT INTO cf_agents_mcp_servers (id, name, server_url, client_id, auth_url, callback_url, server_options)
312+
VALUES (${serverId}, ${"test"}, ${"http://example.com/mcp"}, ${"client"}, ${"http://example.com/auth"}, ${callbackBaseUrl}, ${null})
313+
`;
314+
315+
await agentStub.setupMockMcpConnection(
316+
serverId,
317+
"test",
318+
"http://example.com/mcp",
319+
callbackBaseUrl
320+
);
321+
await agentStub.setupMockOAuthState(serverId, "test-code", "test-state");
322+
323+
return { agentStub, serverId, callbackBaseUrl };
324+
}
325+
326+
it("should return 302 redirect with Location header on successful OAuth callback", async () => {
327+
const { agentStub, serverId, callbackBaseUrl } = await setupOAuthTest({
328+
successRedirect: "/dashboard"
329+
});
330+
331+
const response = await agentStub.fetch(
332+
new Request(
333+
`${callbackBaseUrl}/${serverId}?code=test-code&state=test-state`,
334+
{ method: "GET", redirect: "manual" }
335+
)
336+
);
337+
338+
expect(response.status).toBe(302);
339+
expect(response.headers.get("Location")).toBe(
340+
"http://example.com/dashboard"
341+
);
342+
});
343+
344+
it("should handle relative URLs in successRedirect", async () => {
345+
const { agentStub, serverId, callbackBaseUrl } = await setupOAuthTest({
346+
successRedirect: "/success",
347+
origin: "http://test.local"
348+
});
349+
350+
const response = await agentStub.fetch(
351+
new Request(
352+
`${callbackBaseUrl}/${serverId}?code=test-code&state=test-state`,
353+
{ method: "GET", redirect: "manual" }
354+
)
355+
);
356+
357+
expect(response.status).toBe(302);
358+
expect(response.headers.get("Location")).toBe(
359+
"http://test.local/success"
360+
);
361+
});
362+
363+
it("should redirect to errorRedirect with error parameter on OAuth failure", async () => {
364+
const { agentStub, serverId, callbackBaseUrl } = await setupOAuthTest({
365+
errorRedirect: "/error"
366+
});
367+
368+
const response = await agentStub.fetch(
369+
new Request(
370+
`${callbackBaseUrl}/${serverId}?error=access_denied&state=test-state`,
371+
{ method: "GET", redirect: "manual" }
372+
)
373+
);
374+
375+
expect(response.status).toBe(302);
376+
expect(response.headers.get("Location")).toMatch(
377+
/^http:\/\/example\.com\/error\?error=/
378+
);
379+
});
380+
});
293381
});

packages/agents/src/tests/worker.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,14 @@ export class TestOAuthAgent extends Agent<Env> {
191191
return new Response("Test OAuth Agent");
192192
}
193193

194+
// Allow tests to configure OAuth callback behavior
195+
configureOAuthForTest(config: {
196+
successRedirect?: string;
197+
errorRedirect?: string;
198+
}): void {
199+
this.mcp.configureOAuthCallback(config);
200+
}
201+
194202
async setupMockMcpConnection(
195203
serverId: string,
196204
_serverName: string,

0 commit comments

Comments
 (0)