History

Replace raw OAuth popup HTML with completion routes

Rhys Sullivan committed 9 months ago

Commit 008fe731 parent 876e6ef5 files · +268 −120Browse files

Unified · Split

5 files
  1. executor/apps/web/src/app/mcp/oauth/callback/route.ts +41 −38
  2. executor/apps/web/src/app/mcp/oauth/complete/page.tsx +79 −0
  3. executor/apps/web/src/app/mcp/oauth/result/route.ts +79 −0
  4. executor/apps/web/src/app/mcp/oauth/start/route.ts +32 −30
  5. executor/apps/web/src/lib/mcp-oauth-provider.ts +37 −52
executor/apps/web/src/app/mcp/oauth/callback/route.ts+41−38
@@ -4,26 +4,34 @@ import { auth } from "@modelcontextprotocol/sdk/client/auth.js"; · 3 unmodified lines
44 import {
55 buildPendingCookieName,
66 decodePendingCookieValue,
7+ encodePopupResultCookieValue,
8+ MCP_OAUTH_RESULT_COOKIE,
79 McpPopupOAuthProvider,
8− oauthPopupResultHtml,
10+ type McpOAuthPopupResult,
911 } from "@/lib/mcp-oauth-provider";
1012
11−function popupHtmlResponse(payload: {
12− ok: boolean;
13− sourceUrl?: string;
14− accessToken?: string;
15− refreshToken?: string;
16− scope?: string;
17− expiresIn?: number;
18− error?: string;
19−}): NextResponse {
20− return new NextResponse(oauthPopupResultHtml(payload), {
21− status: payload.ok ? 200 : 400,
22− headers: {
23− "content-type": "text/html; charset=utf-8",
24− "cache-control": "no-store",
25− },
13+function popupResultRedirect(
14+ request: NextRequest,
15+ pendingCookieName: string | null,
16+ payload: McpOAuthPopupResult,
17+): NextResponse {
18+ const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host");
19+ const proto = request.headers.get("x-forwarded-proto") ?? request.nextUrl.protocol.replace(":", "");
20+ const origin = host && proto ? `${proto}://${host}` : request.nextUrl.origin;
21+ const response = NextResponse.redirect(`${origin}/mcp/oauth/complete`);
22+ response.cookies.set({
23+ name: MCP_OAUTH_RESULT_COOKIE,
24+ value: encodePopupResultCookieValue(payload),
25+ httpOnly: true,
26+ secure: request.nextUrl.protocol === "https:",
27+ sameSite: "lax",
28+ maxAge: 2 * 60,
29+ path: "/",
2630 });
31+ if (pendingCookieName) {
32+ response.cookies.delete(pendingCookieName);
33+ }
34+ return response;
2735 }
2836
2937 export async function GET(request: NextRequest) {
@@ -32,7 +40,7 @@ export async function GET(request: NextRequest) { · 2 unmodified lines
3240 const error = request.nextUrl.searchParams.get("error")?.trim();
3341
3442 if (!state) {
35− return popupHtmlResponse({ ok: false, error: "Missing OAuth state" });
43+ return popupResultRedirect(request, null, { ok: false, error: "Missing OAuth state" });
3644 }
3745
3846 const cookieName = buildPendingCookieName(state);
@@ -40,30 +48,28 @@ export async function GET(request: NextRequest) { · 1 unmodified line
4048 const pending = rawPending ? decodePendingCookieValue(rawPending) : null;
4149
4250 if (!pending) {
43− const response = popupHtmlResponse({ ok: false, error: "OAuth session expired. Try connecting again." });
44− response.cookies.delete(cookieName);
45− return response;
51+ return popupResultRedirect(request, cookieName, {
52+ ok: false,
53+ error: "OAuth session expired. Try connecting again.",
54+ });
4655 }
4756
4857 if (error) {
49− const response = popupHtmlResponse({ ok: false, error: `OAuth error: ${error}` });
50− response.cookies.delete(cookieName);
51− return response;
58+ return popupResultRedirect(request, cookieName, { ok: false, error: `OAuth error: ${error}` });
5259 }
5360
5461 if (!code) {
55− const response = popupHtmlResponse({ ok: false, error: "Missing OAuth authorization code" });
56− response.cookies.delete(cookieName);
57− return response;
62+ return popupResultRedirect(request, cookieName, {
63+ ok: false,
64+ error: "Missing OAuth authorization code",
65+ });
5866 }
5967
6068 let sourceUrl: URL;
6169 try {
6270 sourceUrl = new URL(pending.sourceUrl);
6371 } catch {
64− const response = popupHtmlResponse({ ok: false, error: "Invalid MCP source URL" });
65− response.cookies.delete(cookieName);
66− return response;
72+ return popupResultRedirect(request, cookieName, { ok: false, error: "Invalid MCP source URL" });
6773 }
6874
6975 const provider = new McpPopupOAuthProvider({
@@ -79,23 +85,22 @@ export async function GET(request: NextRequest) { · 9 unmodified lines
7985 authorizationCode: code,
8086 });
8187 } catch (finishError) {
82− const response = popupHtmlResponse({
88+ return popupResultRedirect(request, cookieName, {
8389 ok: false,
8490 error: finishError instanceof Error ? finishError.message : "Failed to finish OAuth",
8591 });
86− response.cookies.delete(cookieName);
87− return response;
8892 }
8993
9094 const tokens = provider.getTokens();
9195 const accessToken = tokens?.access_token?.trim() ?? "";
9296 if (!accessToken) {
93− const response = popupHtmlResponse({ ok: false, error: "OAuth completed without an access token" });
94− response.cookies.delete(cookieName);
95− return response;
97+ return popupResultRedirect(request, cookieName, {
98+ ok: false,
99+ error: "OAuth completed without an access token",
100+ });
96101 }
97102
98− const response = popupHtmlResponse({
103+ return popupResultRedirect(request, cookieName, {
99104 ok: true,
100105 sourceUrl: pending.sourceUrl,
101106 accessToken,
@@ -103,6 +108,4 @@ export async function GET(request: NextRequest) { · 1 unmodified line
103108 scope: tokens?.scope,
104109 expiresIn: typeof tokens?.expires_in === "number" ? tokens.expires_in : undefined,
105110 });
106− response.cookies.delete(cookieName);
107− return response;
108111 }
executor/apps/web/src/app/mcp/oauth/complete/page.tsx+79−0
@@ -0,0 +1,79 @@
1+"use client";
2+
3+import { useEffect } from "react";
4+
5+type PopupMessage =
6+ | {
7+ type: "executor:mcp-oauth-result";
8+ ok: true;
9+ sourceUrl: string;
10+ payload: {
11+ accessToken: string;
12+ refreshToken?: string;
13+ scope?: string;
14+ expiresIn?: number;
15+ };
16+ }
17+ | {
18+ type: "executor:mcp-oauth-result";
19+ ok: false;
20+ error: string;
21+ };
22+
23+function fallbackErrorMessage(error: unknown): PopupMessage {
24+ return {
25+ type: "executor:mcp-oauth-result",
26+ ok: false,
27+ error: error instanceof Error ? error.message : "Failed to finalize OAuth",
28+ };
29+}
30+
31+export default function McpOAuthCompletePage() {
32+ useEffect(() => {
33+ let cancelled = false;
34+
35+ const finish = async () => {
36+ let message: PopupMessage;
37+ try {
38+ const response = await fetch("/mcp/oauth/result", {
39+ method: "GET",
40+ cache: "no-store",
41+ credentials: "same-origin",
42+ });
43+ const data = await response.json() as PopupMessage;
44+ if (!data || data.type !== "executor:mcp-oauth-result") {
45+ throw new Error("Invalid OAuth completion payload");
46+ }
47+ message = data;
48+ } catch (error) {
49+ message = fallbackErrorMessage(error);
50+ }
51+
52+ if (cancelled) {
53+ return;
54+ }
55+
56+ try {
57+ if (window.opener) {
58+ window.opener.postMessage(message, window.location.origin);
59+ }
60+ } finally {
61+ window.close();
62+ }
63+ };
64+
65+ void finish();
66+ return () => {
67+ cancelled = true;
68+ };
69+ }, []);
70+
71+ return (
72+ <main className="min-h-screen flex items-center justify-center bg-background p-6">
73+ <div className="text-center space-y-2">
74+ <h1 className="text-base font-medium">Finishing OAuth</h1>
75+ <p className="text-sm text-muted-foreground">You can close this window if it does not close automatically.</p>
76+ </div>
77+ </main>
78+ );
79+}
executor/apps/web/src/app/mcp/oauth/result/route.ts+79−0
@@ -0,0 +1,79 @@
1+import type { NextRequest } from "next/server";
2+import { NextResponse } from "next/server";
3+import {
4+ decodePopupResultCookieValue,
5+ MCP_OAUTH_RESULT_COOKIE,
6+ type McpOAuthPopupResult,
7+} from "@/lib/mcp-oauth-provider";
8+
9+type PopupMessage =
10+ | {
11+ type: "executor:mcp-oauth-result";
12+ ok: true;
13+ sourceUrl: string;
14+ payload: {
15+ accessToken: string;
16+ refreshToken?: string;
17+ scope?: string;
18+ expiresIn?: number;
19+ };
20+ }
21+ | {
22+ type: "executor:mcp-oauth-result";
23+ ok: false;
24+ error: string;
25+ };
26+
27+function toPopupMessage(result: McpOAuthPopupResult | null): PopupMessage {
28+ if (!result) {
29+ return {
30+ type: "executor:mcp-oauth-result",
31+ ok: false,
32+ error: "OAuth result is missing or expired",
33+ };
34+ }
35+
36+ if (!result.ok) {
37+ return {
38+ type: "executor:mcp-oauth-result",
39+ ok: false,
40+ error: result.error ?? "OAuth failed",
41+ };
42+ }
43+
44+ const accessToken = result.accessToken?.trim() ?? "";
45+ if (!accessToken) {
46+ return {
47+ type: "executor:mcp-oauth-result",
48+ ok: false,
49+ error: "OAuth finished without an access token",
50+ };
51+ }
52+
53+ return {
54+ type: "executor:mcp-oauth-result",
55+ ok: true,
56+ sourceUrl: result.sourceUrl ?? "",
57+ payload: {
58+ accessToken,
59+ ...(result.refreshToken ? { refreshToken: result.refreshToken } : {}),
60+ ...(result.scope ? { scope: result.scope } : {}),
61+ ...(typeof result.expiresIn === "number" ? { expiresIn: result.expiresIn } : {}),
62+ },
63+ };
64+}
65+
66+export async function GET(request: NextRequest) {
67+ const encoded = request.cookies.get(MCP_OAUTH_RESULT_COOKIE)?.value ?? "";
68+ const parsed = encoded ? decodePopupResultCookieValue(encoded) : null;
69+ const message = toPopupMessage(parsed);
70+
71+ const response = NextResponse.json(message, {
72+ status: message.ok ? 200 : 400,
73+ headers: {
74+ "cache-control": "no-store",
75+ },
76+ });
77+ response.cookies.delete(MCP_OAUTH_RESULT_COOKIE);
78+ return response;
79+}
executor/apps/web/src/app/mcp/oauth/start/route.ts+32−30
@@ -5,8 +5,10 @@ import { · 4 unmodified lines
55 buildPendingCookieName,
66 createOAuthState,
77 encodePendingCookieValue,
8+ encodePopupResultCookieValue,
9+ MCP_OAUTH_RESULT_COOKIE,
810 McpPopupOAuthProvider,
9− oauthPopupResultHtml,
11+ type McpOAuthPopupResult,
1012 } from "@/lib/mcp-oauth-provider";
1113
1214 function getExternalOrigin(request: NextRequest): string {
@@ -18,27 +20,36 @@ function getExternalOrigin(request: NextRequest): string { · 5 unmodified lines
1820 return request.nextUrl.origin;
1921 }
2022
21−function badPopupResponse(message: string): NextResponse {
22− return new NextResponse(oauthPopupResultHtml({ ok: false, error: message }), {
23− status: 400,
24− headers: {
25− "content-type": "text/html; charset=utf-8",
26− "cache-control": "no-store",
27− },
23+function popupResultRedirect(request: NextRequest, payload: McpOAuthPopupResult): NextResponse {
24+ const externalOrigin = getExternalOrigin(request);
25+ const response = NextResponse.redirect(`${externalOrigin}/mcp/oauth/complete`);
26+ response.cookies.set({
27+ name: MCP_OAUTH_RESULT_COOKIE,
28+ value: encodePopupResultCookieValue(payload),
29+ httpOnly: true,
30+ secure: request.nextUrl.protocol === "https:",
31+ sameSite: "lax",
32+ maxAge: 2 * 60,
33+ path: "/",
2834 });
35+ return response;
36+}
37+
38+function badPopupResponse(request: NextRequest, message: string): NextResponse {
39+ return popupResultRedirect(request, { ok: false, error: message });
2940 }
3041
3142 export async function GET(request: NextRequest) {
3243 const sourceUrlRaw = request.nextUrl.searchParams.get("sourceUrl")?.trim() ?? "";
3344 if (!sourceUrlRaw) {
34− return badPopupResponse("Missing sourceUrl");
45+ return badPopupResponse(request, "Missing sourceUrl");
3546 }
3647
3748 let sourceUrl: URL;
3849 try {
3950 sourceUrl = new URL(sourceUrlRaw);
4051 } catch {
41− return badPopupResponse("Invalid sourceUrl");
52+ return badPopupResponse(request, "Invalid sourceUrl");
4253 }
4354
4455 const state = createOAuthState();
@@ -53,37 +64,28 @@ export async function GET(request: NextRequest) { · 8 unmodified lines
5364 authResult = await auth(provider, { serverUrl: sourceUrl });
5465 } catch (error) {
5566 const message = error instanceof Error ? error.message : "Failed to start OAuth flow";
56− return badPopupResponse(message);
67+ return badPopupResponse(request, message);
5768 }
5869
5970 if (authResult === "AUTHORIZED") {
6071 const tokens = provider.getTokens();
6172 const accessToken = tokens?.access_token?.trim() ?? "";
6273 if (!accessToken) {
63− return badPopupResponse("OAuth flow completed without an access token");
74+ return badPopupResponse(request, "OAuth flow completed without an access token");
6475 }
65− return new NextResponse(
66− oauthPopupResultHtml({
67− ok: true,
68− sourceUrl: sourceUrl.toString(),
69− accessToken,
70− refreshToken: tokens?.refresh_token,
71− scope: tokens?.scope,
72− expiresIn: typeof tokens?.expires_in === "number" ? tokens.expires_in : undefined,
73− }),
74− {
75− status: 200,
76− headers: {
77− "content-type": "text/html; charset=utf-8",
78− "cache-control": "no-store",
79− },
80− },
81− );
76+ return popupResultRedirect(request, {
77+ ok: true,
78+ sourceUrl: sourceUrl.toString(),
79+ accessToken,
80+ refreshToken: tokens?.refresh_token,
81+ scope: tokens?.scope,
82+ expiresIn: typeof tokens?.expires_in === "number" ? tokens.expires_in : undefined,
83+ });
8284 }
8385
8486 const authorizationUrl = provider.getAuthorizationUrl();
8587 if (!authorizationUrl) {
86− return badPopupResponse("Server did not request an OAuth authorization step");
88+ return badPopupResponse(request, "Server did not request an OAuth authorization step");
8789 }
8890
8991 const pendingCookie = encodePendingCookieValue(provider.toPending(sourceUrl.toString()));
executor/apps/web/src/lib/mcp-oauth-provider.ts+37−52
@@ -14,7 +14,18 @@ export type McpOAuthPending = { · 13 unmodified lines
1414 clientInformation?: OAuthClientInformationMixed;
1515 };
1616
17+export type McpOAuthPopupResult = {
18+ ok: boolean;
19+ sourceUrl?: string;
20+ accessToken?: string;
21+ refreshToken?: string;
22+ scope?: string;
23+ expiresIn?: number;
24+ error?: string;
25+};
26+
1727 const COOKIE_PREFIX = "executor_mcp_oauth_";
28+export const MCP_OAUTH_RESULT_COOKIE = "executor_mcp_oauth_result";
1829
1930 export function buildPendingCookieName(state: string): string {
2031 return `${COOKIE_PREFIX}${state}`;
@@ -52,6 +63,32 @@ export function decodePendingCookieValue(raw: string): McpOAuthPending | null { · 31 unmodified lines
5263 }
5364 }
5465
66+export function encodePopupResultCookieValue(result: McpOAuthPopupResult): string {
67+ return Buffer.from(JSON.stringify(result), "utf8").toString("base64url");
68+}
69+
70+export function decodePopupResultCookieValue(raw: string): McpOAuthPopupResult | null {
71+ try {
72+ const decoded = Buffer.from(raw, "base64url").toString("utf8");
73+ const parsed = JSON.parse(decoded) as Partial<McpOAuthPopupResult>;
74+ if (typeof parsed.ok !== "boolean") {
75+ return null;
76+ }
77+
78+ return {
79+ ok: parsed.ok,
80+ ...(typeof parsed.sourceUrl === "string" ? { sourceUrl: parsed.sourceUrl } : {}),
81+ ...(typeof parsed.accessToken === "string" ? { accessToken: parsed.accessToken } : {}),
82+ ...(typeof parsed.refreshToken === "string" ? { refreshToken: parsed.refreshToken } : {}),
83+ ...(typeof parsed.scope === "string" ? { scope: parsed.scope } : {}),
84+ ...(typeof parsed.expiresIn === "number" ? { expiresIn: parsed.expiresIn } : {}),
85+ ...(typeof parsed.error === "string" ? { error: parsed.error } : {}),
86+ };
87+ } catch {
88+ return null;
89+ }
90+}
91+
5592 export class McpPopupOAuthProvider implements OAuthClientProvider {
5693 public clientMetadata: OAuthClientMetadata;
5794 private stateValue: string;
@@ -139,55 +176,3 @@ export class McpPopupOAuthProvider implements OAuthClientProvider { · 81 unmodified lines
139176 return this.tokenValue;
140177 }
141178 }
142−
143−export function oauthPopupResultHtml(payload: {
144− ok: boolean;
145− sourceUrl?: string;
146− accessToken?: string;
147− refreshToken?: string;
148− scope?: string;
149− expiresIn?: number;
150− error?: string;
151−}): string {
152− const message = payload.ok
153− ? {
154− type: "executor:mcp-oauth-result",
155− ok: true,
156− sourceUrl: payload.sourceUrl,
157− payload: {
158− accessToken: payload.accessToken,
159− ...(payload.refreshToken ? { refreshToken: payload.refreshToken } : {}),
160− ...(payload.scope ? { scope: payload.scope } : {}),
161− ...(typeof payload.expiresIn === "number" ? { expiresIn: payload.expiresIn } : {}),
162− },
163− }
164− : {
165− type: "executor:mcp-oauth-result",
166− ok: false,
167− error: payload.error ?? "OAuth failed",
168− };
169−
170− const serialized = JSON.stringify(message);
171− return `<!doctype html>
172−<html>
173− <head>
174− <meta charset="utf-8" />
175− <title>MCP OAuth</title>
176− </head>
177− <body>
178− <script>
179− (function () {
180− const msg = ${serialized};
181− try {
182− if (window.opener) {
183− window.opener.postMessage(msg, window.location.origin);
184− }
185− } finally {
186− window.close();
187− }
188− })();
189− </script>
190− <p>You can close this window.</p>
191− </body>
192−</html>`;
193−}

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t