Unified · Split
| @@ -2,6 +2,10 @@ import { httpRouter } from "convex/server"; · 1 unmodified line | |||
| 2 | 2 | ||
| 3 | import { controlPlaneHttpHandler } from "./controlPlane"; | 3 | import { controlPlaneHttpHandler } from "./controlPlane"; |
| 4 | import { mcpHandler } from "./mcp"; | 4 | import { mcpHandler } from "./mcp"; |
| 5 | import { | ||
| 6 | oauthAuthorizationServerHandler, | ||
| 7 | oauthProtectedResourceHandler, | ||
| 8 | } from "./mcp_auth"; | ||
| 5 | import { handleToolCallHttp } from "./runtimeCallbacks"; | 9 | import { handleToolCallHttp } from "./runtimeCallbacks"; |
| 6 | 10 | ||
| 7 | const http = httpRouter(); | 11 | const http = httpRouter(); |
| @@ -9,6 +13,16 @@ const http = httpRouter(); · 1 unmodified line | |||
| 9 | http.route({ path: "/v1/mcp", method: "POST", handler: mcpHandler }); | 13 | http.route({ path: "/v1/mcp", method: "POST", handler: mcpHandler }); |
| 10 | http.route({ path: "/v1/mcp", method: "GET", handler: mcpHandler }); | 14 | http.route({ path: "/v1/mcp", method: "GET", handler: mcpHandler }); |
| 11 | http.route({ path: "/v1/mcp", method: "DELETE", handler: mcpHandler }); | 15 | http.route({ path: "/v1/mcp", method: "DELETE", handler: mcpHandler }); |
| 16 | http.route({ | ||
| 17 | path: "/.well-known/oauth-protected-resource", | ||
| 18 | method: "GET", | ||
| 19 | handler: oauthProtectedResourceHandler, | ||
| 20 | }); | ||
| 21 | http.route({ | ||
| 22 | path: "/.well-known/oauth-authorization-server", | ||
| 23 | method: "GET", | ||
| 24 | handler: oauthAuthorizationServerHandler, | ||
| 25 | }); | ||
| 12 | http.route({ | 26 | http.route({ |
| 13 | path: "/v1/runtime/tool-call", | 27 | path: "/v1/runtime/tool-call", |
| 14 | method: "POST", | 28 | method: "POST", |
| @@ -3,8 +3,13 @@ import { createExecutorRunClient } from "@executor-v2/sdk"; · 2 unmodified lines | |||
| 3 | import * as Effect from "effect/Effect"; | 3 | import * as Effect from "effect/Effect"; |
| 4 | 4 | ||
| 5 | import { internal } from "./_generated/api"; | 5 | import { internal } from "./_generated/api"; |
| 6 | import { httpAction } from "./_generated/server"; | 6 | import { httpAction, type ActionCtx } from "./_generated/server"; |
| 7 | import { executeRunImpl } from "./executor"; | 7 | import { executeRunImpl } from "./executor"; |
| 8 | import { | ||
| 9 | getMcpAuthConfig, | ||
| 10 | unauthorizedMcpResponse, | ||
| 11 | verifyMcpToken, | ||
| 12 | } from "./mcp_auth"; | ||
| 8 | import { createConvexSourceToolRegistry } from "./source_tool_registry"; | 13 | import { createConvexSourceToolRegistry } from "./source_tool_registry"; |
| 9 | 14 | ||
| 10 | const readConfiguredWorkspaceId = (value: string | undefined): string => { | 15 | const readConfiguredWorkspaceId = (value: string | undefined): string => { |
| @@ -12,10 +17,96 @@ const readConfiguredWorkspaceId = (value: string | undefined): string => { · 1 unmodified line | |||
| 12 | return normalized && normalized.length > 0 ? normalized : "ws_local"; | 17 | return normalized && normalized.length > 0 ? normalized : "ws_local"; |
| 13 | }; | 18 | }; |
| 14 | 19 | ||
| 15 | const workspaceId = readConfiguredWorkspaceId(process.env.CONVEX_WORKSPACE_ID); | 20 | const readWorkspaceIdFromRequest = (request: Request): string | null => { |
| 21 | const value = new URL(request.url).searchParams.get("workspaceId")?.trim(); | ||
| 22 | return value && value.length > 0 ? value : null; | ||
| 23 | }; | ||
| 24 | |||
| 25 | const readRequestedWorkspaceId = (request: Request, fallbackWorkspaceId: string): string => | ||
| 26 | readWorkspaceIdFromRequest(request) ?? fallbackWorkspaceId; | ||
| 27 | |||
| 28 | const hasWorkspaceAccess = async ( | ||
| 29 | ctx: ActionCtx, | ||
| 30 | workspaceId: string, | ||
| 31 | accountId: string, | ||
| 32 | ): Promise<boolean> => { | ||
| 33 | try { | ||
| 34 | const workspace = (await ctx.runQuery( | ||
| 35 | runtimeInternal.control_plane.actor.getWorkspaceForActor, | ||
| 36 | { workspaceId }, | ||
| 37 | )) as { | ||
| 38 | organizationId: string | null; | ||
| 39 | createdByAccountId: string | null; | ||
| 40 | } | null; | ||
| 41 | |||
| 42 | if (!workspace) { | ||
| 43 | return false; | ||
| 44 | } | ||
| 45 | |||
| 46 | if (workspace.createdByAccountId === accountId) { | ||
| 47 | return true; | ||
| 48 | } | ||
| 49 | |||
| 50 | if (!workspace.organizationId) { | ||
| 51 | return false; | ||
| 52 | } | ||
| 53 | |||
| 54 | const memberships = (await ctx.runQuery( | ||
| 55 | runtimeInternal.control_plane.actor.listOrganizationMembershipsForActor, | ||
| 56 | { accountId }, | ||
| 57 | )) as Array<{ | ||
| 58 | organizationId: string; | ||
| 59 | }>; | ||
| 60 | |||
| 61 | return memberships.some((membership) => membership.organizationId === workspace.organizationId); | ||
| 62 | } catch { | ||
| 63 | return false; | ||
| 64 | } | ||
| 65 | }; | ||
| 66 | |||
| 67 | const fallbackWorkspaceId = readConfiguredWorkspaceId(process.env.CONVEX_WORKSPACE_ID); | ||
| 16 | const runtimeInternal = internal as any; | 68 | const runtimeInternal = internal as any; |
| 17 | 69 | ||
| 18 | export const mcpHandler = httpAction(async (ctx, request) => { | 70 | export const mcpHandler = httpAction(async (ctx, request) => { |
| 71 | const mcpAuthConfig = getMcpAuthConfig(); | ||
| 72 | const requestWorkspaceId = readWorkspaceIdFromRequest(request); | ||
| 73 | const workspaceId = readRequestedWorkspaceId(request, fallbackWorkspaceId); | ||
| 74 | |||
| 75 | if (!mcpAuthConfig.enabled) { | ||
| 76 | if (mcpAuthConfig.required) { | ||
| 77 | return Response.json( | ||
| 78 | { | ||
| 79 | error: "MCP OAuth must be configured for cloud deployments", | ||
| 80 | }, | ||
| 81 | { status: 503 }, | ||
| 82 | ); | ||
| 83 | } | ||
| 84 | } else { | ||
| 85 | const auth = await verifyMcpToken(request, mcpAuthConfig); | ||
| 86 | if (!auth) { | ||
| 87 | return unauthorizedMcpResponse(request, "No valid bearer token provided."); | ||
| 88 | } | ||
| 89 | |||
| 90 | if (!requestWorkspaceId) { | ||
| 91 | return Response.json( | ||
| 92 | { | ||
| 93 | error: "workspaceId query parameter is required when MCP OAuth is enabled", | ||
| 94 | }, | ||
| 95 | { status: 400 }, | ||
| 96 | ); | ||
| 97 | } | ||
| 98 | |||
| 99 | const authorized = await hasWorkspaceAccess(ctx, requestWorkspaceId, auth.subject); | ||
| 100 | if (!authorized) { | ||
| 101 | return Response.json( | ||
| 102 | { | ||
| 103 | error: "Workspace authorization failed", | ||
| 104 | }, | ||
| 105 | { status: 403 }, | ||
| 106 | ); | ||
| 107 | } | ||
| 108 | } | ||
| 109 | |||
| 19 | const toolRegistry = createConvexSourceToolRegistry(ctx, workspaceId); | 110 | const toolRegistry = createConvexSourceToolRegistry(ctx, workspaceId); |
| 20 | 111 | ||
| 21 | const runClient = createExecutorRunClient(async (input) => { | 112 | const runClient = createExecutorRunClient(async (input) => { |
| @@ -0,0 +1,216 @@ | |||
| 1 | import { createRemoteJWKSet, jwtVerify } from "jose"; | ||
| 2 | |||
| 3 | import { httpAction } from "./_generated/server"; | ||
| 4 | |||
| 5 | export const MCP_PATH = "/v1/mcp"; | ||
| 6 | |||
| 7 | type McpAuthConfig = { | ||
| 8 | required: boolean; | ||
| 9 | enabled: boolean; | ||
| 10 | authorizationServer: string | null; | ||
| 11 | jwks: ReturnType<typeof createRemoteJWKSet> | null; | ||
| 12 | }; | ||
| 13 | |||
| 14 | type VerifiedMcpToken = { | ||
| 15 | provider: "workos"; | ||
| 16 | subject: string; | ||
| 17 | }; | ||
| 18 | |||
| 19 | const isTruthyEnvValue = (value: string | undefined): boolean => { | ||
| 20 | const normalized = value?.trim().toLowerCase(); | ||
| 21 | return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on"; | ||
| 22 | }; | ||
| 23 | |||
| 24 | const mcpAuthRequired = (): boolean => { | ||
| 25 | const deploymentMode = (process.env.EXECUTOR_DEPLOYMENT_MODE ?? "").trim().toLowerCase(); | ||
| 26 | if ( | ||
| 27 | deploymentMode === "cloud" | ||
| 28 | || deploymentMode === "hosted" | ||
| 29 | || deploymentMode === "production" | ||
| 30 | || deploymentMode === "prod" | ||
| 31 | ) { | ||
| 32 | return true; | ||
| 33 | } | ||
| 34 | |||
| 35 | if ( | ||
| 36 | deploymentMode === "self-hosted" | ||
| 37 | || deploymentMode === "self_hosted" | ||
| 38 | || deploymentMode === "selfhosted" | ||
| 39 | ) { | ||
| 40 | return false; | ||
| 41 | } | ||
| 42 | |||
| 43 | return isTruthyEnvValue(process.env.EXECUTOR_ENFORCE_MCP_AUTH); | ||
| 44 | }; | ||
| 45 | |||
| 46 | const getMcpAuthorizationServer = (): string | null => | ||
| 47 | process.env.MCP_AUTHORIZATION_SERVER | ||
| 48 | ?? process.env.MCP_AUTHORIZATION_SERVER_URL | ||
| 49 | ?? process.env.WORKOS_AUTHKIT_ISSUER | ||
| 50 | ?? process.env.WORKOS_AUTHKIT_DOMAIN | ||
| 51 | ?? null; | ||
| 52 | |||
| 53 | export const getMcpAuthConfig = (): McpAuthConfig => { | ||
| 54 | const required = mcpAuthRequired(); | ||
| 55 | const authorizationServer = getMcpAuthorizationServer(); | ||
| 56 | |||
| 57 | if (!authorizationServer) { | ||
| 58 | return { | ||
| 59 | required, | ||
| 60 | enabled: false, | ||
| 61 | authorizationServer: null, | ||
| 62 | jwks: null, | ||
| 63 | }; | ||
| 64 | } | ||
| 65 | |||
| 66 | return { | ||
| 67 | required, | ||
| 68 | enabled: true, | ||
| 69 | authorizationServer, | ||
| 70 | jwks: createRemoteJWKSet(new URL("/oauth2/jwks", authorizationServer)), | ||
| 71 | }; | ||
| 72 | }; | ||
| 73 | |||
| 74 | const parseBearerToken = (request: Request): string | null => { | ||
| 75 | const header = request.headers.get("authorization"); | ||
| 76 | if (!header || !header.startsWith("Bearer ")) { | ||
| 77 | return null; | ||
| 78 | } | ||
| 79 | |||
| 80 | const token = header.slice("Bearer ".length).trim(); | ||
| 81 | return token.length > 0 ? token : null; | ||
| 82 | }; | ||
| 83 | |||
| 84 | const resourceMetadataUrl = (request: Request): string => { | ||
| 85 | const url = new URL(request.url); | ||
| 86 | const metadata = new URL("/.well-known/oauth-protected-resource", url.origin); | ||
| 87 | metadata.search = url.search; | ||
| 88 | |||
| 89 | const resource = new URL(url.pathname, url.origin); | ||
| 90 | resource.search = url.search; | ||
| 91 | metadata.searchParams.set("resource", resource.toString()); | ||
| 92 | |||
| 93 | return metadata.toString(); | ||
| 94 | }; | ||
| 95 | |||
| 96 | export const unauthorizedMcpResponse = (request: Request, message: string): Response => { | ||
| 97 | const challenge = [ | ||
| 98 | 'Bearer error="unauthorized"', | ||
| 99 | 'error_description="Authorization needed"', | ||
| 100 | `resource_metadata="${resourceMetadataUrl(request)}"`, | ||
| 101 | ].join(", "); | ||
| 102 | |||
| 103 | return Response.json( | ||
| 104 | { error: message }, | ||
| 105 | { | ||
| 106 | status: 401, | ||
| 107 | headers: { | ||
| 108 | "WWW-Authenticate": challenge, | ||
| 109 | }, | ||
| 110 | }, | ||
| 111 | ); | ||
| 112 | }; | ||
| 113 | |||
| 114 | export const verifyMcpToken = async ( | ||
| 115 | request: Request, | ||
| 116 | config: McpAuthConfig, | ||
| 117 | ): Promise<VerifiedMcpToken | null> => { | ||
| 118 | if (!config.enabled || !config.jwks || !config.authorizationServer) { | ||
| 119 | return null; | ||
| 120 | } | ||
| 121 | |||
| 122 | const token = parseBearerToken(request); | ||
| 123 | if (!token) { | ||
| 124 | return null; | ||
| 125 | } | ||
| 126 | |||
| 127 | try { | ||
| 128 | const { payload } = await jwtVerify(token, config.jwks, { | ||
| 129 | issuer: config.authorizationServer, | ||
| 130 | }); | ||
| 131 | |||
| 132 | if (typeof payload.sub !== "string" || payload.sub.length === 0) { | ||
| 133 | return null; | ||
| 134 | } | ||
| 135 | |||
| 136 | const providerClaim = typeof payload.provider === "string" ? payload.provider : undefined; | ||
| 137 | if (providerClaim === "anonymous") { | ||
| 138 | return null; | ||
| 139 | } | ||
| 140 | |||
| 141 | return { | ||
| 142 | provider: "workos", | ||
| 143 | subject: payload.sub, | ||
| 144 | }; | ||
| 145 | } catch (error) { | ||
| 146 | console.error("MCP token verification failed:", error instanceof Error ? error.message : String(error)); | ||
| 147 | return null; | ||
| 148 | } | ||
| 149 | }; | ||
| 150 | |||
| 151 | const errorResponseForMissingConfig = (config: McpAuthConfig): Response => | ||
| 152 | Response.json( | ||
| 153 | { | ||
| 154 | error: config.required | ||
| 155 | ? "MCP OAuth must be configured for cloud deployments" | ||
| 156 | : "MCP OAuth is not configured", | ||
| 157 | }, | ||
| 158 | { status: config.required ? 503 : 404 }, | ||
| 159 | ); | ||
| 160 | |||
| 161 | export const oauthProtectedResourceHandler = httpAction(async (_ctx, request) => { | ||
| 162 | const config = getMcpAuthConfig(); | ||
| 163 | if (!config.enabled || !config.authorizationServer) { | ||
| 164 | return errorResponseForMissingConfig(config); | ||
| 165 | } | ||
| 166 | |||
| 167 | const url = new URL(request.url); | ||
| 168 | let resource = new URL(MCP_PATH, url.origin); | ||
| 169 | resource.search = url.search; | ||
| 170 | |||
| 171 | const resourceHint = url.searchParams.get("resource"); | ||
| 172 | if (resourceHint) { | ||
| 173 | try { | ||
| 174 | const parsed = new URL(resourceHint); | ||
| 175 | if (parsed.origin !== url.origin) { | ||
| 176 | return Response.json({ error: "resource hint origin must match this server" }, { status: 400 }); | ||
| 177 | } | ||
| 178 | |||
| 179 | if (parsed.pathname === MCP_PATH) { | ||
| 180 | resource = new URL(MCP_PATH, url.origin); | ||
| 181 | resource.search = parsed.search; | ||
| 182 | } | ||
| 183 | } catch { | ||
| 184 | return Response.json({ error: "Invalid resource hint" }, { status: 400 }); | ||
| 185 | } | ||
| 186 | } | ||
| 187 | |||
| 188 | return Response.json({ | ||
| 189 | resource: resource.toString(), | ||
| 190 | authorization_servers: [config.authorizationServer], | ||
| 191 | bearer_methods_supported: ["header"], | ||
| 192 | }); | ||
| 193 | }); | ||
| 194 | |||
| 195 | export const oauthAuthorizationServerHandler = httpAction(async (_ctx, _request) => { | ||
| 196 | const config = getMcpAuthConfig(); | ||
| 197 | if (!config.enabled || !config.authorizationServer) { | ||
| 198 | return errorResponseForMissingConfig(config); | ||
| 199 | } | ||
| 200 | |||
| 201 | const upstream = new URL("/.well-known/oauth-authorization-server", config.authorizationServer); | ||
| 202 | const response = await fetch(upstream.toString(), { | ||
| 203 | headers: { | ||
| 204 | accept: "application/json", | ||
| 205 | }, | ||
| 206 | }); | ||
| 207 | |||
| 208 | const body = await response.text(); | ||
| 209 | |||
| 210 | return new Response(body, { | ||
| 211 | status: response.status, | ||
| 212 | headers: { | ||
| 213 | "content-type": response.headers.get("content-type") ?? "application/json", | ||
| 214 | }, | ||
| 215 | }); | ||
| 216 | }); | ||
| @@ -266,6 +266,139 @@ const safeJsonParse = (value: string): unknown => { · 265 unmodified lines | |||
| 266 | } | 266 | } |
| 267 | }; | 267 | }; |
| 268 | 268 | ||
| 269 | const normalizeHeaderString = (value: unknown): string | null => { | ||
| 270 | if (typeof value !== "string") { | ||
| 271 | return null; | ||
| 272 | } | ||
| 273 | |||
| 274 | const trimmed = value.trim(); | ||
| 275 | return trimmed.length > 0 ? trimmed : null; | ||
| 276 | }; | ||
| 277 | |||
| 278 | const parseSourceConfig = (source: Source): Record<string, unknown> => | ||
| 279 | jsonObjectFromUnknown(safeJsonParse(source.configJson)); | ||
| 280 | |||
| 281 | const collectConfiguredHeadersFromSourceConfig = ( | ||
| 282 | config: Record<string, unknown>, | ||
| 283 | ): Record<string, string> => { | ||
| 284 | const headers = jsonObjectFromUnknown(config.headers); | ||
| 285 | const nextHeaders: Record<string, string> = {}; | ||
| 286 | |||
| 287 | for (const [rawKey, rawValue] of Object.entries(headers)) { | ||
| 288 | const key = normalizeHeaderString(rawKey); | ||
| 289 | const value = normalizeHeaderString(rawValue); | ||
| 290 | if (key && value) { | ||
| 291 | nextHeaders[key] = value; | ||
| 292 | } | ||
| 293 | } | ||
| 294 | |||
| 295 | return nextHeaders; | ||
| 296 | }; | ||
| 297 | |||
| 298 | const collectAuthHeadersFromSourceConfig = ( | ||
| 299 | config: Record<string, unknown>, | ||
| 300 | ): Record<string, string> => { | ||
| 301 | const auth = jsonObjectFromUnknown(config.auth); | ||
| 302 | const authType = normalizeHeaderString(auth.type)?.toLowerCase(); | ||
| 303 | const nextHeaders: Record<string, string> = {}; | ||
| 304 | |||
| 305 | if (authType === "bearer") { | ||
| 306 | const token = normalizeHeaderString(auth.token) ?? normalizeHeaderString(auth.value); | ||
| 307 | if (token) { | ||
| 308 | nextHeaders.Authorization = `Bearer ${token}`; | ||
| 309 | } | ||
| 310 | } | ||
| 311 | |||
| 312 | if (authType === "apikey" || authType === "api_key") { | ||
| 313 | const token = normalizeHeaderString(auth.value) ?? normalizeHeaderString(auth.token); | ||
| 314 | if (token) { | ||
| 315 | const headerName = normalizeHeaderString(auth.header) ?? "Authorization"; | ||
| 316 | nextHeaders[headerName] = token; | ||
| 317 | } | ||
| 318 | } | ||
| 319 | |||
| 320 | if (authType === "basic") { | ||
| 321 | const username = normalizeHeaderString(auth.username); | ||
| 322 | const password = normalizeHeaderString(auth.password); | ||
| 323 | if (username && password && typeof btoa === "function") { | ||
| 324 | nextHeaders.Authorization = `Basic ${btoa(`${username}:${password}`)}`; | ||
| 325 | } | ||
| 326 | } | ||
| 327 | |||
| 328 | return nextHeaders; | ||
| 329 | }; | ||
| 330 | |||
| 331 | const mergeHeaders = (...sets: ReadonlyArray<Record<string, string>>): Record<string, string> => { | ||
| 332 | const merged: Record<string, string> = {}; | ||
| 333 | const keyByLower = new Map<string, string>(); | ||
| 334 | |||
| 335 | for (const headers of sets) { | ||
| 336 | for (const [rawKey, rawValue] of Object.entries(headers)) { | ||
| 337 | const key = rawKey.trim(); | ||
| 338 | const value = rawValue.trim(); | ||
| 339 | if (key.length === 0 || value.length === 0) { | ||
| 340 | continue; | ||
| 341 | } | ||
| 342 | |||
| 343 | const normalizedKey = key.toLowerCase(); | ||
| 344 | const existingKey = keyByLower.get(normalizedKey); | ||
| 345 | if (existingKey && existingKey !== key) { | ||
| 346 | delete merged[existingKey]; | ||
| 347 | } | ||
| 348 | |||
| 349 | keyByLower.set(normalizedKey, key); | ||
| 350 | merged[key] = value; | ||
| 351 | } | ||
| 352 | } | ||
| 353 | |||
| 354 | return merged; | ||
| 355 | }; | ||
| 356 | |||
| 357 | const resolveSourceCredentialHeadersForInvocation = async ( | ||
| 358 | ctx: ActionCtx, | ||
| 359 | source: Source, | ||
| 360 | ): Promise<Record<string, string>> => { | ||
| 361 | try { | ||
| 362 | const resolved = (await ctx.runQuery( | ||
| 363 | runtimeInternal.control_plane.credentials.resolveSourceCredentialHeadersForIngest, | ||
| 364 | { | ||
| 365 | workspaceId: source.workspaceId, | ||
| 366 | sourceId: source.id, | ||
| 367 | sourceName: source.name, | ||
| 368 | sourceEndpoint: source.endpoint, | ||
| 369 | }, | ||
| 370 | )) as { headers?: unknown }; | ||
| 371 | |||
| 372 | const headers = jsonObjectFromUnknown(resolved.headers); | ||
| 373 | const normalized: Record<string, string> = {}; | ||
| 374 | |||
| 375 | for (const [rawKey, rawValue] of Object.entries(headers)) { | ||
| 376 | const key = normalizeHeaderString(rawKey); | ||
| 377 | const value = normalizeHeaderString(rawValue); | ||
| 378 | if (key && value) { | ||
| 379 | normalized[key] = value; | ||
| 380 | } | ||
| 381 | } | ||
| 382 | |||
| 383 | return normalized; | ||
| 384 | } catch { | ||
| 385 | return {}; | ||
| 386 | } | ||
| 387 | }; | ||
| 388 | |||
| 389 | const resolveSourceHeadersForInvocation = async ( | ||
| 390 | ctx: ActionCtx, | ||
| 391 | source: Source, | ||
| 392 | ): Promise<Record<string, string>> => { | ||
| 393 | const config = parseSourceConfig(source); | ||
| 394 | |||
| 395 | return mergeHeaders( | ||
| 396 | collectConfiguredHeadersFromSourceConfig(config), | ||
| 397 | collectAuthHeadersFromSourceConfig(config), | ||
| 398 | await resolveSourceCredentialHeadersForInvocation(ctx, source), | ||
| 399 | ); | ||
| 400 | }; | ||
| 401 | |||
| 269 | const headersToRecord = (headers: Headers): Record<string, string> => { | 402 | const headersToRecord = (headers: Headers): Record<string, string> => { |
| 270 | const record: Record<string, string> = {}; | 403 | const record: Record<string, string> = {}; |
| 271 | headers.forEach((value, key) => { | 404 | headers.forEach((value, key) => { |
| @@ -829,11 +962,16 @@ const createConvexGraphqlToolProvider = (ctx: ActionCtx): ToolProvider => ({ · 557 unmodified lines | |||
| 829 | operationName = payload.fieldName; | 962 | operationName = payload.fieldName; |
| 830 | } | 963 | } |
| 831 | 964 | ||
| 965 | const sourceHeaders = await resolveSourceHeadersForInvocation(ctx, input.source); | ||
| 966 | |||
| 832 | const response = await fetch(payload.endpoint, { | 967 | const response = await fetch(payload.endpoint, { |
| 833 | method: "POST", | 968 | method: "POST", |
| 834 | headers: { | 969 | headers: mergeHeaders( |
| 835 | "content-type": "application/json", | 970 | { |
| 836 | }, | 971 | "content-type": "application/json", |
| 972 | }, | ||
| 973 | sourceHeaders, | ||
| 974 | ), | ||
| 837 | body: JSON.stringify({ | 975 | body: JSON.stringify({ |
| 838 | query, | 976 | query, |
| 839 | variables, | 977 | variables, |
| @@ -878,12 +1016,17 @@ const postMcpJsonRpc = async ( · 38 unmodified lines | |||
| 878 | endpoint: URL, | 1016 | endpoint: URL, |
| 879 | body: unknown, | 1017 | body: unknown, |
| 880 | sessionId: string | null, | 1018 | sessionId: string | null, |
| 1019 | sourceHeaders: Record<string, string>, | ||
| 881 | ): Promise<Response> => { | 1020 | ): Promise<Response> => { |
| 882 | const headers = new Headers({ | 1021 | const headers = new Headers({ |
| 883 | "content-type": "application/json", | 1022 | "content-type": "application/json", |
| 884 | accept: "application/json, text/event-stream", | 1023 | accept: "application/json, text/event-stream", |
| 885 | }); | 1024 | }); |
| 886 | 1025 | ||
| 1026 | for (const [key, value] of Object.entries(sourceHeaders)) { | ||
| 1027 | headers.set(key, value); | ||
| 1028 | } | ||
| 1029 | |||
| 887 | if (sessionId && sessionId.trim().length > 0) { | 1030 | if (sessionId && sessionId.trim().length > 0) { |
| 888 | headers.set("mcp-session-id", sessionId); | 1031 | headers.set("mcp-session-id", sessionId); |
| 889 | } | 1032 | } |
| @@ -934,8 +1077,13 @@ const createConvexMcpToolProvider = (ctx: ActionCtx): ToolProvider => ({ · 44 unmodified lines | |||
| 934 | invoke: (input) => | 1077 | invoke: (input) => |
| 935 | Effect.tryPromise({ | 1078 | Effect.tryPromise({ |
| 936 | try: async () => { | 1079 | try: async () => { |
| 1080 | if (!input.source) { | ||
| 1081 | throw new Error("MCP provider requires a source"); | ||
| 1082 | } | ||
| 1083 | |||
| 937 | const payload = decodeMcpInvocationPayload(input.tool.providerPayload); | 1084 | const payload = decodeMcpInvocationPayload(input.tool.providerPayload); |
| 938 | const args = normalizeGraphqlInvokeInput(input.args); | 1085 | const args = normalizeGraphqlInvokeInput(input.args); |
| 1086 | const sourceHeaders = await resolveSourceHeadersForInvocation(ctx, input.source); | ||
| 939 | 1087 | ||
| 940 | if (payload.transport !== "streamable-http") { | 1088 | if (payload.transport !== "streamable-http") { |
| 941 | throw new Error( | 1089 | throw new Error( |
| @@ -960,6 +1108,7 @@ const createConvexMcpToolProvider = (ctx: ActionCtx): ToolProvider => ({ · 18 unmodified lines | |||
| 960 | }, | 1108 | }, |
| 961 | }, | 1109 | }, |
| 962 | null, | 1110 | null, |
| 1111 | sourceHeaders, | ||
| 963 | ); | 1112 | ); |
| 964 | const initializeBody = await decodeMcpJsonResponse(initializeResponse); | 1113 | const initializeBody = await decodeMcpJsonResponse(initializeResponse); |
| 965 | 1114 | ||
| @@ -984,6 +1133,7 @@ const createConvexMcpToolProvider = (ctx: ActionCtx): ToolProvider => ({ · 18 unmodified lines | |||
| 984 | params: {}, | 1133 | params: {}, |
| 985 | }, | 1134 | }, |
| 986 | sessionId, | 1135 | sessionId, |
| 1136 | sourceHeaders, | ||
| 987 | ); | 1137 | ); |
| 988 | 1138 | ||
| 989 | const callResponse = await postMcpJsonRpc( | 1139 | const callResponse = await postMcpJsonRpc( |
| @@ -998,6 +1148,7 @@ const createConvexMcpToolProvider = (ctx: ActionCtx): ToolProvider => ({ · 8 unmodified lines | |||
| 998 | }, | 1148 | }, |
| 999 | }, | 1149 | }, |
| 1000 | sessionId, | 1150 | sessionId, |
| 1151 | sourceHeaders, | ||
| 1001 | ); | 1152 | ); |
| 1002 | const callBody = await decodeMcpJsonResponse(callResponse); | 1153 | const callBody = await decodeMcpJsonResponse(callResponse); |
| 1003 | 1154 | ||
| @@ -0,0 +1,169 @@ | |||
| 1 | import { auth } from "@modelcontextprotocol/sdk/client/auth.js"; | ||
| 2 | import type { NextRequest } from "next/server"; | ||
| 3 | import { NextResponse } from "next/server"; | ||
| 4 | |||
| 5 | import { fetchMcpOAuth } from "../../../../lib/mcp/oauth-fetch"; | ||
| 6 | import { getExternalOrigin, isExternalHttps } from "../../../../lib/mcp/oauth-request"; | ||
| 7 | import { parseMcpSourceUrl } from "../../../../lib/mcp/oauth-url"; | ||
| 8 | import { | ||
| 9 | buildPendingCookieName, | ||
| 10 | decodePendingCookieValue, | ||
| 11 | encodePopupResultCookieValue, | ||
| 12 | MCP_OAUTH_RESULT_COOKIE, | ||
| 13 | McpPopupOAuthProvider, | ||
| 14 | type McpOAuthPopupResult, | ||
| 15 | } from "../../../../lib/mcp/oauth-provider"; | ||
| 16 | |||
| 17 | const MCP_OAUTH_CALLBACK_FLOW_TIMEOUT_MS = 75_000; | ||
| 18 | const MCP_OAUTH_CALLBACK_REQUEST_TIMEOUT_MS = 20_000; | ||
| 19 | |||
| 20 | const withTimeout = async <T>( | ||
| 21 | factory: () => Promise<T>, | ||
| 22 | timeoutMs: number, | ||
| 23 | label: string, | ||
| 24 | ): Promise<T> => | ||
| 25 | await new Promise<T>((resolve, reject) => { | ||
| 26 | const timeoutId = setTimeout(() => { | ||
| 27 | reject(new Error(`${label} timed out after ${timeoutMs}ms`)); | ||
| 28 | }, timeoutMs); | ||
| 29 | |||
| 30 | factory().then( | ||
| 31 | (value) => { | ||
| 32 | clearTimeout(timeoutId); | ||
| 33 | resolve(value); | ||
| 34 | }, | ||
| 35 | (error) => { | ||
| 36 | clearTimeout(timeoutId); | ||
| 37 | reject(error); | ||
| 38 | }, | ||
| 39 | ); | ||
| 40 | }); | ||
| 41 | |||
| 42 | const popupResultRedirect = ( | ||
| 43 | request: NextRequest, | ||
| 44 | pendingCookieName: string | null, | ||
| 45 | payload: McpOAuthPopupResult, | ||
| 46 | ): Response => { | ||
| 47 | const origin = getExternalOrigin(request); | ||
| 48 | const response = NextResponse.redirect(new URL("/mcp/oauth/complete", origin)); | ||
| 49 | |||
| 50 | response.cookies.set({ | ||
| 51 | name: MCP_OAUTH_RESULT_COOKIE, | ||
| 52 | value: encodePopupResultCookieValue(payload), | ||
| 53 | httpOnly: true, | ||
| 54 | secure: isExternalHttps(request), | ||
| 55 | sameSite: "lax", | ||
| 56 | maxAge: 2 * 60, | ||
| 57 | path: "/", | ||
| 58 | }); | ||
| 59 | |||
| 60 | if (pendingCookieName) { | ||
| 61 | response.cookies.set({ | ||
| 62 | name: pendingCookieName, | ||
| 63 | value: "", | ||
| 64 | expires: new Date(0), | ||
| 65 | path: "/", | ||
| 66 | }); | ||
| 67 | } | ||
| 68 | |||
| 69 | return response; | ||
| 70 | }; | ||
| 71 | |||
| 72 | const toErrorMessage = (error: unknown, fallback: string): string => | ||
| 73 | error instanceof Error ? error.message : fallback; | ||
| 74 | |||
| 75 | export async function GET(request: NextRequest): Promise<Response> { | ||
| 76 | const code = request.nextUrl.searchParams.get("code")?.trim() ?? ""; | ||
| 77 | const state = request.nextUrl.searchParams.get("state")?.trim() ?? ""; | ||
| 78 | const oauthError = request.nextUrl.searchParams.get("error")?.trim() ?? ""; | ||
| 79 | |||
| 80 | if (!state) { | ||
| 81 | return popupResultRedirect(request, null, { | ||
| 82 | ok: false, | ||
| 83 | error: "Missing OAuth state", | ||
| 84 | }); | ||
| 85 | } | ||
| 86 | |||
| 87 | const pendingCookieName = buildPendingCookieName(state); | ||
| 88 | const rawPending = request.cookies.get(pendingCookieName)?.value ?? ""; | ||
| 89 | const pending = rawPending ? decodePendingCookieValue(rawPending) : null; | ||
| 90 | |||
| 91 | if (!pending) { | ||
| 92 | return popupResultRedirect(request, pendingCookieName, { | ||
| 93 | ok: false, | ||
| 94 | error: "OAuth session expired. Try connecting again.", | ||
| 95 | }); | ||
| 96 | } | ||
| 97 | |||
| 98 | if (oauthError.length > 0) { | ||
| 99 | return popupResultRedirect(request, pendingCookieName, { | ||
| 100 | ok: false, | ||
| 101 | error: `OAuth error: ${oauthError}`, | ||
| 102 | }); | ||
| 103 | } | ||
| 104 | |||
| 105 | if (code.length === 0) { | ||
| 106 | return popupResultRedirect(request, pendingCookieName, { | ||
| 107 | ok: false, | ||
| 108 | error: "Missing OAuth authorization code", | ||
| 109 | }); | ||
| 110 | } | ||
| 111 | |||
| 112 | let sourceUrl: URL; | ||
| 113 | try { | ||
| 114 | sourceUrl = parseMcpSourceUrl(pending.sourceUrl); | ||
| 115 | } catch { | ||
| 116 | return popupResultRedirect(request, pendingCookieName, { | ||
| 117 | ok: false, | ||
| 118 | error: "Invalid MCP source URL", | ||
| 119 | }); | ||
| 120 | } | ||
| 121 | |||
| 122 | const provider = new McpPopupOAuthProvider({ | ||
| 123 | redirectUrl: pending.redirectUrl, | ||
| 124 | state: pending.state, | ||
| 125 | codeVerifier: pending.codeVerifier, | ||
| 126 | clientInformation: pending.clientInformation, | ||
| 127 | }); | ||
| 128 | |||
| 129 | try { | ||
| 130 | await withTimeout( | ||
| 131 | () => | ||
| 132 | auth(provider, { | ||
| 133 | serverUrl: sourceUrl, | ||
| 134 | authorizationCode: code, | ||
| 135 | fetchFn: (input, init) => | ||
| 136 | fetchMcpOAuth(input, init ?? {}, { | ||
| 137 | timeoutMs: MCP_OAUTH_CALLBACK_REQUEST_TIMEOUT_MS, | ||
| 138 | label: "OAuth callback request", | ||
| 139 | }), | ||
| 140 | }), | ||
| 141 | MCP_OAUTH_CALLBACK_FLOW_TIMEOUT_MS, | ||
| 142 | "OAuth callback", | ||
| 143 | ); | ||
| 144 | } catch (error) { | ||
| 145 | return popupResultRedirect(request, pendingCookieName, { | ||
| 146 | ok: false, | ||
| 147 | error: toErrorMessage(error, "Failed to finish OAuth"), | ||
| 148 | }); | ||
| 149 | } | ||
| 150 | |||
| 151 | const tokens = provider.getTokens(); | ||
| 152 | const accessToken = tokens?.access_token?.trim() ?? ""; | ||
| 153 | |||
| 154 | if (accessToken.length === 0) { | ||
| 155 | return popupResultRedirect(request, pendingCookieName, { | ||
| 156 | ok: false, | ||
| 157 | error: "OAuth completed without an access token", | ||
| 158 | }); | ||
| 159 | } | ||
| 160 | |||
| 161 | return popupResultRedirect(request, pendingCookieName, { | ||
| 162 | ok: true, | ||
| 163 | sourceUrl: pending.sourceUrl, | ||
| 164 | accessToken, | ||
| 165 | refreshToken: tokens?.refresh_token, | ||
| 166 | scope: tokens?.scope, | ||
| 167 | expiresIn: typeof tokens?.expires_in === "number" ? tokens.expires_in : undefined, | ||
| 168 | }); | ||
| 169 | } | ||
| @@ -0,0 +1,86 @@ | |||
| 1 | "use client"; | ||
| 2 | |||
| 3 | import { useEffect, useRef, useState } from "react"; | ||
| 4 | |||
| 5 | type PopupMessage = | ||
| 6 | | { | ||
| 7 | type: "executor-v2: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-v2:mcp-oauth-result"; | ||
| 19 | ok: false; | ||
| 20 | error: string; | ||
| 21 | }; | ||
| 22 | |||
| 23 | const fallbackErrorMessage = (error: unknown): PopupMessage => ({ | ||
| 24 | type: "executor-v2:mcp-oauth-result", | ||
| 25 | ok: false, | ||
| 26 | error: error instanceof Error ? error.message : "Failed to finalize OAuth", | ||
| 27 | }); | ||
| 28 | |||
| 29 | const fetchPopupResult = async (): Promise<PopupMessage> => { | ||
| 30 | const response = await fetch("/mcp/oauth/result", { | ||
| 31 | method: "GET", | ||
| 32 | cache: "no-store", | ||
| 33 | credentials: "same-origin", | ||
| 34 | }); | ||
| 35 | |||
| 36 | const data = (await response.json()) as PopupMessage; | ||
| 37 | if (!data || data.type !== "executor-v2:mcp-oauth-result") { | ||
| 38 | throw new Error("Invalid OAuth completion payload"); | ||
| 39 | } | ||
| 40 | |||
| 41 | return data; | ||
| 42 | }; | ||
| 43 | |||
| 44 | export default function McpOAuthCompletePage() { | ||
| 45 | const sentRef = useRef(false); | ||
| 46 | const [statusText, setStatusText] = useState("Finishing OAuth"); | ||
| 47 | |||
| 48 | useEffect(() => { | ||
| 49 | if (sentRef.current) { | ||
| 50 | return; | ||
| 51 | } | ||
| 52 | |||
| 53 | sentRef.current = true; | ||
| 54 | |||
| 55 | void fetchPopupResult() | ||
| 56 | .then((message) => { | ||
| 57 | setStatusText("OAuth complete"); | ||
| 58 | |||
| 59 | if (window.opener) { | ||
| 60 | window.opener.postMessage(message, window.location.origin); | ||
| 61 | } | ||
| 62 | }) | ||
| 63 | .catch((error) => { | ||
| 64 | const message = fallbackErrorMessage(error); | ||
| 65 | setStatusText("OAuth failed"); | ||
| 66 | |||
| 67 | if (window.opener) { | ||
| 68 | window.opener.postMessage(message, window.location.origin); | ||
| 69 | } | ||
| 70 | }) | ||
| 71 | .finally(() => { | ||
| 72 | window.close(); | ||
| 73 | }); | ||
| 74 | }, []); | ||
| 75 | |||
| 76 | return ( | ||
| 77 | <main className="min-h-screen flex items-center justify-center bg-background p-6"> | ||
| 78 | <div className="text-center space-y-2"> | ||
| 79 | <h1 className="text-base font-medium">{statusText}</h1> | ||
| 80 | <p className="text-sm text-muted-foreground"> | ||
| 81 | You can close this window if it does not close automatically. | ||
| 82 | </p> | ||
| 83 | </div> | ||
| 84 | </main> | ||
| 85 | ); | ||
| 86 | } | ||
| @@ -0,0 +1,178 @@ | |||
| 1 | import { discoverOAuthProtectedResourceMetadata, extractWWWAuthenticateParams } from "@modelcontextprotocol/sdk/client/auth.js"; | ||
| 2 | import { LATEST_PROTOCOL_VERSION } from "@modelcontextprotocol/sdk/types.js"; | ||
| 3 | |||
| 4 | import { fetchMcpOAuth } from "../../../../lib/mcp/oauth-fetch"; | ||
| 5 | import { parseMcpSourceUrl } from "../../../../lib/mcp/oauth-url"; | ||
| 6 | |||
| 7 | type DetectResponse = { | ||
| 8 | oauth: boolean; | ||
| 9 | authorizationServers: Array<string>; | ||
| 10 | detail?: string; | ||
| 11 | }; | ||
| 12 | |||
| 13 | const MCP_OAUTH_CHALLENGE_PROBE_TIMEOUT_MS = 2_500; | ||
| 14 | const MCP_OAUTH_METADATA_TIMEOUT_MS = 12_000; | ||
| 15 | |||
| 16 | const noStoreDetectJson = (payload: DetectResponse, status = 200): Response => | ||
| 17 | Response.json(payload, { | ||
| 18 | status, | ||
| 19 | headers: { | ||
| 20 | "cache-control": "no-store", | ||
| 21 | }, | ||
| 22 | }); | ||
| 23 | |||
| 24 | const withTimeout = async <T>( | ||
| 25 | factory: () => Promise<T>, | ||
| 26 | timeoutMs: number, | ||
| 27 | label: string, | ||
| 28 | ): Promise<T> => | ||
| 29 | await new Promise<T>((resolve, reject) => { | ||
| 30 | const timeoutId = setTimeout(() => { | ||
| 31 | reject(new Error(`${label} timed out after ${timeoutMs}ms`)); | ||
| 32 | }, timeoutMs); | ||
| 33 | |||
| 34 | factory().then( | ||
| 35 | (value) => { | ||
| 36 | clearTimeout(timeoutId); | ||
| 37 | resolve(value); | ||
| 38 | }, | ||
| 39 | (error) => { | ||
| 40 | clearTimeout(timeoutId); | ||
| 41 | reject(error); | ||
| 42 | }, | ||
| 43 | ); | ||
| 44 | }); | ||
| 45 | |||
| 46 | const probeOAuthChallenge = async (sourceUrl: URL): Promise<boolean> => { | ||
| 47 | const response = await fetchMcpOAuth( | ||
| 48 | sourceUrl, | ||
| 49 | { | ||
| 50 | method: "POST", | ||
| 51 | headers: { | ||
| 52 | "content-type": "application/json", | ||
| 53 | accept: "application/json, text/event-stream", | ||
| 54 | "mcp-protocol-version": LATEST_PROTOCOL_VERSION, | ||
| 55 | }, | ||
| 56 | body: JSON.stringify({ | ||
| 57 | jsonrpc: "2.0", | ||
| 58 | id: "oauth-detect", | ||
| 59 | method: "initialize", | ||
| 60 | params: { | ||
| 61 | protocolVersion: LATEST_PROTOCOL_VERSION, | ||
| 62 | capabilities: {}, | ||
| 63 | clientInfo: { | ||
| 64 | name: "Executor v2 OAuth Detector", | ||
| 65 | version: "1.0.0", | ||
| 66 | }, | ||
| 67 | }, | ||
| 68 | }), | ||
| 69 | }, | ||
| 70 | { | ||
| 71 | timeoutMs: MCP_OAUTH_CHALLENGE_PROBE_TIMEOUT_MS, | ||
| 72 | label: "OAuth challenge probe", | ||
| 73 | }, | ||
| 74 | ); | ||
| 75 | |||
| 76 | const hasBearerChallenge = /^Bearer\s/i.test( | ||
| 77 | response.headers.get("WWW-Authenticate") ?? "", | ||
| 78 | ); | ||
| 79 | const challenge = extractWWWAuthenticateParams(response); | ||
| 80 | |||
| 81 | await response.body?.cancel(); | ||
| 82 | |||
| 83 | if ((response.status === 401 || response.status === 403) && hasBearerChallenge) { | ||
| 84 | return true; | ||
| 85 | } | ||
| 86 | |||
| 87 | return Boolean(challenge.resourceMetadataUrl); | ||
| 88 | }; | ||
| 89 | |||
| 90 | const toErrorDetail = (error: unknown, fallback: string): string => | ||
| 91 | error instanceof Error ? error.message : fallback; | ||
| 92 | |||
| 93 | export async function GET(request: Request): Promise<Response> { | ||
| 94 | const requestUrl = new URL(request.url); | ||
| 95 | const sourceUrlRaw = requestUrl.searchParams.get("sourceUrl")?.trim() ?? ""; | ||
| 96 | |||
| 97 | if (!sourceUrlRaw) { | ||
| 98 | return noStoreDetectJson( | ||
| 99 | { | ||
| 100 | oauth: false, | ||
| 101 | authorizationServers: [], | ||
| 102 | detail: "Missing sourceUrl", | ||
| 103 | }, | ||
| 104 | 400, | ||
| 105 | ); | ||
| 106 | } | ||
| 107 | |||
| 108 | let sourceUrl: URL; | ||
| 109 | try { | ||
| 110 | sourceUrl = parseMcpSourceUrl(sourceUrlRaw); | ||
| 111 | } catch (error) { | ||
| 112 | return noStoreDetectJson( | ||
| 113 | { | ||
| 114 | oauth: false, | ||
| 115 | authorizationServers: [], | ||
| 116 | detail: toErrorDetail(error, "Invalid sourceUrl"), | ||
| 117 | }, | ||
| 118 | 400, | ||
| 119 | ); | ||
| 120 | } | ||
| 121 | |||
| 122 | try { | ||
| 123 | const oauthByChallenge = await probeOAuthChallenge(sourceUrl); | ||
| 124 | if (oauthByChallenge) { | ||
| 125 | return noStoreDetectJson({ | ||
| 126 | oauth: true, | ||
| 127 | authorizationServers: [], | ||
| 128 | detail: "OAuth detected from endpoint challenge", | ||
| 129 | }); | ||
| 130 | } | ||
| 131 | } catch { | ||
| 132 | // Continue to metadata discovery. | ||
| 133 | } | ||
| 134 | |||
| 135 | let metadata: unknown; | ||
| 136 | try { | ||
| 137 | metadata = await withTimeout( | ||
| 138 | () => | ||
| 139 | discoverOAuthProtectedResourceMetadata( | ||
| 140 | sourceUrl, | ||
| 141 | undefined, | ||
| 142 | (input, init) => | ||
| 143 | fetchMcpOAuth(input, init ?? {}, { | ||
| 144 | timeoutMs: MCP_OAUTH_METADATA_TIMEOUT_MS, | ||
| 145 | label: "OAuth metadata lookup", | ||
| 146 | }), | ||
| 147 | ), | ||
| 148 | MCP_OAUTH_METADATA_TIMEOUT_MS, | ||
| 149 | "OAuth metadata lookup", | ||
| 150 | ); | ||
| 151 | } catch (error) { | ||
| 152 | return noStoreDetectJson( | ||
| 153 | { | ||
| 154 | oauth: false, | ||
| 155 | authorizationServers: [], | ||
| 156 | detail: toErrorDetail(error, "OAuth metadata lookup failed"), | ||
| 157 | }, | ||
| 158 | 502, | ||
| 159 | ); | ||
| 160 | } | ||
| 161 | |||
| 162 | const authorizationServersRaw = | ||
| 163 | metadata && typeof metadata === "object" && !Array.isArray(metadata) | ||
| 164 | ? (metadata as { authorization_servers?: unknown }).authorization_servers | ||
| 165 | : undefined; | ||
| 166 | |||
| 167 | const authorizationServers = Array.isArray(authorizationServersRaw) | ||
| 168 | ? authorizationServersRaw | ||
| 169 | .filter((entry): entry is string => typeof entry === "string") | ||
| 170 | .map((entry) => entry.trim()) | ||
| 171 | .filter((entry) => entry.length > 0) | ||
| 172 | : []; | ||
| 173 | |||
| 174 | return noStoreDetectJson({ | ||
| 175 | oauth: authorizationServers.length > 0, | ||
| 176 | authorizationServers, | ||
| 177 | }); | ||
| 178 | } | ||
| @@ -0,0 +1,89 @@ | |||
| 1 | import type { NextRequest } from "next/server"; | ||
| 2 | import { NextResponse } from "next/server"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | decodePopupResultCookieValue, | ||
| 6 | MCP_OAUTH_RESULT_COOKIE, | ||
| 7 | type McpOAuthPopupResult, | ||
| 8 | } from "../../../../lib/mcp/oauth-provider"; | ||
| 9 | |||
| 10 | type PopupMessage = | ||
| 11 | | { | ||
| 12 | type: "executor-v2:mcp-oauth-result"; | ||
| 13 | ok: true; | ||
| 14 | sourceUrl: string; | ||
| 15 | payload: { | ||
| 16 | accessToken: string; | ||
| 17 | refreshToken?: string; | ||
| 18 | scope?: string; | ||
| 19 | expiresIn?: number; | ||
| 20 | }; | ||
| 21 | } | ||
| 22 | | { | ||
| 23 | type: "executor-v2:mcp-oauth-result"; | ||
| 24 | ok: false; | ||
| 25 | error: string; | ||
| 26 | }; | ||
| 27 | |||
| 28 | const toPopupMessage = (result: McpOAuthPopupResult | null): PopupMessage => { | ||
| 29 | if (!result) { | ||
| 30 | return { | ||
| 31 | type: "executor-v2:mcp-oauth-result", | ||
| 32 | ok: false, | ||
| 33 | error: "OAuth result is missing or expired", | ||
| 34 | }; | ||
| 35 | } | ||
| 36 | |||
| 37 | if (!result.ok) { | ||
| 38 | return { | ||
| 39 | type: "executor-v2:mcp-oauth-result", | ||
| 40 | ok: false, | ||
| 41 | error: result.error ?? "OAuth failed", | ||
| 42 | }; | ||
| 43 | } | ||
| 44 | |||
| 45 | const accessToken = result.accessToken?.trim() ?? ""; | ||
| 46 | if (accessToken.length === 0) { | ||
| 47 | return { | ||
| 48 | type: "executor-v2:mcp-oauth-result", | ||
| 49 | ok: false, | ||
| 50 | error: "OAuth finished without an access token", | ||
| 51 | }; | ||
| 52 | } | ||
| 53 | |||
| 54 | return { | ||
| 55 | type: "executor-v2:mcp-oauth-result", | ||
| 56 | ok: true, | ||
| 57 | sourceUrl: result.sourceUrl ?? "", | ||
| 58 | payload: { | ||
| 59 | accessToken, | ||
| 60 | ...(result.refreshToken ? { refreshToken: result.refreshToken } : {}), | ||
| 61 | ...(result.scope ? { scope: result.scope } : {}), | ||
| 62 | ...(typeof result.expiresIn === "number" | ||
| 63 | ? { expiresIn: result.expiresIn } | ||
| 64 | : {}), | ||
| 65 | }, | ||
| 66 | }; | ||
| 67 | }; | ||
| 68 | |||
| 69 | export async function GET(request: NextRequest): Promise<Response> { | ||
| 70 | const encoded = request.cookies.get(MCP_OAUTH_RESULT_COOKIE)?.value ?? ""; | ||
| 71 | const parsed = encoded ? decodePopupResultCookieValue(encoded) : null; | ||
| 72 | const message = toPopupMessage(parsed); | ||
| 73 | |||
| 74 | const response = NextResponse.json(message, { | ||
| 75 | status: message.ok ? 200 : 400, | ||
| 76 | headers: { | ||
| 77 | "cache-control": "no-store", | ||
| 78 | }, | ||
| 79 | }); | ||
| 80 | |||
| 81 | response.cookies.set({ | ||
| 82 | name: MCP_OAUTH_RESULT_COOKIE, | ||
| 83 | value: "", | ||
| 84 | expires: new Date(0), | ||
| 85 | path: "/", | ||
| 86 | }); | ||
| 87 | |||
| 88 | return response; | ||
| 89 | } | ||
| @@ -0,0 +1,152 @@ | |||
| 1 | import { auth } from "@modelcontextprotocol/sdk/client/auth.js"; | ||
| 2 | import type { NextRequest } from "next/server"; | ||
| 3 | import { NextResponse } from "next/server"; | ||
| 4 | |||
| 5 | import { fetchMcpOAuth } from "../../../../lib/mcp/oauth-fetch"; | ||
| 6 | import { getExternalOrigin, isExternalHttps } from "../../../../lib/mcp/oauth-request"; | ||
| 7 | import { parseMcpSourceUrl } from "../../../../lib/mcp/oauth-url"; | ||
| 8 | import { | ||
| 9 | buildPendingCookieName, | ||
| 10 | createOAuthState, | ||
| 11 | encodePendingCookieValue, | ||
| 12 | encodePopupResultCookieValue, | ||
| 13 | MCP_OAUTH_RESULT_COOKIE, | ||
| 14 | McpPopupOAuthProvider, | ||
| 15 | type McpOAuthPopupResult, | ||
| 16 | } from "../../../../lib/mcp/oauth-provider"; | ||
| 17 | |||
| 18 | const MCP_OAUTH_FLOW_TIMEOUT_MS = 75_000; | ||
| 19 | const MCP_OAUTH_REQUEST_TIMEOUT_MS = 20_000; | ||
| 20 | |||
| 21 | const withTimeout = async <T>( | ||
| 22 | factory: () => Promise<T>, | ||
| 23 | timeoutMs: number, | ||
| 24 | label: string, | ||
| 25 | ): Promise<T> => | ||
| 26 | await new Promise<T>((resolve, reject) => { | ||
| 27 | const timeoutId = setTimeout(() => { | ||
| 28 | reject(new Error(`${label} timed out after ${timeoutMs}ms`)); | ||
| 29 | }, timeoutMs); | ||
| 30 | |||
| 31 | factory().then( | ||
| 32 | (value) => { | ||
| 33 | clearTimeout(timeoutId); | ||
| 34 | resolve(value); | ||
| 35 | }, | ||
| 36 | (error) => { | ||
| 37 | clearTimeout(timeoutId); | ||
| 38 | reject(error); | ||
| 39 | }, | ||
| 40 | ); | ||
| 41 | }); | ||
| 42 | |||
| 43 | const popupResultRedirect = ( | ||
| 44 | request: NextRequest, | ||
| 45 | payload: McpOAuthPopupResult, | ||
| 46 | ): Response => { | ||
| 47 | const origin = getExternalOrigin(request); | ||
| 48 | const response = NextResponse.redirect(new URL("/mcp/oauth/complete", origin)); | ||
| 49 | |||
| 50 | response.cookies.set({ | ||
| 51 | name: MCP_OAUTH_RESULT_COOKIE, | ||
| 52 | value: encodePopupResultCookieValue(payload), | ||
| 53 | httpOnly: true, | ||
| 54 | secure: isExternalHttps(request), | ||
| 55 | sameSite: "lax", | ||
| 56 | maxAge: 2 * 60, | ||
| 57 | path: "/", | ||
| 58 | }); | ||
| 59 | |||
| 60 | return response; | ||
| 61 | }; | ||
| 62 | |||
| 63 | const badPopupResponse = (request: NextRequest, message: string): Response => | ||
| 64 | popupResultRedirect(request, { ok: false, error: message }); | ||
| 65 | |||
| 66 | const toErrorMessage = (error: unknown, fallback: string): string => | ||
| 67 | error instanceof Error ? error.message : fallback; | ||
| 68 | |||
| 69 | export async function GET(request: NextRequest): Promise<Response> { | ||
| 70 | const sourceUrlRaw = request.nextUrl.searchParams.get("sourceUrl")?.trim() ?? ""; | ||
| 71 | if (sourceUrlRaw.length === 0) { | ||
| 72 | return badPopupResponse(request, "Missing sourceUrl"); | ||
| 73 | } | ||
| 74 | |||
| 75 | let sourceUrl: URL; | ||
| 76 | try { | ||
| 77 | sourceUrl = parseMcpSourceUrl(sourceUrlRaw); | ||
| 78 | } catch (error) { | ||
| 79 | return badPopupResponse(request, toErrorMessage(error, "Invalid sourceUrl")); | ||
| 80 | } | ||
| 81 | |||
| 82 | const state = createOAuthState(); | ||
| 83 | const redirectUrl = `${getExternalOrigin(request)}/mcp/oauth/callback`; | ||
| 84 | const provider = new McpPopupOAuthProvider({ | ||
| 85 | redirectUrl, | ||
| 86 | state, | ||
| 87 | }); | ||
| 88 | |||
| 89 | let authResult: "AUTHORIZED" | "REDIRECT"; | ||
| 90 | try { | ||
| 91 | authResult = await withTimeout( | ||
| 92 | () => | ||
| 93 | auth(provider, { | ||
| 94 | serverUrl: sourceUrl, | ||
| 95 | fetchFn: (input, init) => | ||
| 96 | fetchMcpOAuth(input, init ?? {}, { | ||
| 97 | timeoutMs: MCP_OAUTH_REQUEST_TIMEOUT_MS, | ||
| 98 | label: "OAuth startup request", | ||
| 99 | }), | ||
| 100 | }), | ||
| 101 | MCP_OAUTH_FLOW_TIMEOUT_MS, | ||
| 102 | "OAuth startup", | ||
| 103 | ); | ||
| 104 | } catch (error) { | ||
| 105 | return badPopupResponse( | ||
| 106 | request, | ||
| 107 | toErrorMessage(error, "Failed to start OAuth flow"), | ||
| 108 | ); | ||
| 109 | } | ||
| 110 | |||
| 111 | if (authResult === "AUTHORIZED") { | ||
| 112 | const tokens = provider.getTokens(); | ||
| 113 | const accessToken = tokens?.access_token?.trim() ?? ""; | ||
| 114 | if (accessToken.length === 0) { | ||
| 115 | return badPopupResponse( | ||
| 116 | request, | ||
| 117 | "OAuth flow completed without an access token", | ||
| 118 | ); | ||
| 119 | } | ||
| 120 | |||
| 121 | return popupResultRedirect(request, { | ||
| 122 | ok: true, | ||
| 123 | sourceUrl: sourceUrl.toString(), | ||
| 124 | accessToken, | ||
| 125 | refreshToken: tokens?.refresh_token, | ||
| 126 | scope: tokens?.scope, | ||
| 127 | expiresIn: | ||
| 128 | typeof tokens?.expires_in === "number" ? tokens.expires_in : undefined, | ||
| 129 | }); | ||
| 130 | } | ||
| 131 | |||
| 132 | const authorizationUrl = provider.getAuthorizationUrl(); | ||
| 133 | if (!authorizationUrl) { | ||
| 134 | return badPopupResponse( | ||
| 135 | request, | ||
| 136 | "Server did not request an OAuth authorization step", | ||
| 137 | ); | ||
| 138 | } | ||
| 139 | |||
| 140 | const response = NextResponse.redirect(authorizationUrl); | ||
| 141 | response.cookies.set({ | ||
| 142 | name: buildPendingCookieName(state), | ||
| 143 | value: encodePendingCookieValue(provider.toPending(sourceUrl.toString())), | ||
| 144 | httpOnly: true, | ||
| 145 | secure: isExternalHttps(request), | ||
| 146 | sameSite: "lax", | ||
| 147 | maxAge: 10 * 60, | ||
| 148 | path: "/", | ||
| 149 | }); | ||
| 150 | |||
| 151 | return response; | ||
| 152 | } | ||
| @@ -1,18 +1,21 @@ | |||
| 1 | "use client"; | 1 | "use client"; |
| 2 | 2 | ||
| 3 | import type { ChangeEvent, FormEvent } from "react"; | 3 | import type { ChangeEvent, FormEvent } from "react"; |
| 4 | import { useMemo, useState } from "react"; | 4 | import { useEffect, useMemo, useState } from "react"; |
| 5 | import { useAtomSet, useAtomValue } from "@effect-atom/atom-react"; | 5 | import { useAtomSet, useAtomValue } from "@effect-atom/atom-react"; |
| 6 | import type { SourceId } from "@executor-v2/schema"; | 6 | import type { SourceCredentialBinding, SourceId } from "@executor-v2/schema"; |
| 7 | 7 | ||
| 8 | import { useWorkspace } from "../../lib/hooks/use-workspace"; | 8 | import { useWorkspace } from "../../lib/hooks/use-workspace"; |
| 9 | import { | 9 | import { |
| 10 | credentialBindingsByWorkspace, | ||
| 10 | optimisticRemoveSources, | 11 | optimisticRemoveSources, |
| 11 | optimisticSourcesByWorkspace, | 12 | optimisticSourcesByWorkspace, |
| 12 | optimisticUpsertSources, | 13 | optimisticUpsertSources, |
| 13 | removeSource, | 14 | removeSource, |
| 14 | sourcesByWorkspace, | 15 | sourcesByWorkspace, |
| 15 | sourcesPendingByWorkspace, | 16 | sourcesPendingByWorkspace, |
| 17 | toCredentialBindingUpsertPayload, | ||
| 18 | upsertCredentialBinding, | ||
| 16 | upsertSource, | 19 | upsertSource, |
| 17 | } from "../../lib/control-plane/atoms"; | 20 | } from "../../lib/control-plane/atoms"; |
| 18 | import { | 21 | import { |
| @@ -22,6 +25,10 @@ import { · 3 unmodified lines | |||
| 22 | type LegacySourceFormState, | 25 | type LegacySourceFormState, |
| 23 | type LegacySourceType, | 26 | type LegacySourceType, |
| 24 | } from "../../lib/control-plane/legacy-source"; | 27 | } from "../../lib/control-plane/legacy-source"; |
| 28 | import { | ||
| 29 | startMcpOAuthPopup, | ||
| 30 | type McpOAuthPopupSuccess, | ||
| 31 | } from "../../lib/mcp/oauth-popup"; | ||
| 25 | import { Badge } from "../ui/badge"; | 32 | import { Badge } from "../ui/badge"; |
| 26 | import { Button } from "../ui/button"; | 33 | import { Button } from "../ui/button"; |
| 27 | import { | 34 | import { |
| @@ -177,6 +184,20 @@ const defaultFormState = (): LegacySourceFormState => ({ · 149 unmodified lines | |||
| 177 | enabled: true, | 184 | enabled: true, |
| 178 | }); | 185 | }); |
| 179 | 186 | ||
| 187 | type McpOAuthDetectionState = { | ||
| 188 | status: "idle" | "checking" | "oauth" | "none" | "error"; | ||
| 189 | detail: string; | ||
| 190 | authorizationServers: ReadonlyArray<string>; | ||
| 191 | }; | ||
| 192 | |||
| 193 | const defaultMcpOAuthDetectionState = (): McpOAuthDetectionState => ({ | ||
| 194 | status: "idle", | ||
| 195 | detail: "", | ||
| 196 | authorizationServers: [], | ||
| 197 | }); | ||
| 198 | |||
| 199 | const normalizeEndpoint = (value: string): string => value.trim(); | ||
| 200 | |||
| 180 | const statusBadgeVariant = ( | 201 | const statusBadgeVariant = ( |
| 181 | status: string, | 202 | status: string, |
| 182 | ): "outline" | "pending" | "approved" | "denied" => { | 203 | ): "outline" | "pending" | "approved" | "denied" => { |
| @@ -197,14 +218,25 @@ export default function SourcesView() { · 14 unmodified lines | |||
| 197 | 218 | ||
| 198 | const sources = useAtomValue(sourcesByWorkspace(workspaceId)); | 219 | const sources = useAtomValue(sourcesByWorkspace(workspaceId)); |
| 199 | const sourcesPending = useAtomValue(sourcesPendingByWorkspace(workspaceId)); | 220 | const sourcesPending = useAtomValue(sourcesPendingByWorkspace(workspaceId)); |
| 221 | const credentialBindings = useAtomValue(credentialBindingsByWorkspace(workspaceId)); | ||
| 200 | const setOptimisticSources = useAtomSet(optimisticSourcesByWorkspace(workspaceId)); | 222 | const setOptimisticSources = useAtomSet(optimisticSourcesByWorkspace(workspaceId)); |
| 201 | const runUpsertSource = useAtomSet(upsertSource, { mode: "promise" }); | 223 | const runUpsertSource = useAtomSet(upsertSource, { mode: "promise" }); |
| 224 | const runUpsertCredentialBinding = useAtomSet(upsertCredentialBinding, { | ||
| 225 | mode: "promise", | ||
| 226 | }); | ||
| 202 | const runRemoveSource = useAtomSet(removeSource, { mode: "promise" }); | 227 | const runRemoveSource = useAtomSet(removeSource, { mode: "promise" }); |
| 203 | 228 | ||
| 204 | const [formState, setFormState] = useState<LegacySourceFormState>(() => defaultFormState()); | 229 | const [formState, setFormState] = useState<LegacySourceFormState>(() => defaultFormState()); |
| 205 | const [searchQuery, setSearchQuery] = useState(""); | 230 | const [searchQuery, setSearchQuery] = useState(""); |
| 206 | const [templateQuery, setTemplateQuery] = useState(""); | 231 | const [templateQuery, setTemplateQuery] = useState(""); |
| 207 | const [statusText, setStatusText] = useState<string | null>(null); | 232 | const [statusText, setStatusText] = useState<string | null>(null); |
| 233 | const [mcpOAuthDetection, setMcpOAuthDetection] = useState<McpOAuthDetectionState>( | ||
| 234 | () => defaultMcpOAuthDetectionState(), | ||
| 235 | ); | ||
| 236 | const [mcpOAuthBusy, setMcpOAuthBusy] = useState(false); | ||
| 237 | const [mcpOAuthSession, setMcpOAuthSession] = useState<McpOAuthPopupSuccess | null>( | ||
| 238 | null, | ||
| 239 | ); | ||
| 208 | 240 | ||
| 209 | const sourceItems = useMemo( | 241 | const sourceItems = useMemo( |
| 210 | () => sources.items.map(sourceToLegacyRecord), | 242 | () => sources.items.map(sourceToLegacyRecord), |
| @@ -246,6 +278,33 @@ export default function SourcesView() { · 35 unmodified lines | |||
| 246 | 278 | ||
| 247 | const isEditing = Boolean(formState.id); | 279 | const isEditing = Boolean(formState.id); |
| 248 | 280 | ||
| 281 | const existingMcpOAuthCredential = useMemo(() => { | ||
| 282 | if (!formState.id) { | ||
| 283 | return null; | ||
| 284 | } | ||
| 285 | |||
| 286 | const sourceKey = `source:${formState.id}`; | ||
| 287 | return ( | ||
| 288 | credentialBindings.items.find( | ||
| 289 | (binding) => | ||
| 290 | binding.sourceKey === sourceKey && binding.provider === "oauth2", | ||
| 291 | ) ?? null | ||
| 292 | ); | ||
| 293 | }, [credentialBindings.items, formState.id]); | ||
| 294 | |||
| 295 | const mcpOAuthSessionMatchesEndpoint = Boolean( | ||
| 296 | mcpOAuthSession | ||
| 297 | && normalizeEndpoint(mcpOAuthSession.sourceUrl) | ||
| 298 | === normalizeEndpoint(formState.endpoint), | ||
| 299 | ); | ||
| 300 | |||
| 301 | const mcpOAuthConnected = | ||
| 302 | formState.type === "mcp" | ||
| 303 | && (mcpOAuthSessionMatchesEndpoint || Boolean(existingMcpOAuthCredential)); | ||
| 304 | |||
| 305 | const mcpOAuthCanConnect = | ||
| 306 | formState.type === "mcp" && mcpOAuthDetection.status === "oauth"; | ||
| 307 | |||
| 249 | const setFormField = <K extends keyof LegacySourceFormState>( | 308 | const setFormField = <K extends keyof LegacySourceFormState>( |
| 250 | key: K, | 309 | key: K, |
| 251 | value: LegacySourceFormState[K], | 310 | value: LegacySourceFormState[K], |
| @@ -258,9 +317,122 @@ export default function SourcesView() { · 6 unmodified lines | |||
| 258 | 317 | ||
| 259 | const resetForm = () => { | 318 | const resetForm = () => { |
| 260 | setFormState(defaultFormState()); | 319 | setFormState(defaultFormState()); |
| 320 | setMcpOAuthDetection(defaultMcpOAuthDetectionState()); | ||
| 321 | setMcpOAuthSession(null); | ||
| 322 | setMcpOAuthBusy(false); | ||
| 323 | }; | ||
| 324 | |||
| 325 | useEffect(() => { | ||
| 326 | const endpoint = normalizeEndpoint(formState.endpoint); | ||
| 327 | |||
| 328 | if (formState.type !== "mcp" || endpoint.length === 0) { | ||
| 329 | setMcpOAuthDetection(defaultMcpOAuthDetectionState()); | ||
| 330 | return; | ||
| 331 | } | ||
| 332 | |||
| 333 | const controller = new AbortController(); | ||
| 334 | setMcpOAuthDetection({ | ||
| 335 | status: "checking", | ||
| 336 | detail: "Checking for OAuth support...", | ||
| 337 | authorizationServers: [], | ||
| 338 | }); | ||
| 339 | |||
| 340 | void fetch(`/mcp/oauth/detect?sourceUrl=${encodeURIComponent(endpoint)}`, { | ||
| 341 | signal: controller.signal, | ||
| 342 | cache: "no-store", | ||
| 343 | }) | ||
| 344 | .then(async (response) => { | ||
| 345 | const payload = (await response.json().catch(() => null)) as | ||
| 346 | | { | ||
| 347 | oauth?: unknown; | ||
| 348 | authorizationServers?: unknown; | ||
| 349 | detail?: unknown; | ||
| 350 | } | ||
| 351 | | null; | ||
| 352 | |||
| 353 | if (controller.signal.aborted) { | ||
| 354 | return; | ||
| 355 | } | ||
| 356 | |||
| 357 | const oauth = payload?.oauth === true; | ||
| 358 | const authorizationServers = Array.isArray(payload?.authorizationServers) | ||
| 359 | ? payload.authorizationServers | ||
| 360 | .filter((entry): entry is string => typeof entry === "string") | ||
| 361 | .map((entry) => entry.trim()) | ||
| 362 | .filter((entry) => entry.length > 0) | ||
| 363 | : []; | ||
| 364 | const detail = typeof payload?.detail === "string" ? payload.detail.trim() : ""; | ||
| 365 | |||
| 366 | if (!response.ok) { | ||
| 367 | setMcpOAuthDetection({ | ||
| 368 | status: "error", | ||
| 369 | detail: detail || `OAuth detection failed (${response.status})`, | ||
| 370 | authorizationServers, | ||
| 371 | }); | ||
| 372 | return; | ||
| 373 | } | ||
| 374 | |||
| 375 | setMcpOAuthDetection({ | ||
| 376 | status: oauth ? "oauth" : "none", | ||
| 377 | detail, | ||
| 378 | authorizationServers, | ||
| 379 | }); | ||
| 380 | }) | ||
| 381 | .catch((error) => { | ||
| 382 | if (controller.signal.aborted) { | ||
| 383 | return; | ||
| 384 | } | ||
| 385 | |||
| 386 | setMcpOAuthDetection({ | ||
| 387 | status: "error", | ||
| 388 | detail: error instanceof Error ? error.message : "OAuth detection failed", | ||
| 389 | authorizationServers: [], | ||
| 390 | }); | ||
| 391 | }); | ||
| 392 | |||
| 393 | return () => { | ||
| 394 | controller.abort(); | ||
| 395 | }; | ||
| 396 | }, [formState.endpoint, formState.type]); | ||
| 397 | |||
| 398 | useEffect(() => { | ||
| 399 | if ( | ||
| 400 | mcpOAuthSession | ||
| 401 | && normalizeEndpoint(mcpOAuthSession.sourceUrl) | ||
| 402 | !== normalizeEndpoint(formState.endpoint) | ||
| 403 | ) { | ||
| 404 | setMcpOAuthSession(null); | ||
| 405 | } | ||
| 406 | }, [formState.endpoint, mcpOAuthSession]); | ||
| 407 | |||
| 408 | const handleMcpOAuthConnect = () => { | ||
| 409 | const endpoint = normalizeEndpoint(formState.endpoint); | ||
| 410 | if (endpoint.length === 0 || mcpOAuthBusy) { | ||
| 411 | return; | ||
| 412 | } | ||
| 413 | |||
| 414 | setMcpOAuthBusy(true); | ||
| 415 | |||
| 416 | void startMcpOAuthPopup(endpoint) | ||
| 417 | .then((result) => { | ||
| 418 | setMcpOAuthSession(result); | ||
| 419 | setFormField("authType", "bearer"); | ||
| 420 | setFormField("authMode", "workspace"); | ||
| 421 | setStatusText("OAuth connected. Save source to persist credentials."); | ||
| 422 | }) | ||
| 423 | .catch((error) => { | ||
| 424 | setStatusText( | ||
| 425 | error instanceof Error ? error.message : "OAuth connection failed.", | ||
| 426 | ); | ||
| 427 | }) | ||
| 428 | .finally(() => { | ||
| 429 | setMcpOAuthBusy(false); | ||
| 430 | }); | ||
| 261 | }; | 431 | }; |
| 262 | 432 | ||
| 263 | const handleTemplateUse = (template: CatalogTemplate) => { | 433 | const handleTemplateUse = (template: CatalogTemplate) => { |
| 434 | setMcpOAuthSession(null); | ||
| 435 | setMcpOAuthBusy(false); | ||
| 264 | setFormState((current) => ({ | 436 | setFormState((current) => ({ |
| 265 | ...current, | 437 | ...current, |
| 266 | id: undefined, | 438 | id: undefined, |
| @@ -279,6 +451,8 @@ export default function SourcesView() { · 12 unmodified lines | |||
| 279 | return; | 451 | return; |
| 280 | } | 452 | } |
| 281 | 453 | ||
| 454 | setMcpOAuthSession(null); | ||
| 455 | setMcpOAuthBusy(false); | ||
| 282 | setFormState(formStateFromSource(source)); | 456 | setFormState(formStateFromSource(source)); |
| 283 | setStatusText(`Editing ${source.name}.`); | 457 | setStatusText(`Editing ${source.name}.`); |
| 284 | }; | 458 | }; |
| @@ -305,6 +479,14 @@ export default function SourcesView() { · 20 unmodified lines | |||
| 305 | const previousSources = sources.items; | 479 | const previousSources = sources.items; |
| 306 | const optimistic = optimisticUpsertSources(previousSources, workspaceId, payload); | 480 | const optimistic = optimisticUpsertSources(previousSources, workspaceId, payload); |
| 307 | 481 | ||
| 482 | const endpoint = normalizeEndpoint(formState.endpoint); | ||
| 483 | const oauthSessionForEndpoint = | ||
| 484 | formState.type === "mcp" | ||
| 485 | && mcpOAuthSession | ||
| 486 | && normalizeEndpoint(mcpOAuthSession.sourceUrl) === endpoint | ||
| 487 | ? mcpOAuthSession | ||
| 488 | : null; | ||
| 489 | |||
| 308 | setOptimisticSources({ | 490 | setOptimisticSources({ |
| 309 | items: optimistic.items, | 491 | items: optimistic.items, |
| 310 | pendingAck: { | 492 | pendingAck: { |
| @@ -314,8 +496,74 @@ export default function SourcesView() { · 3 unmodified lines | |||
| 314 | }); | 496 | }); |
| 315 | 497 | ||
| 316 | void runUpsertSource({ path: { workspaceId }, payload }) | 498 | void runUpsertSource({ path: { workspaceId }, payload }) |
| 317 | .then(() => { | 499 | .then(async () => { |
| 318 | setStatusText(isEditing ? `Updated ${formState.name.trim()}.` : `Saved ${formState.name.trim()}.`); | 500 | let oauthLinked = false; |
| 501 | let oauthLinkNote: string | null = null; | ||
| 502 | |||
| 503 | if (formState.type === "mcp" && oauthSessionForEndpoint) { | ||
| 504 | const sourceKey = `source:${sourceId}`; | ||
| 505 | const existingBindingForSource = | ||
| 506 | credentialBindings.items.find( | ||
| 507 | (binding) => | ||
| 508 | binding.sourceKey === sourceKey && binding.provider === "oauth2", | ||
| 509 | ) ?? null; | ||
| 510 | |||
| 511 | const scopeType = | ||
| 512 | formState.authMode === "organization" | ||
| 513 | ? "organization" | ||
| 514 | : "workspace"; | ||
| 515 | |||
| 516 | if (formState.authMode === "account") { | ||
| 517 | oauthLinkNote = | ||
| 518 | "Account scope is not supported from this flow; OAuth credential was saved as workspace scope."; | ||
| 519 | } | ||
| 520 | |||
| 521 | try { | ||
| 522 | await runUpsertCredentialBinding({ | ||
| 523 | path: { workspaceId }, | ||
| 524 | payload: toCredentialBindingUpsertPayload({ | ||
| 525 | ...(existingBindingForSource | ||
| 526 | ? { id: existingBindingForSource.id } | ||
| 527 | : {}), | ||
| 528 | credentialId: ( | ||
| 529 | existingBindingForSource?.credentialId | ||
| 530 | ?? createLocalId("cred_") | ||
| 531 | ) as SourceCredentialBinding["credentialId"], | ||
| 532 | scopeType, | ||
| 533 | sourceKey, | ||
| 534 | provider: "oauth2", | ||
| 535 | secretRef: oauthSessionForEndpoint.accessToken, | ||
| 536 | accountId: null, | ||
| 537 | additionalHeadersJson: null, | ||
| 538 | boundAuthFingerprint: null, | ||
| 539 | }), | ||
| 540 | }); | ||
| 541 | |||
| 542 | oauthLinked = true; | ||
| 543 | setMcpOAuthSession(null); | ||
| 544 | } catch { | ||
| 545 | oauthLinkNote = "Source saved, but OAuth credential linking failed."; | ||
| 546 | } | ||
| 547 | } | ||
| 548 | |||
| 549 | if (oauthLinked) { | ||
| 550 | setStatusText( | ||
| 551 | `${isEditing ? "Updated" : "Saved"} ${formState.name.trim()} and linked OAuth credentials.${oauthLinkNote ? ` ${oauthLinkNote}` : ""}`, | ||
| 552 | ); | ||
| 553 | } else if (oauthLinkNote) { | ||
| 554 | setStatusText( | ||
| 555 | oauthLinkNote.startsWith("Source saved") | ||
| 556 | ? oauthLinkNote | ||
| 557 | : `${isEditing ? "Updated" : "Saved"} ${formState.name.trim()}. ${oauthLinkNote}`, | ||
| 558 | ); | ||
| 559 | } else { | ||
| 560 | setStatusText( | ||
| 561 | isEditing | ||
| 562 | ? `Updated ${formState.name.trim()}.` | ||
| 563 | : `Saved ${formState.name.trim()}.`, | ||
| 564 | ); | ||
| 565 | } | ||
| 566 | |||
| 319 | resetForm(); | 567 | resetForm(); |
| 320 | }) | 568 | }) |
| 321 | .catch(() => { | 569 | .catch(() => { |
| @@ -498,6 +746,61 @@ export default function SourcesView() { · 176 unmodified lines | |||
| 498 | </div> | 746 | </div> |
| 499 | ) : null} | 747 | ) : null} |
| 500 | 748 | ||
| 749 | {formState.type === "mcp" ? ( | ||
| 750 | <div className="space-y-2 rounded-md border border-border/70 bg-muted/20 p-3"> | ||
| 751 | <div className="flex items-start justify-between gap-3"> | ||
| 752 | <div className="space-y-1"> | ||
| 753 | <div className="flex items-center gap-2"> | ||
| 754 | <p className="text-xs font-medium text-foreground">MCP OAuth</p> | ||
| 755 | {mcpOAuthConnected ? ( | ||
| 756 | <Badge variant="approved" className="text-[10px] uppercase tracking-wide"> | ||
| 757 | connected | ||
| 758 | </Badge> | ||
| 759 | ) : null} | ||
| 760 | </div> | ||
| 761 | <p className="text-[11px] text-muted-foreground"> | ||
| 762 | {mcpOAuthDetection.status === "checking" | ||
| 763 | ? "Checking endpoint for OAuth support..." | ||
| 764 | : mcpOAuthDetection.status === "oauth" | ||
| 765 | ? "OAuth is supported for this MCP endpoint." | ||
| 766 | : mcpOAuthDetection.status === "none" | ||
| 767 | ? "OAuth was not detected for this endpoint." | ||
| 768 | : mcpOAuthDetection.status === "error" | ||
| 769 | ? "OAuth detection failed." | ||
| 770 | : "Set an MCP endpoint to detect OAuth support."} | ||
| 771 | </p> | ||
| 772 | {mcpOAuthDetection.detail.length > 0 ? ( | ||
| 773 | <p className="text-[11px] text-muted-foreground"> | ||
| 774 | {mcpOAuthDetection.detail} | ||
| 775 | </p> | ||
| 776 | ) : null} | ||
| 777 | {mcpOAuthDetection.authorizationServers.length > 0 ? ( | ||
| 778 | <p className="text-[11px] text-muted-foreground"> | ||
| 779 | Auth servers: {mcpOAuthDetection.authorizationServers.join(", ")} | ||
| 780 | </p> | ||
| 781 | ) : null} | ||
| 782 | </div> | ||
| 783 | |||
| 784 | {mcpOAuthCanConnect ? ( | ||
| 785 | <Button | ||
| 786 | type="button" | ||
| 787 | size="sm" | ||
| 788 | variant="outline" | ||
| 789 | className="shrink-0" | ||
| 790 | onClick={handleMcpOAuthConnect} | ||
| 791 | disabled={mcpOAuthBusy} | ||
| 792 | > | ||
| 793 | {mcpOAuthBusy | ||
| 794 | ? "Connecting..." | ||
| 795 | : mcpOAuthConnected | ||
| 796 | ? "Reconnect OAuth" | ||
| 797 | : "Connect OAuth"} | ||
| 798 | </Button> | ||
| 799 | ) : null} | ||
| 800 | </div> | ||
| 801 | </div> | ||
| 802 | ) : null} | ||
| 803 | |||
| 501 | <div className="grid gap-3 sm:grid-cols-2"> | 804 | <div className="grid gap-3 sm:grid-cols-2"> |
| 502 | <div className="grid gap-1.5"> | 805 | <div className="grid gap-1.5"> |
| 503 | <label className="text-xs text-muted-foreground" htmlFor="source-auth-type">Auth Type</label> | 806 | <label className="text-xs text-muted-foreground" htmlFor="source-auth-type">Auth Type</label> |
| @@ -0,0 +1,112 @@ | |||
| 1 | import { parseMcpSourceUrl } from "./oauth-url"; | ||
| 2 | |||
| 3 | const REDIRECT_STATUS_CODES = new Set([301, 302, 303, 307, 308]); | ||
| 4 | const DEFAULT_MAX_REDIRECTS = 5; | ||
| 5 | |||
| 6 | type FetchMcpOAuthOptions = { | ||
| 7 | timeoutMs: number; | ||
| 8 | label: string; | ||
| 9 | maxRedirects?: number; | ||
| 10 | }; | ||
| 11 | |||
| 12 | const toUrl = (input: RequestInfo | URL): URL => { | ||
| 13 | if (input instanceof URL) { | ||
| 14 | return input; | ||
| 15 | } | ||
| 16 | |||
| 17 | if (typeof input === "string") { | ||
| 18 | return new URL(input); | ||
| 19 | } | ||
| 20 | |||
| 21 | return new URL(input.url); | ||
| 22 | }; | ||
| 23 | |||
| 24 | const fetchWithTimeout = async ( | ||
| 25 | input: URL, | ||
| 26 | init: RequestInit, | ||
| 27 | timeoutMs: number, | ||
| 28 | label: string, | ||
| 29 | ): Promise<Response> => { | ||
| 30 | const controller = new AbortController(); | ||
| 31 | const timeoutId = setTimeout(() => controller.abort(), timeoutMs); | ||
| 32 | |||
| 33 | const sourceSignal = init.signal; | ||
| 34 | const abortFromSourceSignal = () => controller.abort(); | ||
| 35 | |||
| 36 | if (sourceSignal) { | ||
| 37 | if (sourceSignal.aborted) { | ||
| 38 | controller.abort(); | ||
| 39 | } else { | ||
| 40 | sourceSignal.addEventListener("abort", abortFromSourceSignal, { once: true }); | ||
| 41 | } | ||
| 42 | } | ||
| 43 | |||
| 44 | try { | ||
| 45 | return await fetch(input, { | ||
| 46 | ...init, | ||
| 47 | signal: controller.signal, | ||
| 48 | cache: "no-store", | ||
| 49 | redirect: "manual", | ||
| 50 | }); | ||
| 51 | } catch (error) { | ||
| 52 | if (controller.signal.aborted) { | ||
| 53 | throw new Error(`${label} timed out after ${timeoutMs}ms`); | ||
| 54 | } | ||
| 55 | |||
| 56 | throw error; | ||
| 57 | } finally { | ||
| 58 | clearTimeout(timeoutId); | ||
| 59 | sourceSignal?.removeEventListener("abort", abortFromSourceSignal); | ||
| 60 | } | ||
| 61 | }; | ||
| 62 | |||
| 63 | export const fetchMcpOAuth = async ( | ||
| 64 | input: RequestInfo | URL, | ||
| 65 | init: RequestInit, | ||
| 66 | options: FetchMcpOAuthOptions, | ||
| 67 | ): Promise<Response> => { | ||
| 68 | const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS; | ||
| 69 | let currentUrl = toUrl(input); | ||
| 70 | let currentInit = { ...init }; | ||
| 71 | |||
| 72 | for (let redirectCount = 0; redirectCount <= maxRedirects; redirectCount += 1) { | ||
| 73 | const response = await fetchWithTimeout( | ||
| 74 | currentUrl, | ||
| 75 | currentInit, | ||
| 76 | options.timeoutMs, | ||
| 77 | options.label, | ||
| 78 | ); | ||
| 79 | |||
| 80 | if (!REDIRECT_STATUS_CODES.has(response.status)) { | ||
| 81 | return response; | ||
| 82 | } | ||
| 83 | |||
| 84 | const location = response.headers.get("location")?.trim() ?? ""; | ||
| 85 | if (location.length === 0) { | ||
| 86 | return response; | ||
| 87 | } | ||
| 88 | |||
| 89 | await response.body?.cancel(); | ||
| 90 | |||
| 91 | if (redirectCount === maxRedirects) { | ||
| 92 | throw new Error(`Too many redirects while ${options.label.toLowerCase()}`); | ||
| 93 | } | ||
| 94 | |||
| 95 | const nextUrlRaw = new URL(location, currentUrl).toString(); | ||
| 96 | currentUrl = parseMcpSourceUrl(nextUrlRaw); | ||
| 97 | |||
| 98 | const method = (currentInit.method ?? "GET").toUpperCase(); | ||
| 99 | if ( | ||
| 100 | response.status === 303 | ||
| 101 | || ((response.status === 301 || response.status === 302) && method === "POST") | ||
| 102 | ) { | ||
| 103 | currentInit = { | ||
| 104 | ...currentInit, | ||
| 105 | method: "GET", | ||
| 106 | body: undefined, | ||
| 107 | }; | ||
| 108 | } | ||
| 109 | } | ||
| 110 | |||
| 111 | throw new Error(`Too many redirects while ${options.label.toLowerCase()}`); | ||
| 112 | }; | ||
| @@ -0,0 +1,128 @@ | |||
| 1 | export type McpOAuthPopupSuccess = { | ||
| 2 | sourceUrl: string; | ||
| 3 | accessToken: string; | ||
| 4 | refreshToken?: string; | ||
| 5 | scope?: string; | ||
| 6 | expiresIn?: number; | ||
| 7 | }; | ||
| 8 | |||
| 9 | type McpOAuthPopupMessage = | ||
| 10 | | { | ||
| 11 | type: "executor-v2: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-v2:mcp-oauth-result"; | ||
| 23 | ok: false; | ||
| 24 | error: string; | ||
| 25 | }; | ||
| 26 | |||
| 27 | const MCP_OAUTH_POPUP_RESULT_TIMEOUT_MS = 2 * 60_000; | ||
| 28 | |||
| 29 | export const startMcpOAuthPopup = async ( | ||
| 30 | sourceUrl: string, | ||
| 31 | ): Promise<McpOAuthPopupSuccess> => { | ||
| 32 | if (typeof window === "undefined") { | ||
| 33 | throw new Error("OAuth popup is only available in a browser context"); | ||
| 34 | } | ||
| 35 | |||
| 36 | const startUrl = `/mcp/oauth/start?sourceUrl=${encodeURIComponent(sourceUrl)}`; | ||
| 37 | const popup = window.open( | ||
| 38 | startUrl, | ||
| 39 | "executor-v2-mcp-oauth", | ||
| 40 | "popup=yes,width=520,height=720", | ||
| 41 | ); | ||
| 42 | |||
| 43 | if (!popup) { | ||
| 44 | throw new Error("Popup blocked. Allow popups and try again."); | ||
| 45 | } | ||
| 46 | |||
| 47 | popup.focus(); | ||
| 48 | |||
| 49 | return await new Promise<McpOAuthPopupSuccess>((resolve, reject) => { | ||
| 50 | let settled = false; | ||
| 51 | let closedPoll = 0; | ||
| 52 | let resultTimeout = 0; | ||
| 53 | |||
| 54 | const cleanup = () => { | ||
| 55 | window.removeEventListener("message", onMessage); | ||
| 56 | if (closedPoll) { | ||
| 57 | window.clearInterval(closedPoll); | ||
| 58 | } | ||
| 59 | |||
| 60 | if (resultTimeout) { | ||
| 61 | window.clearTimeout(resultTimeout); | ||
| 62 | } | ||
| 63 | |||
| 64 | if (!popup.closed) { | ||
| 65 | popup.close(); | ||
| 66 | } | ||
| 67 | }; | ||
| 68 | |||
| 69 | const closeAndReject = (message: string) => { | ||
| 70 | if (settled) { | ||
| 71 | return; | ||
| 72 | } | ||
| 73 | |||
| 74 | settled = true; | ||
| 75 | cleanup(); | ||
| 76 | reject(new Error(message)); | ||
| 77 | }; | ||
| 78 | |||
| 79 | const onMessage = (event: MessageEvent) => { | ||
| 80 | if (event.origin !== window.location.origin) { | ||
| 81 | return; | ||
| 82 | } | ||
| 83 | |||
| 84 | const data = event.data as McpOAuthPopupMessage | undefined; | ||
| 85 | if (!data || data.type !== "executor-v2:mcp-oauth-result") { | ||
| 86 | return; | ||
| 87 | } | ||
| 88 | |||
| 89 | if (!data.ok) { | ||
| 90 | closeAndReject(data.error || "OAuth failed"); | ||
| 91 | return; | ||
| 92 | } | ||
| 93 | |||
| 94 | if (!data.payload?.accessToken) { | ||
| 95 | closeAndReject("OAuth finished without an access token"); | ||
| 96 | return; | ||
| 97 | } | ||
| 98 | |||
| 99 | if (settled) { | ||
| 100 | return; | ||
| 101 | } | ||
| 102 | |||
| 103 | settled = true; | ||
| 104 | cleanup(); | ||
| 105 | resolve({ | ||
| 106 | sourceUrl: data.sourceUrl, | ||
| 107 | accessToken: data.payload.accessToken, | ||
| 108 | ...(data.payload.refreshToken ? { refreshToken: data.payload.refreshToken } : {}), | ||
| 109 | ...(data.payload.scope ? { scope: data.payload.scope } : {}), | ||
| 110 | ...(typeof data.payload.expiresIn === "number" | ||
| 111 | ? { expiresIn: data.payload.expiresIn } | ||
| 112 | : {}), | ||
| 113 | }); | ||
| 114 | }; | ||
| 115 | |||
| 116 | window.addEventListener("message", onMessage); | ||
| 117 | |||
| 118 | resultTimeout = window.setTimeout(() => { | ||
| 119 | closeAndReject("OAuth popup timed out before completion. Please try again."); | ||
| 120 | }, MCP_OAUTH_POPUP_RESULT_TIMEOUT_MS); | ||
| 121 | |||
| 122 | closedPoll = window.setInterval(() => { | ||
| 123 | if (popup.closed) { | ||
| 124 | closeAndReject("OAuth popup was closed before completion"); | ||
| 125 | } | ||
| 126 | }, 300); | ||
| 127 | }); | ||
| 128 | }; | ||
| @@ -0,0 +1,249 @@ | |||
| 1 | import { randomUUID } from "node:crypto"; | ||
| 2 | import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; | ||
| 3 | import type { | ||
| 4 | OAuthClientInformationMixed, | ||
| 5 | OAuthClientMetadata, | ||
| 6 | OAuthTokens, | ||
| 7 | } from "@modelcontextprotocol/sdk/shared/auth.js"; | ||
| 8 | |||
| 9 | const COOKIE_PREFIX = "executor_v2_mcp_oauth_"; | ||
| 10 | export const MCP_OAUTH_RESULT_COOKIE = "executor_v2_mcp_oauth_result"; | ||
| 11 | |||
| 12 | export type McpOAuthPending = { | ||
| 13 | state: string; | ||
| 14 | sourceUrl: string; | ||
| 15 | redirectUrl: string; | ||
| 16 | codeVerifier?: string; | ||
| 17 | clientInformation?: OAuthClientInformationMixed; | ||
| 18 | }; | ||
| 19 | |||
| 20 | export type McpOAuthPopupResult = { | ||
| 21 | ok: boolean; | ||
| 22 | sourceUrl?: string; | ||
| 23 | accessToken?: string; | ||
| 24 | refreshToken?: string; | ||
| 25 | scope?: string; | ||
| 26 | expiresIn?: number; | ||
| 27 | error?: string; | ||
| 28 | }; | ||
| 29 | |||
| 30 | const asRecord = (value: unknown): Record<string, unknown> | null => { | ||
| 31 | if (!value || typeof value !== "object" || Array.isArray(value)) { | ||
| 32 | return null; | ||
| 33 | } | ||
| 34 | |||
| 35 | return value as Record<string, unknown>; | ||
| 36 | }; | ||
| 37 | |||
| 38 | const readString = (record: Record<string, unknown>, key: string): string | null => { | ||
| 39 | const value = record[key]; | ||
| 40 | return typeof value === "string" ? value : null; | ||
| 41 | }; | ||
| 42 | |||
| 43 | const readOptionalString = (record: Record<string, unknown>, key: string): string | undefined => { | ||
| 44 | const value = record[key]; | ||
| 45 | return typeof value === "string" ? value : undefined; | ||
| 46 | }; | ||
| 47 | |||
| 48 | const readOptionalNumber = (record: Record<string, unknown>, key: string): number | undefined => { | ||
| 49 | const value = record[key]; | ||
| 50 | return typeof value === "number" ? value : undefined; | ||
| 51 | }; | ||
| 52 | |||
| 53 | const decodeCookieJson = (raw: string): unknown | null => { | ||
| 54 | try { | ||
| 55 | const decoded = Buffer.from(raw, "base64url").toString("utf8"); | ||
| 56 | return JSON.parse(decoded); | ||
| 57 | } catch { | ||
| 58 | return null; | ||
| 59 | } | ||
| 60 | }; | ||
| 61 | |||
| 62 | const decodePending = (value: unknown): McpOAuthPending | null => { | ||
| 63 | const record = asRecord(value); | ||
| 64 | if (!record) { | ||
| 65 | return null; | ||
| 66 | } | ||
| 67 | |||
| 68 | const state = readString(record, "state"); | ||
| 69 | const sourceUrl = readString(record, "sourceUrl"); | ||
| 70 | const redirectUrl = readString(record, "redirectUrl"); | ||
| 71 | |||
| 72 | if (!state || !sourceUrl || !redirectUrl) { | ||
| 73 | return null; | ||
| 74 | } | ||
| 75 | |||
| 76 | const codeVerifier = readOptionalString(record, "codeVerifier"); | ||
| 77 | const clientInformationValue = record.clientInformation; | ||
| 78 | const clientInformation = asRecord(clientInformationValue) | ||
| 79 | ? (clientInformationValue as OAuthClientInformationMixed) | ||
| 80 | : undefined; | ||
| 81 | |||
| 82 | return { | ||
| 83 | state, | ||
| 84 | sourceUrl, | ||
| 85 | redirectUrl, | ||
| 86 | ...(codeVerifier ? { codeVerifier } : {}), | ||
| 87 | ...(clientInformation ? { clientInformation } : {}), | ||
| 88 | }; | ||
| 89 | }; | ||
| 90 | |||
| 91 | const decodePopupResult = (value: unknown): McpOAuthPopupResult | null => { | ||
| 92 | const record = asRecord(value); | ||
| 93 | if (!record) { | ||
| 94 | return null; | ||
| 95 | } | ||
| 96 | |||
| 97 | const ok = record.ok; | ||
| 98 | if (typeof ok !== "boolean") { | ||
| 99 | return null; | ||
| 100 | } | ||
| 101 | |||
| 102 | return { | ||
| 103 | ok, | ||
| 104 | sourceUrl: readOptionalString(record, "sourceUrl"), | ||
| 105 | accessToken: readOptionalString(record, "accessToken"), | ||
| 106 | refreshToken: readOptionalString(record, "refreshToken"), | ||
| 107 | scope: readOptionalString(record, "scope"), | ||
| 108 | expiresIn: readOptionalNumber(record, "expiresIn"), | ||
| 109 | error: readOptionalString(record, "error"), | ||
| 110 | }; | ||
| 111 | }; | ||
| 112 | |||
| 113 | export const buildPendingCookieName = (state: string): string => | ||
| 114 | `${COOKIE_PREFIX}${state}`; | ||
| 115 | |||
| 116 | export const createOAuthState = (): string => randomUUID(); | ||
| 117 | |||
| 118 | export const encodePendingCookieValue = (pending: McpOAuthPending): string => | ||
| 119 | Buffer.from( | ||
| 120 | JSON.stringify({ | ||
| 121 | version: 1, | ||
| 122 | pending, | ||
| 123 | }), | ||
| 124 | "utf8", | ||
| 125 | ).toString("base64url"); | ||
| 126 | |||
| 127 | export const decodePendingCookieValue = (raw: string): McpOAuthPending | null => { | ||
| 128 | const decoded = decodeCookieJson(raw); | ||
| 129 | const versioned = asRecord(decoded); | ||
| 130 | if (!versioned || versioned.version !== 1) { | ||
| 131 | return null; | ||
| 132 | } | ||
| 133 | |||
| 134 | return decodePending(versioned.pending); | ||
| 135 | }; | ||
| 136 | |||
| 137 | export const encodePopupResultCookieValue = (result: McpOAuthPopupResult): string => | ||
| 138 | Buffer.from( | ||
| 139 | JSON.stringify({ | ||
| 140 | version: 1, | ||
| 141 | result, | ||
| 142 | }), | ||
| 143 | "utf8", | ||
| 144 | ).toString("base64url"); | ||
| 145 | |||
| 146 | export const decodePopupResultCookieValue = ( | ||
| 147 | raw: string, | ||
| 148 | ): McpOAuthPopupResult | null => { | ||
| 149 | const decoded = decodeCookieJson(raw); | ||
| 150 | const versioned = asRecord(decoded); | ||
| 151 | if (!versioned || versioned.version !== 1) { | ||
| 152 | return null; | ||
| 153 | } | ||
| 154 | |||
| 155 | return decodePopupResult(versioned.result); | ||
| 156 | }; | ||
| 157 | |||
| 158 | export class McpPopupOAuthProvider implements OAuthClientProvider { | ||
| 159 | public clientMetadata: OAuthClientMetadata; | ||
| 160 | |||
| 161 | private readonly stateValue: string; | ||
| 162 | private readonly redirectTarget: string; | ||
| 163 | private codeVerifierValue?: string; | ||
| 164 | private clientInfo?: OAuthClientInformationMixed; | ||
| 165 | private tokenValue?: OAuthTokens; | ||
| 166 | private authorizationUrl?: string; | ||
| 167 | |||
| 168 | constructor(input: { | ||
| 169 | redirectUrl: string; | ||
| 170 | state: string; | ||
| 171 | codeVerifier?: string; | ||
| 172 | clientInformation?: OAuthClientInformationMixed; | ||
| 173 | tokens?: OAuthTokens; | ||
| 174 | }) { | ||
| 175 | this.redirectTarget = input.redirectUrl; | ||
| 176 | this.stateValue = input.state; | ||
| 177 | this.codeVerifierValue = input.codeVerifier; | ||
| 178 | this.clientInfo = input.clientInformation; | ||
| 179 | this.tokenValue = input.tokens; | ||
| 180 | |||
| 181 | this.clientMetadata = { | ||
| 182 | redirect_uris: [input.redirectUrl], | ||
| 183 | grant_types: ["authorization_code", "refresh_token"], | ||
| 184 | response_types: ["code"], | ||
| 185 | token_endpoint_auth_method: "none", | ||
| 186 | client_name: "Executor v2 MCP Connector", | ||
| 187 | }; | ||
| 188 | } | ||
| 189 | |||
| 190 | get redirectUrl(): string { | ||
| 191 | return this.redirectTarget; | ||
| 192 | } | ||
| 193 | |||
| 194 | async state(): Promise<string> { | ||
| 195 | return this.stateValue; | ||
| 196 | } | ||
| 197 | |||
| 198 | clientInformation(): OAuthClientInformationMixed | undefined { | ||
| 199 | return this.clientInfo; | ||
| 200 | } | ||
| 201 | |||
| 202 | async saveClientInformation( | ||
| 203 | clientInformation: OAuthClientInformationMixed, | ||
| 204 | ): Promise<void> { | ||
| 205 | this.clientInfo = clientInformation; | ||
| 206 | } | ||
| 207 | |||
| 208 | tokens(): OAuthTokens | undefined { | ||
| 209 | return this.tokenValue; | ||
| 210 | } | ||
| 211 | |||
| 212 | async saveTokens(tokens: OAuthTokens): Promise<void> { | ||
| 213 | this.tokenValue = tokens; | ||
| 214 | } | ||
| 215 | |||
| 216 | async redirectToAuthorization(authorizationUrl: URL): Promise<void> { | ||
| 217 | this.authorizationUrl = authorizationUrl.toString(); | ||
| 218 | } | ||
| 219 | |||
| 220 | async saveCodeVerifier(codeVerifier: string): Promise<void> { | ||
| 221 | this.codeVerifierValue = codeVerifier; | ||
| 222 | } | ||
| 223 | |||
| 224 | codeVerifier(): string { | ||
| 225 | if (!this.codeVerifierValue) { | ||
| 226 | throw new Error("Missing PKCE code verifier"); | ||
| 227 | } | ||
| 228 | |||
| 229 | return this.codeVerifierValue; | ||
| 230 | } | ||
| 231 | |||
| 232 | getAuthorizationUrl(): string | undefined { | ||
| 233 | return this.authorizationUrl; | ||
| 234 | } | ||
| 235 | |||
| 236 | toPending(sourceUrl: string): McpOAuthPending { | ||
| 237 | return { | ||
| 238 | state: this.stateValue, | ||
| 239 | sourceUrl, | ||
| 240 | redirectUrl: this.redirectTarget, | ||
| 241 | ...(this.codeVerifierValue ? { codeVerifier: this.codeVerifierValue } : {}), | ||
| 242 | ...(this.clientInfo ? { clientInformation: this.clientInfo } : {}), | ||
| 243 | }; | ||
| 244 | } | ||
| 245 | |||
| 246 | getTokens(): OAuthTokens | undefined { | ||
| 247 | return this.tokenValue; | ||
| 248 | } | ||
| 249 | } | ||
| @@ -0,0 +1,59 @@ | |||
| 1 | const firstHeaderValue = (value: string | null): string => { | ||
| 2 | if (!value) { | ||
| 3 | return ""; | ||
| 4 | } | ||
| 5 | |||
| 6 | return value.split(",")[0]?.trim() ?? ""; | ||
| 7 | }; | ||
| 8 | |||
| 9 | const configuredExternalOrigin = (): string | null => { | ||
| 10 | const configured = | ||
| 11 | process.env.EXECUTOR_PUBLIC_ORIGIN | ||
| 12 | ?? process.env.NEXT_PUBLIC_APP_ORIGIN | ||
| 13 | ?? process.env.VERCEL_PROJECT_PRODUCTION_URL | ||
| 14 | ?? process.env.VERCEL_URL | ||
| 15 | ?? ""; | ||
| 16 | |||
| 17 | if (!configured.trim()) { | ||
| 18 | return null; | ||
| 19 | } | ||
| 20 | |||
| 21 | try { | ||
| 22 | const normalized = | ||
| 23 | configured.startsWith("http://") || configured.startsWith("https://") | ||
| 24 | ? configured | ||
| 25 | : `https://${configured}`; | ||
| 26 | |||
| 27 | return new URL(normalized).origin; | ||
| 28 | } catch { | ||
| 29 | return null; | ||
| 30 | } | ||
| 31 | }; | ||
| 32 | |||
| 33 | export const getExternalOrigin = (request: Request): string => { | ||
| 34 | const configured = configuredExternalOrigin(); | ||
| 35 | if (configured) { | ||
| 36 | return configured; | ||
| 37 | } | ||
| 38 | |||
| 39 | const host = firstHeaderValue( | ||
| 40 | request.headers.get("x-forwarded-host") ?? request.headers.get("host"), | ||
| 41 | ); | ||
| 42 | const requestUrl = new URL(request.url); | ||
| 43 | const proto = | ||
| 44 | firstHeaderValue(request.headers.get("x-forwarded-proto")) | ||
| 45 | || requestUrl.protocol.replace(":", ""); | ||
| 46 | |||
| 47 | if (host && proto) { | ||
| 48 | try { | ||
| 49 | return new URL(`${proto}://${host}`).origin; | ||
| 50 | } catch { | ||
| 51 | return requestUrl.origin; | ||
| 52 | } | ||
| 53 | } | ||
| 54 | |||
| 55 | return requestUrl.origin; | ||
| 56 | }; | ||
| 57 | |||
| 58 | export const isExternalHttps = (request: Request): boolean => | ||
| 59 | getExternalOrigin(request).startsWith("https://"); | ||
| @@ -0,0 +1,30 @@ | |||
| 1 | const isLocalHost = (hostname: string): boolean => | ||
| 2 | hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1"; | ||
| 3 | |||
| 4 | const localHttpAllowed = (): boolean => | ||
| 5 | process.env.NODE_ENV !== "production" | ||
| 6 | || process.env.EXECUTOR_ALLOW_LOCAL_MCP_OAUTH === "1"; | ||
| 7 | |||
| 8 | export const parseMcpSourceUrl = (raw: string): URL => { | ||
| 9 | let url: URL; | ||
| 10 | |||
| 11 | try { | ||
| 12 | url = new URL(raw); | ||
| 13 | } catch { | ||
| 14 | throw new Error("Invalid MCP source URL"); | ||
| 15 | } | ||
| 16 | |||
| 17 | if (url.username || url.password) { | ||
| 18 | throw new Error("Credentials in MCP source URL are not allowed"); | ||
| 19 | } | ||
| 20 | |||
| 21 | if (url.protocol === "https:") { | ||
| 22 | return url; | ||
| 23 | } | ||
| 24 | |||
| 25 | if (url.protocol === "http:" && localHttpAllowed() && isLocalHost(url.hostname.toLowerCase())) { | ||
| 26 | return url; | ||
| 27 | } | ||
| 28 | |||
| 29 | throw new Error("MCP source URL must use https:// (http://localhost allowed in local dev)"); | ||
| 30 | }; | ||