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
4import {4import {
5 buildPendingCookieName,5 buildPendingCookieName,
6 decodePendingCookieValue,6 decodePendingCookieValue,
7 encodePopupResultCookieValue,
8 MCP_OAUTH_RESULT_COOKIE,
7 McpPopupOAuthProvider,9 McpPopupOAuthProvider,
8 oauthPopupResultHtml,10 type McpOAuthPopupResult,
9} from "@/lib/mcp-oauth-provider";11} from "@/lib/mcp-oauth-provider";
10 12
11function popupHtmlResponse(payload: {13function popupResultRedirect(
12 ok: boolean;14 request: NextRequest,
13 sourceUrl?: string;15 pendingCookieName: string | null,
14 accessToken?: string;16 payload: McpOAuthPopupResult,
15 refreshToken?: string;17): NextResponse {
16 scope?: string;18 const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host");
17 expiresIn?: number;19 const proto = request.headers.get("x-forwarded-proto") ?? request.nextUrl.protocol.replace(":", "");
18 error?: string;20 const origin = host && proto ? `${proto}://${host}` : request.nextUrl.origin;
19}): NextResponse {21 const response = NextResponse.redirect(`${origin}/mcp/oauth/complete`);
20 return new NextResponse(oauthPopupResultHtml(payload), {22 response.cookies.set({
21 status: payload.ok ? 200 : 400,23 name: MCP_OAUTH_RESULT_COOKIE,
22 headers: {24 value: encodePopupResultCookieValue(payload),
23 "content-type": "text/html; charset=utf-8",25 httpOnly: true,
24 "cache-control": "no-store",26 secure: request.nextUrl.protocol === "https:",
25 },27 sameSite: "lax",
28 maxAge: 2 * 60,
29 path: "/",
26 });30 });
31 if (pendingCookieName) {
32 response.cookies.delete(pendingCookieName);
33 }
34 return response;
27}35}
28 36
29export async function GET(request: NextRequest) {37export async function GET(request: NextRequest) {
@@ -32,7 +40,7 @@ export async function GET(request: NextRequest) { · 2 unmodified lines
32 const error = request.nextUrl.searchParams.get("error")?.trim();40 const error = request.nextUrl.searchParams.get("error")?.trim();
33 41
34 if (!state) {42 if (!state) {
35 return popupHtmlResponse({ ok: false, error: "Missing OAuth state" });43 return popupResultRedirect(request, null, { ok: false, error: "Missing OAuth state" });
36 }44 }
37 45
38 const cookieName = buildPendingCookieName(state);46 const cookieName = buildPendingCookieName(state);
@@ -40,30 +48,28 @@ export async function GET(request: NextRequest) { · 1 unmodified line
40 const pending = rawPending ? decodePendingCookieValue(rawPending) : null;48 const pending = rawPending ? decodePendingCookieValue(rawPending) : null;
41 49
42 if (!pending) {50 if (!pending) {
43 const response = popupHtmlResponse({ ok: false, error: "OAuth session expired. Try connecting again." });51 return popupResultRedirect(request, cookieName, {
44 response.cookies.delete(cookieName);52 ok: false,
45 return response;53 error: "OAuth session expired. Try connecting again.",
54 });
46 }55 }
47 56
48 if (error) {57 if (error) {
49 const response = popupHtmlResponse({ ok: false, error: `OAuth error: ${error}` });58 return popupResultRedirect(request, cookieName, { ok: false, error: `OAuth error: ${error}` });
50 response.cookies.delete(cookieName);
51 return response;
52 }59 }
53 60
54 if (!code) {61 if (!code) {
55 const response = popupHtmlResponse({ ok: false, error: "Missing OAuth authorization code" });62 return popupResultRedirect(request, cookieName, {
56 response.cookies.delete(cookieName);63 ok: false,
57 return response;64 error: "Missing OAuth authorization code",
65 });
58 }66 }
59 67
60 let sourceUrl: URL;68 let sourceUrl: URL;
61 try {69 try {
62 sourceUrl = new URL(pending.sourceUrl);70 sourceUrl = new URL(pending.sourceUrl);
63 } catch {71 } catch {
64 const response = popupHtmlResponse({ ok: false, error: "Invalid MCP source URL" });72 return popupResultRedirect(request, cookieName, { ok: false, error: "Invalid MCP source URL" });
65 response.cookies.delete(cookieName);
66 return response;
67 }73 }
68 74
69 const provider = new McpPopupOAuthProvider({75 const provider = new McpPopupOAuthProvider({
@@ -79,23 +85,22 @@ export async function GET(request: NextRequest) { · 9 unmodified lines
79 authorizationCode: code,85 authorizationCode: code,
80 });86 });
81 } catch (finishError) {87 } catch (finishError) {
82 const response = popupHtmlResponse({88 return popupResultRedirect(request, cookieName, {
83 ok: false,89 ok: false,
84 error: finishError instanceof Error ? finishError.message : "Failed to finish OAuth",90 error: finishError instanceof Error ? finishError.message : "Failed to finish OAuth",
85 });91 });
86 response.cookies.delete(cookieName);
87 return response;
88 }92 }
89 93
90 const tokens = provider.getTokens();94 const tokens = provider.getTokens();
91 const accessToken = tokens?.access_token?.trim() ?? "";95 const accessToken = tokens?.access_token?.trim() ?? "";
92 if (!accessToken) {96 if (!accessToken) {
93 const response = popupHtmlResponse({ ok: false, error: "OAuth completed without an access token" });97 return popupResultRedirect(request, cookieName, {
94 response.cookies.delete(cookieName);98 ok: false,
95 return response;99 error: "OAuth completed without an access token",
100 });
96 }101 }
97 102
98 const response = popupHtmlResponse({103 return popupResultRedirect(request, cookieName, {
99 ok: true,104 ok: true,
100 sourceUrl: pending.sourceUrl,105 sourceUrl: pending.sourceUrl,
101 accessToken,106 accessToken,
@@ -103,6 +108,4 @@ export async function GET(request: NextRequest) { · 1 unmodified line
103 scope: tokens?.scope,108 scope: tokens?.scope,
104 expiresIn: typeof tokens?.expires_in === "number" ? tokens.expires_in : undefined,109 expiresIn: typeof tokens?.expires_in === "number" ? tokens.expires_in : undefined,
105 });110 });
106 response.cookies.delete(cookieName);
107 return response;
108}111}
executor/apps/web/src/app/mcp/oauth/complete/page.tsx+79−0
@@ -0,0 +1,79 @@
1"use client";
2
3import { useEffect } from "react";
4
5type 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
23function 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
31export 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 @@
1import type { NextRequest } from "next/server";
2import { NextResponse } from "next/server";
3import {
4 decodePopupResultCookieValue,
5 MCP_OAUTH_RESULT_COOKIE,
6 type McpOAuthPopupResult,
7} from "@/lib/mcp-oauth-provider";
8
9type 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
27function 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
66export 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
5 buildPendingCookieName,5 buildPendingCookieName,
6 createOAuthState,6 createOAuthState,
7 encodePendingCookieValue,7 encodePendingCookieValue,
8 encodePopupResultCookieValue,
9 MCP_OAUTH_RESULT_COOKIE,
8 McpPopupOAuthProvider,10 McpPopupOAuthProvider,
9 oauthPopupResultHtml,11 type McpOAuthPopupResult,
10} from "@/lib/mcp-oauth-provider";12} from "@/lib/mcp-oauth-provider";
11 13
12function getExternalOrigin(request: NextRequest): string {14function getExternalOrigin(request: NextRequest): string {
@@ -18,27 +20,36 @@ function getExternalOrigin(request: NextRequest): string { · 5 unmodified lines
18 return request.nextUrl.origin;20 return request.nextUrl.origin;
19}21}
20 22
21function badPopupResponse(message: string): NextResponse {23function popupResultRedirect(request: NextRequest, payload: McpOAuthPopupResult): NextResponse {
22 return new NextResponse(oauthPopupResultHtml({ ok: false, error: message }), {24 const externalOrigin = getExternalOrigin(request);
23 status: 400,25 const response = NextResponse.redirect(`${externalOrigin}/mcp/oauth/complete`);
24 headers: {26 response.cookies.set({
25 "content-type": "text/html; charset=utf-8",27 name: MCP_OAUTH_RESULT_COOKIE,
26 "cache-control": "no-store",28 value: encodePopupResultCookieValue(payload),
27 },29 httpOnly: true,
30 secure: request.nextUrl.protocol === "https:",
31 sameSite: "lax",
32 maxAge: 2 * 60,
33 path: "/",
28 });34 });
35 return response;
36}
37
38function badPopupResponse(request: NextRequest, message: string): NextResponse {
39 return popupResultRedirect(request, { ok: false, error: message });
29}40}
30 41
31export async function GET(request: NextRequest) {42export async function GET(request: NextRequest) {
32 const sourceUrlRaw = request.nextUrl.searchParams.get("sourceUrl")?.trim() ?? "";43 const sourceUrlRaw = request.nextUrl.searchParams.get("sourceUrl")?.trim() ?? "";
33 if (!sourceUrlRaw) {44 if (!sourceUrlRaw) {
34 return badPopupResponse("Missing sourceUrl");45 return badPopupResponse(request, "Missing sourceUrl");
35 }46 }
36 47
37 let sourceUrl: URL;48 let sourceUrl: URL;
38 try {49 try {
39 sourceUrl = new URL(sourceUrlRaw);50 sourceUrl = new URL(sourceUrlRaw);
40 } catch {51 } catch {
41 return badPopupResponse("Invalid sourceUrl");52 return badPopupResponse(request, "Invalid sourceUrl");
42 }53 }
43 54
44 const state = createOAuthState();55 const state = createOAuthState();
@@ -53,37 +64,28 @@ export async function GET(request: NextRequest) { · 8 unmodified lines
53 authResult = await auth(provider, { serverUrl: sourceUrl });64 authResult = await auth(provider, { serverUrl: sourceUrl });
54 } catch (error) {65 } catch (error) {
55 const message = error instanceof Error ? error.message : "Failed to start OAuth flow";66 const message = error instanceof Error ? error.message : "Failed to start OAuth flow";
56 return badPopupResponse(message);67 return badPopupResponse(request, message);
57 }68 }
58 69
59 if (authResult === "AUTHORIZED") {70 if (authResult === "AUTHORIZED") {
60 const tokens = provider.getTokens();71 const tokens = provider.getTokens();
61 const accessToken = tokens?.access_token?.trim() ?? "";72 const accessToken = tokens?.access_token?.trim() ?? "";
62 if (!accessToken) {73 if (!accessToken) {
63 return badPopupResponse("OAuth flow completed without an access token");74 return badPopupResponse(request, "OAuth flow completed without an access token");
64 }75 }
65 return new NextResponse(76 return popupResultRedirect(request, {
66 oauthPopupResultHtml({77 ok: true,
67 ok: true,78 sourceUrl: sourceUrl.toString(),
68 sourceUrl: sourceUrl.toString(),79 accessToken,
69 accessToken,80 refreshToken: tokens?.refresh_token,
70 refreshToken: tokens?.refresh_token,81 scope: tokens?.scope,
71 scope: tokens?.scope,82 expiresIn: typeof tokens?.expires_in === "number" ? tokens.expires_in : undefined,
72 expiresIn: typeof tokens?.expires_in === "number" ? tokens.expires_in : undefined,83 });
73 }),
74 {
75 status: 200,
76 headers: {
77 "content-type": "text/html; charset=utf-8",
78 "cache-control": "no-store",
79 },
80 },
81 );
82 }84 }
83 85
84 const authorizationUrl = provider.getAuthorizationUrl();86 const authorizationUrl = provider.getAuthorizationUrl();
85 if (!authorizationUrl) {87 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");
87 }89 }
88 90
89 const pendingCookie = encodePendingCookieValue(provider.toPending(sourceUrl.toString()));91 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
14 clientInformation?: OAuthClientInformationMixed;14 clientInformation?: OAuthClientInformationMixed;
15};15};
16 16
17export 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
17const COOKIE_PREFIX = "executor_mcp_oauth_";27const COOKIE_PREFIX = "executor_mcp_oauth_";
28export const MCP_OAUTH_RESULT_COOKIE = "executor_mcp_oauth_result";
18 29
19export function buildPendingCookieName(state: string): string {30export function buildPendingCookieName(state: string): string {
20 return `${COOKIE_PREFIX}${state}`;31 return `${COOKIE_PREFIX}${state}`;
@@ -52,6 +63,32 @@ export function decodePendingCookieValue(raw: string): McpOAuthPending | null { · 31 unmodified lines
52 }63 }
53}64}
54 65
66export function encodePopupResultCookieValue(result: McpOAuthPopupResult): string {
67 return Buffer.from(JSON.stringify(result), "utf8").toString("base64url");
68}
69
70export 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
55export class McpPopupOAuthProvider implements OAuthClientProvider {92export class McpPopupOAuthProvider implements OAuthClientProvider {
56 public clientMetadata: OAuthClientMetadata;93 public clientMetadata: OAuthClientMetadata;
57 private stateValue: string;94 private stateValue: string;
@@ -139,55 +176,3 @@ export class McpPopupOAuthProvider implements OAuthClientProvider { · 81 unmodified lines
139 return this.tokenValue;176 return this.tokenValue;
140 }177 }
141}178}
142
143export 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