Unified · Split
| @@ -13,6 +13,7 @@ describe("isAppOwnedPath", () => { · 12 unmodified lines | ||
| 13 | 13 | "/api/executions", |
| 14 | 14 | "/api/auth/me", |
| 15 | 15 | "/api/openapi.json", |
| 16 | + "/api/oauth/client-id-metadata/default.json", | |
| 16 | 17 | "/api/billing/customer", // AutumnProvider pathPrefix — the billing UI |
| 17 | 18 | "/api/billing/attach", |
| 18 | 19 | "/api/docs", // Swagger UI |
| @@ -54,6 +54,9 @@ export default defineConfig({ · 53 unmodified lines | ||
| 54 | 54 | process.env.npm_package_version ?? "0.0.0", |
| 55 | 55 | ), |
| 56 | 56 | "import.meta.env.VITE_GITHUB_URL": JSON.stringify("https://github.com/RhysSullivan/executor"), |
| 57 | + "import.meta.env.VITE_EXECUTOR_CIMD_CLIENT_ID_METADATA_BASE_URL": JSON.stringify( | |
| 58 | + process.env.EXECUTOR_CIMD_CLIENT_ID_METADATA_BASE_URL ?? "https://executor.sh", | |
| 59 | + ), | |
| 57 | 60 | }, |
| 58 | 61 | resolve: { |
| 59 | 62 | alias: { |
| @@ -4,7 +4,9 @@ import { · 3 unmodified lines | ||
| 4 | 4 | DEFAULT_ALLOWED_HOSTS, |
| 5 | 5 | hasBearerToken, |
| 6 | 6 | isAllowedOrigin, |
| 7 | + isUnauthenticatedOAuthClientMetadataPath, | |
| 7 | 8 | isUnauthenticatedOAuthCallbackPath, |
| 9 | + isUnauthenticatedOAuthPath, | |
| 8 | 10 | makeIsAuthorized, |
| 9 | 11 | } from "./serve-shared"; |
| 10 | 12 | |
| @@ -49,3 +51,24 @@ describe("isUnauthenticatedOAuthCallbackPath", () => { · 38 unmodified lines | ||
| 49 | 51 | expect(isUnauthenticatedOAuthCallbackPath("/api/scope")).toBe(false); |
| 50 | 52 | }); |
| 51 | 53 | }); |
| 54 | + | |
| 55 | +describe("isUnauthenticatedOAuthClientMetadataPath", () => { | |
| 56 | + it("exempts the CIMD documents the OAuth provider fetches", () => { | |
| 57 | + expect(isUnauthenticatedOAuthClientMetadataPath("/api/oauth/client-id-metadata.json")).toBe( | |
| 58 | + true, | |
| 59 | + ); | |
| 60 | + expect( | |
| 61 | + isUnauthenticatedOAuthClientMetadataPath("/api/oauth/client-id-metadata/default.json"), | |
| 62 | + ).toBe(true); | |
| 63 | + expect( | |
| 64 | + isUnauthenticatedOAuthClientMetadataPath("/api/oauth/client-id-metadata/local.json"), | |
| 65 | + ).toBe(true); | |
| 66 | + }); | |
| 67 | + | |
| 68 | + it("does not exempt OAuth result polling or arbitrary API paths", () => { | |
| 69 | + expect(isUnauthenticatedOAuthPath("/api/oauth/client-id-metadata/local.json")).toBe(true); | |
| 70 | + expect(isUnauthenticatedOAuthPath("/api/oauth/callback")).toBe(true); | |
| 71 | + expect(isUnauthenticatedOAuthPath("/api/oauth/await/session-1")).toBe(false); | |
| 72 | + expect(isUnauthenticatedOAuthPath("/api/connections")).toBe(false); | |
| 73 | + }); | |
| 74 | +}); | |
| @@ -86,3 +86,11 @@ export const hasFileExtension = (pathname: string): boolean => { · 85 unmodified lines | ||
| 86 | 86 | */ |
| 87 | 87 | export const isUnauthenticatedOAuthCallbackPath = (pathname: string): boolean => |
| 88 | 88 | /^\/api\/oauth\/callback(\/|$)/.test(pathname); |
| 89 | + | |
| 90 | +export const isUnauthenticatedOAuthClientMetadataPath = (pathname: string): boolean => | |
| 91 | + pathname === "/api/oauth/client-id-metadata.json" || | |
| 92 | + /^\/api\/oauth\/client-id-metadata\/[^/]+\.json$/.test(pathname); | |
| 93 | + | |
| 94 | +export const isUnauthenticatedOAuthPath = (pathname: string): boolean => | |
| 95 | + isUnauthenticatedOAuthCallbackPath(pathname) || | |
| 96 | + isUnauthenticatedOAuthClientMetadataPath(pathname); | |
| @@ -134,6 +134,25 @@ describe("startServer bearer auth", () => { · 133 unmodified lines | ||
| 134 | 134 | expect(response.status).toBe(200); |
| 135 | 135 | }); |
| 136 | 136 | |
| 137 | + it("serves the CIMD client metadata document without a bearer", async () => { | |
| 138 | + const baseUrl = await startTestServer(); | |
| 139 | + const documentUrl = `${baseUrl}/api/oauth/client-id-metadata/local.json`; | |
| 140 | + const response = await fetch(documentUrl); | |
| 141 | + expect(response.status).toBe(200); | |
| 142 | + const metadata = (await response.json()) as { | |
| 143 | + readonly client_id: string; | |
| 144 | + readonly redirect_uris: readonly string[]; | |
| 145 | + readonly application_type: string; | |
| 146 | + }; | |
| 147 | + expect(metadata.client_id).toBe(documentUrl); | |
| 148 | + expect(metadata.redirect_uris).toEqual([ | |
| 149 | + "http://127.0.0.1/api/oauth/callback", | |
| 150 | + "http://localhost/api/oauth/callback", | |
| 151 | + "http://[::1]/api/oauth/callback", | |
| 152 | + ]); | |
| 153 | + expect(metadata.application_type).toBe("native"); | |
| 154 | + }); | |
| 155 | + | |
| 137 | 156 | it("requires the bearer token on the OAuth await poll", async () => { |
| 138 | 157 | const baseUrl = await startTestServer(); |
| 139 | 158 | const response = await fetch(`${baseUrl}/api/oauth/await/session-1`); |
| @@ -11,6 +11,7 @@ import { resolve, join } from "node:path"; · 10 unmodified lines | ||
| 11 | 11 | import { readdirSync } from "node:fs"; |
| 12 | 12 | import type { Subprocess } from "bun"; |
| 13 | 13 | import { setOAuthCompletionListener } from "@executor-js/api"; |
| 14 | +import { oauthClientIdMetadataDocumentFromRequest } from "@executor-js/api/server"; | |
| 14 | 15 | import { loadOrMintLocalAuthToken } from "./auth"; |
| 15 | 16 | import { consumeOAuthResult, publishOAuthResult } from "./oauth-result-store"; |
| 16 | 17 | import { startIntegrationsRefresh } from "./integrations"; |
| @@ -19,7 +20,8 @@ import { · 2 unmodified lines | ||
| 19 | 20 | DEFAULT_ALLOWED_HOSTS, |
| 20 | 21 | hasFileExtension, |
| 21 | 22 | isAllowedOrigin, |
| 22 | − isUnauthenticatedOAuthCallbackPath, | |
| 23 | + isUnauthenticatedOAuthClientMetadataPath, | |
| 24 | + isUnauthenticatedOAuthPath, | |
| 23 | 25 | makeIsAuthorized, |
| 24 | 26 | normalizeCredential, |
| 25 | 27 | } from "./serve-shared"; |
| @@ -35,6 +37,23 @@ const htmlResponse = (file: Bun.BunFile): Response => · 9 unmodified lines | ||
| 35 | 37 | headers: { "content-type": "text/html", "cache-control": "no-store" }, |
| 36 | 38 | }); |
| 37 | 39 | |
| 40 | +const oauthClientMetadataResponse = (requestUrl: string, webRequest: Request): Response => | |
| 41 | + new Response( | |
| 42 | + JSON.stringify( | |
| 43 | + oauthClientIdMetadataDocumentFromRequest({ | |
| 44 | + requestUrl, | |
| 45 | + webRequest, | |
| 46 | + mountPrefix: "/api", | |
| 47 | + }), | |
| 48 | + ), | |
| 49 | + { | |
| 50 | + headers: { | |
| 51 | + "content-type": "application/json", | |
| 52 | + "cache-control": "public, max-age=300", | |
| 53 | + }, | |
| 54 | + }, | |
| 55 | + ); | |
| 56 | + | |
| 38 | 57 | function collectStaticRoutes(dir: string, prefix = ""): Record<string, StaticHandler> { |
| 39 | 58 | const routes: Record<string, StaticHandler> = {}; |
| 40 | 59 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: filesystem route discovery is best-effort for optional built assets |
| @@ -339,11 +358,10 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server · 298 unmodified lines | ||
| 339 | 358 | return withCors(new Response("ok", { headers: { "content-type": "text/plain" } })); |
| 340 | 359 | } |
| 341 | 360 | |
| 342 | − // OAuth provider callbacks are hit by the user's external browser and | |
| 343 | − // can't carry our bearer. The OAuth `state` parameter is the security | |
| 344 | − // gate — see isUnauthenticatedOAuthCallbackPath. Everything else under | |
| 361 | + // OAuth callbacks and CIMD documents are reached by the external | |
| 362 | + // provider, which cannot carry our local bearer. Everything else under | |
| 345 | 363 | // /api and /mcp requires the bearer. |
| 346 | − const skipAuth = isUnauthenticatedOAuthCallbackPath(url.pathname); | |
| 364 | + const skipAuth = isUnauthenticatedOAuthPath(url.pathname); | |
| 347 | 365 | const isGatedSurface = url.pathname.startsWith("/api") || url.pathname.startsWith("/mcp"); |
| 348 | 366 | |
| 349 | 367 | if (isGatedSurface && !skipAuth && !isAuthorized(req)) { |
| @@ -355,6 +373,10 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server · 5 unmodified lines | ||
| 355 | 373 | ); |
| 356 | 374 | } |
| 357 | 375 | |
| 376 | + if (isUnauthenticatedOAuthClientMetadataPath(url.pathname) && req.method === "GET") { | |
| 377 | + return withCors(oauthClientMetadataResponse(`${url.pathname}${url.search}`, req)); | |
| 378 | + } | |
| 379 | + | |
| 358 | 380 | if (url.pathname.startsWith("/mcp")) { |
| 359 | 381 | return withCors(await handlers.mcp.handleRequest(req)); |
| 360 | 382 | } |
| @@ -4,9 +4,14 @@ import { Readable } from "node:stream"; · 3 unmodified lines | ||
| 4 | 4 | import { fileURLToPath } from "node:url"; |
| 5 | 5 | import { defineConfig, type Plugin } from "vite"; |
| 6 | 6 | import appPlugin from "@executor-js/app/vite"; |
| 7 | +import { oauthClientIdMetadataDocumentFromRequest } from "@executor-js/api/server"; | |
| 7 | 8 | import { loadOrMintLocalAuthToken } from "./src/auth"; |
| 8 | 9 | import { consumeOAuthResult } from "./src/oauth-result-store"; |
| 9 | −import { isUnauthenticatedOAuthCallbackPath, makeIsAuthorized } from "./src/serve-shared"; | |
| 10 | +import { | |
| 11 | + isUnauthenticatedOAuthClientMetadataPath, | |
| 12 | + isUnauthenticatedOAuthPath, | |
| 13 | + makeIsAuthorized, | |
| 14 | +} from "./src/serve-shared"; | |
| 10 | 15 | |
| 11 | 16 | // oxlint-disable-next-line executor/no-json-parse -- boundary: Vite config reads package metadata from package.json |
| 12 | 17 | const rootPackage = JSON.parse( |
| @@ -33,6 +38,23 @@ const EXECUTOR_GITHUB_URL = ( · 20 unmodified lines | ||
| 33 | 38 | const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url)); |
| 34 | 39 | const APP_ROOT = fileURLToPath(new URL("../../packages/app/", import.meta.url)); |
| 35 | 40 | |
| 41 | +const oauthClientMetadataResponse = (requestUrl: string, webRequest: Request): Response => | |
| 42 | + new Response( | |
| 43 | + JSON.stringify( | |
| 44 | + oauthClientIdMetadataDocumentFromRequest({ | |
| 45 | + requestUrl, | |
| 46 | + webRequest, | |
| 47 | + mountPrefix: "/api", | |
| 48 | + }), | |
| 49 | + ), | |
| 50 | + { | |
| 51 | + headers: { | |
| 52 | + "content-type": "application/json", | |
| 53 | + "cache-control": "public, max-age=300", | |
| 54 | + }, | |
| 55 | + }, | |
| 56 | + ); | |
| 57 | + | |
| 36 | 58 | /** |
| 37 | 59 | * Vite plugin that forwards /api and /mcp requests to the Effect handlers |
| 38 | 60 | * during development, so you don't need a separate server process. |
| @@ -85,8 +107,7 @@ function executorApiPlugin(): Plugin { · 46 unmodified lines | ||
| 85 | 107 | // bootstrap, so the UI is unaffected; external MCP clients use the |
| 86 | 108 | // daemon port. |
| 87 | 109 | const pathOnly = rawUrl.split("?")[0] ?? "/"; |
| 88 | − const authExempt = | |
| 89 | − pathOnly === "/api/health" || isUnauthenticatedOAuthCallbackPath(pathOnly); | |
| 110 | + const authExempt = pathOnly === "/api/health" || isUnauthenticatedOAuthPath(pathOnly); | |
| 90 | 111 | if (!authExempt) { |
| 91 | 112 | const presented = req.headers.authorization; |
| 92 | 113 | const authValue = Array.isArray(presented) ? presented[0] : presented; |
| @@ -125,7 +146,9 @@ function executorApiPlugin(): Plugin { · 32 unmodified lines | ||
| 125 | 146 | } as RequestInit); |
| 126 | 147 | |
| 127 | 148 | let response: Response; |
| 128 | − if (isMcp) { | |
| 149 | + if (isUnauthenticatedOAuthClientMetadataPath(pathOnly) && req.method === "GET") { | |
| 150 | + response = oauthClientMetadataResponse(rawUrl, webRequest(rawUrl)); | |
| 151 | + } else if (isMcp) { | |
| 129 | 152 | response = await handlers.mcp.handleRequest(webRequest(rawUrl)); |
| 130 | 153 | } else if (pathOnly === "/api/health" && req.method === "GET") { |
| 131 | 154 | response = new Response("ok", { headers: { "content-type": "text/plain" } }); |
| @@ -182,6 +205,9 @@ export default defineConfig({ · 50 unmodified lines | ||
| 182 | 205 | "import.meta.env.VITE_APP_VERSION": JSON.stringify(EXECUTOR_VERSION), |
| 183 | 206 | "import.meta.env.VITE_GITHUB_URL": JSON.stringify(EXECUTOR_GITHUB_URL), |
| 184 | 207 | "import.meta.env.VITE_EXECUTOR_DEV_CLI_CWD": JSON.stringify(REPO_ROOT), |
| 208 | + "import.meta.env.VITE_EXECUTOR_CIMD_CLIENT_ID_METADATA_BASE_URL": JSON.stringify( | |
| 209 | + process.env.EXECUTOR_CIMD_CLIENT_ID_METADATA_BASE_URL ?? "https://executor.sh", | |
| 210 | + ), | |
| 185 | 211 | "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? "development"), |
| 186 | 212 | }, |
| 187 | 213 | resolve: { |
| @@ -95,6 +95,18 @@ export { · 94 unmodified lines | ||
| 95 | 95 | type MakeAccountApiLayerOptions, |
| 96 | 96 | type ApiHandler, |
| 97 | 97 | } from "./server/host-foundation"; |
| 98 | +export { | |
| 99 | + makeOAuthClientIdMetadataRoute, | |
| 100 | + oauthClientIdMetadataDocumentFromRequest, | |
| 101 | + oauthClientIdMetadataDocumentPath, | |
| 102 | + oauthClientIdMetadataDocumentTargetPath, | |
| 103 | + oauthClientIdMetadataDocumentUrlFromRequest, | |
| 104 | + OAUTH_CLIENT_ID_METADATA_DOCUMENT_BASE_PATH, | |
| 105 | + OAUTH_CLIENT_ID_METADATA_DOCUMENT_DEFAULT_TARGET, | |
| 106 | + OAUTH_CLIENT_ID_METADATA_DOCUMENT_LOCAL_TARGET, | |
| 107 | + OAUTH_CLIENT_ID_METADATA_DOCUMENT_PATH, | |
| 108 | + OAUTH_CLIENT_ID_METADATA_DOCUMENT_TARGET_PATH_PREFIX, | |
| 109 | +} from "./server/oauth-client-metadata"; | |
| 98 | 110 | export * as ExecutorApp from "./server/executor-app"; |
| 99 | 111 | export type { |
| 100 | 112 | ExecutorAppOptions, |
| @@ -74,6 +74,7 @@ import { · 73 unmodified lines | ||
| 74 | 74 | toApiHandler, |
| 75 | 75 | type ApiHandler, |
| 76 | 76 | } from "./host-foundation"; |
| 77 | +import { makeOAuthClientIdMetadataRoute } from "./oauth-client-metadata"; | |
| 77 | 78 | |
| 78 | 79 | // A fully-resolved route/app Layer with its channels erased. Used at the |
| 79 | 80 | // assembly boundaries (mirrors `toApiHandler`'s loose typing): each host's |
| @@ -562,7 +563,10 @@ export const make = < · 482 unmodified lines | ||
| 562 | 563 | // `provideMerge(boot)` resolves the seams' residual requirements (the |
| 563 | 564 | // long-lived DB handle, the control-plane services); the runtime contract is |
| 564 | 565 | // the same — every route requirement is provided. |
| 565 | − const routeLayers: AppRouteLayer[] = [apiLive]; | |
| 566 | + const routeLayers: AppRouteLayer[] = [ | |
| 567 | + makeOAuthClientIdMetadataRoute(config.mountPrefix) as AppRouteLayer, | |
| 568 | + apiLive, | |
| 569 | + ]; | |
| 566 | 570 | if (mcpRouteLive) routeLayers.push(mcpRouteLive); |
| 567 | 571 | for (const route of extensionRoutes) routeLayers.push(route); |
| 568 | 572 | |
| @@ -0,0 +1,84 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | + | |
| 3 | +import { oauthClientIdMetadataDocumentFromRequest } from "./oauth-client-metadata"; | |
| 4 | + | |
| 5 | +describe("OAuth client ID metadata document", () => { | |
| 6 | + it("builds an org-scoped hosted document from a target path", () => { | |
| 7 | + const metadata = oauthClientIdMetadataDocumentFromRequest({ | |
| 8 | + requestUrl: "/api/oauth/client-id-metadata/acme.json", | |
| 9 | + webRequest: new Request("http://127.0.0.1:42384/api/oauth/client-id-metadata/acme.json", { | |
| 10 | + headers: { Host: "100.81.219.45:42384" }, | |
| 11 | + }), | |
| 12 | + mountPrefix: "/api", | |
| 13 | + }); | |
| 14 | + | |
| 15 | + expect(metadata.client_id).toBe( | |
| 16 | + "http://100.81.219.45:42384/api/oauth/client-id-metadata/acme.json", | |
| 17 | + ); | |
| 18 | + expect(metadata.redirect_uris).toEqual([ | |
| 19 | + "http://100.81.219.45:42384/api/oauth/callback?executor_org=acme", | |
| 20 | + ]); | |
| 21 | + expect(metadata.token_endpoint_auth_method).toBe("none"); | |
| 22 | + expect(metadata.application_type).toBe("web"); | |
| 23 | + }); | |
| 24 | + | |
| 25 | + it("uses forwarded host and protocol for proxy-facing hosted documents", () => { | |
| 26 | + const metadata = oauthClientIdMetadataDocumentFromRequest({ | |
| 27 | + requestUrl: "/api/oauth/client-id-metadata/default.json", | |
| 28 | + webRequest: new Request("http://127.0.0.1:3000/api/oauth/client-id-metadata/default.json", { | |
| 29 | + headers: { | |
| 30 | + "x-forwarded-host": "wsl-dev.tail5665af.ts.net", | |
| 31 | + "x-forwarded-proto": "https", | |
| 32 | + Host: "127.0.0.1:3000", | |
| 33 | + }, | |
| 34 | + }), | |
| 35 | + mountPrefix: "/api", | |
| 36 | + }); | |
| 37 | + | |
| 38 | + expect(metadata.client_id).toBe( | |
| 39 | + "https://wsl-dev.tail5665af.ts.net/api/oauth/client-id-metadata/default.json", | |
| 40 | + ); | |
| 41 | + expect(metadata.redirect_uris).toEqual([ | |
| 42 | + "https://wsl-dev.tail5665af.ts.net/api/oauth/callback", | |
| 43 | + ]); | |
| 44 | + }); | |
| 45 | + | |
| 46 | + it("serves a hosted local document with portless loopback callbacks", () => { | |
| 47 | + const metadata = oauthClientIdMetadataDocumentFromRequest({ | |
| 48 | + requestUrl: "/api/oauth/client-id-metadata/local.json", | |
| 49 | + webRequest: new Request("http://127.0.0.1:3000/api/oauth/client-id-metadata/local.json", { | |
| 50 | + headers: { | |
| 51 | + "x-forwarded-host": "executor.sh", | |
| 52 | + "x-forwarded-proto": "https", | |
| 53 | + Host: "127.0.0.1:3000", | |
| 54 | + }, | |
| 55 | + }), | |
| 56 | + mountPrefix: "/api", | |
| 57 | + }); | |
| 58 | + | |
| 59 | + expect(metadata.client_id).toBe("https://executor.sh/api/oauth/client-id-metadata/local.json"); | |
| 60 | + expect(metadata.redirect_uris).toEqual([ | |
| 61 | + "http://127.0.0.1/api/oauth/callback", | |
| 62 | + "http://localhost/api/oauth/callback", | |
| 63 | + "http://[::1]/api/oauth/callback", | |
| 64 | + ]); | |
| 65 | + expect(metadata.application_type).toBe("native"); | |
| 66 | + }); | |
| 67 | + | |
| 68 | + it("still reads the org selector from the legacy query form", () => { | |
| 69 | + const metadata = oauthClientIdMetadataDocumentFromRequest({ | |
| 70 | + requestUrl: "/api/oauth/client-id-metadata.json?executor_org=acme", | |
| 71 | + webRequest: new Request("http://127.0.0.1:42384/api/oauth/client-id-metadata.json", { | |
| 72 | + headers: { Host: "100.81.219.45:42384" }, | |
| 73 | + }), | |
| 74 | + mountPrefix: "/api", | |
| 75 | + }); | |
| 76 | + | |
| 77 | + expect(metadata.client_id).toBe( | |
| 78 | + "http://100.81.219.45:42384/api/oauth/client-id-metadata.json?executor_org=acme", | |
| 79 | + ); | |
| 80 | + expect(metadata.redirect_uris).toEqual([ | |
| 81 | + "http://100.81.219.45:42384/api/oauth/callback?executor_org=acme", | |
| 82 | + ]); | |
| 83 | + }); | |
| 84 | +}); | |
| @@ -0,0 +1,193 @@ | ||
| 1 | +import { Effect, Layer, Option } from "effect"; | |
| 2 | +import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; | |
| 3 | + | |
| 4 | +export const OAUTH_CLIENT_ID_METADATA_DOCUMENT_BASE_PATH = "/oauth/client-id-metadata" as const; | |
| 5 | +export const OAUTH_CLIENT_ID_METADATA_DOCUMENT_PATH = | |
| 6 | + `${OAUTH_CLIENT_ID_METADATA_DOCUMENT_BASE_PATH}.json` as const; | |
| 7 | +export const OAUTH_CLIENT_ID_METADATA_DOCUMENT_TARGET_PATH_PREFIX = | |
| 8 | + `${OAUTH_CLIENT_ID_METADATA_DOCUMENT_BASE_PATH}/` as const; | |
| 9 | +export const OAUTH_CLIENT_ID_METADATA_DOCUMENT_DEFAULT_TARGET = "default" as const; | |
| 10 | +export const OAUTH_CLIENT_ID_METADATA_DOCUMENT_LOCAL_TARGET = "local" as const; | |
| 11 | +const OAUTH_CALLBACK_ORG_QUERY_PARAM = "executor_org" as const; | |
| 12 | + | |
| 13 | +type MetadataTarget = | |
| 14 | + | typeof OAUTH_CLIENT_ID_METADATA_DOCUMENT_DEFAULT_TARGET | |
| 15 | + | typeof OAUTH_CLIENT_ID_METADATA_DOCUMENT_LOCAL_TARGET | |
| 16 | + | string; | |
| 17 | + | |
| 18 | +interface OAuthClientIdMetadataDocument { | |
| 19 | + readonly client_id: string; | |
| 20 | + readonly client_name: string; | |
| 21 | + readonly client_uri: string; | |
| 22 | + readonly redirect_uris: readonly string[]; | |
| 23 | + readonly grant_types: readonly ["authorization_code"]; | |
| 24 | + readonly response_types: readonly ["code"]; | |
| 25 | + readonly token_endpoint_auth_method: "none"; | |
| 26 | + readonly application_type: "web" | "native"; | |
| 27 | +} | |
| 28 | + | |
| 29 | +const pathWithMountPrefix = (mountPrefix?: string): `/${string}` => | |
| 30 | + `${mountPrefix ?? ""}${OAUTH_CLIENT_ID_METADATA_DOCUMENT_PATH}` as `/${string}`; | |
| 31 | + | |
| 32 | +const targetPathWithMountPrefix = (mountPrefix?: string): `/${string}` => | |
| 33 | + `${mountPrefix ?? ""}${OAUTH_CLIENT_ID_METADATA_DOCUMENT_TARGET_PATH_PREFIX}*` as `/${string}`; | |
| 34 | + | |
| 35 | +const callbackPathWithMountPrefix = (mountPrefix?: string): `/${string}` => | |
| 36 | + `${mountPrefix ?? ""}/oauth/callback` as `/${string}`; | |
| 37 | + | |
| 38 | +export const oauthClientIdMetadataDocumentPath = (mountPrefix?: string): `/${string}` => | |
| 39 | + pathWithMountPrefix(mountPrefix); | |
| 40 | + | |
| 41 | +export const oauthClientIdMetadataDocumentTargetPath = ( | |
| 42 | + target: MetadataTarget, | |
| 43 | + mountPrefix?: string, | |
| 44 | +): `/${string}` => | |
| 45 | + `${mountPrefix ?? ""}${OAUTH_CLIENT_ID_METADATA_DOCUMENT_TARGET_PATH_PREFIX}${encodeURIComponent( | |
| 46 | + target, | |
| 47 | + )}.json` as `/${string}`; | |
| 48 | + | |
| 49 | +const firstForwardedHeaderValue = (headers: Headers, name: string): string | undefined => { | |
| 50 | + const value = headers.get(name)?.split(",")[0]?.trim(); | |
| 51 | + return value ? value : undefined; | |
| 52 | +}; | |
| 53 | + | |
| 54 | +const parseAbsoluteUrl = (value: string): URL | undefined => { | |
| 55 | + if (!/^[a-z][a-z\d+.-]*:\/\//i.test(value)) return undefined; | |
| 56 | + return new URL(value); | |
| 57 | +}; | |
| 58 | +const decodeUriComponentOption = Option.liftThrowable(decodeURIComponent); | |
| 59 | + | |
| 60 | +const pathSearchAndHash = (value: string, fallbackPath: `/${string}`): string => { | |
| 61 | + const parsed = parseAbsoluteUrl(value); | |
| 62 | + if (parsed) return `${parsed.pathname}${parsed.search}${parsed.hash}`; | |
| 63 | + return value.startsWith("/") ? value : fallbackPath; | |
| 64 | +}; | |
| 65 | + | |
| 66 | +const metadataTargetFromPath = ({ | |
| 67 | + pathname, | |
| 68 | + mountPrefix, | |
| 69 | +}: { | |
| 70 | + readonly pathname: string; | |
| 71 | + readonly mountPrefix?: string; | |
| 72 | +}): MetadataTarget | undefined => { | |
| 73 | + const prefix = `${mountPrefix ?? ""}${OAUTH_CLIENT_ID_METADATA_DOCUMENT_TARGET_PATH_PREFIX}`; | |
| 74 | + if (!pathname.startsWith(prefix) || !pathname.endsWith(".json")) return undefined; | |
| 75 | + const encoded = pathname.slice(prefix.length, -".json".length); | |
| 76 | + if (!encoded || encoded.includes("/")) return undefined; | |
| 77 | + const target = Option.getOrUndefined( | |
| 78 | + Option.map(decodeUriComponentOption(encoded), (value) => value.trim()), | |
| 79 | + ); | |
| 80 | + return target ? target : undefined; | |
| 81 | +}; | |
| 82 | + | |
| 83 | +const orgSlugFromMetadataTarget = (target: MetadataTarget | undefined): string | undefined => { | |
| 84 | + if (!target) return undefined; | |
| 85 | + if ( | |
| 86 | + target === OAUTH_CLIENT_ID_METADATA_DOCUMENT_DEFAULT_TARGET || | |
| 87 | + target === OAUTH_CLIENT_ID_METADATA_DOCUMENT_LOCAL_TARGET | |
| 88 | + ) { | |
| 89 | + return undefined; | |
| 90 | + } | |
| 91 | + return target; | |
| 92 | +}; | |
| 93 | + | |
| 94 | +export const oauthClientIdMetadataDocumentUrlFromRequest = ({ | |
| 95 | + requestUrl, | |
| 96 | + webRequest, | |
| 97 | + mountPrefix, | |
| 98 | +}: { | |
| 99 | + readonly requestUrl: string; | |
| 100 | + readonly webRequest: Request; | |
| 101 | + readonly mountPrefix?: string; | |
| 102 | +}): URL => { | |
| 103 | + const requestAbsoluteUrl = parseAbsoluteUrl(requestUrl) ?? parseAbsoluteUrl(webRequest.url); | |
| 104 | + const proto = | |
| 105 | + firstForwardedHeaderValue(webRequest.headers, "x-forwarded-proto") ?? | |
| 106 | + requestAbsoluteUrl?.protocol.replace(/:$/, "") ?? | |
| 107 | + "http"; | |
| 108 | + const host = | |
| 109 | + firstForwardedHeaderValue(webRequest.headers, "x-forwarded-host") ?? | |
| 110 | + firstForwardedHeaderValue(webRequest.headers, "host") ?? | |
| 111 | + requestAbsoluteUrl?.host ?? | |
| 112 | + "localhost"; | |
| 113 | + const origin = `${proto}://${host}`; | |
| 114 | + return new URL(pathSearchAndHash(requestUrl, pathWithMountPrefix(mountPrefix)), origin); | |
| 115 | +}; | |
| 116 | + | |
| 117 | +const localLoopbackRedirectUris = (mountPrefix?: string): readonly string[] => { | |
| 118 | + const callbackPath = callbackPathWithMountPrefix(mountPrefix); | |
| 119 | + return [ | |
| 120 | + new URL(callbackPath, "http://127.0.0.1").toString(), | |
| 121 | + new URL(callbackPath, "http://localhost").toString(), | |
| 122 | + new URL(callbackPath, "http://[::1]").toString(), | |
| 123 | + ]; | |
| 124 | +}; | |
| 125 | + | |
| 126 | +export const oauthClientIdMetadataDocumentFromRequest = ({ | |
| 127 | + requestUrl, | |
| 128 | + webRequest, | |
| 129 | + mountPrefix, | |
| 130 | +}: { | |
| 131 | + readonly requestUrl: string; | |
| 132 | + readonly webRequest: Request; | |
| 133 | + readonly mountPrefix?: string; | |
| 134 | +}): OAuthClientIdMetadataDocument => { | |
| 135 | + const url = oauthClientIdMetadataDocumentUrlFromRequest({ requestUrl, webRequest, mountPrefix }); | |
| 136 | + const target = metadataTargetFromPath({ pathname: url.pathname, mountPrefix }); | |
| 137 | + | |
| 138 | + if (target === OAUTH_CLIENT_ID_METADATA_DOCUMENT_LOCAL_TARGET) { | |
| 139 | + return { | |
| 140 | + client_id: url.toString(), | |
| 141 | + client_name: "Executor Local", | |
| 142 | + client_uri: url.origin, | |
| 143 | + redirect_uris: localLoopbackRedirectUris(mountPrefix), | |
| 144 | + grant_types: ["authorization_code"], | |
| 145 | + response_types: ["code"], | |
| 146 | + token_endpoint_auth_method: "none", | |
| 147 | + application_type: "native", | |
| 148 | + }; | |
| 149 | + } | |
| 150 | + | |
| 151 | + const redirectUri = new URL(callbackPathWithMountPrefix(mountPrefix), url.origin); | |
| 152 | + const orgSlug = | |
| 153 | + orgSlugFromMetadataTarget(target) ?? | |
| 154 | + url.searchParams.get(OAUTH_CALLBACK_ORG_QUERY_PARAM)?.trim(); | |
| 155 | + if (orgSlug) redirectUri.searchParams.set(OAUTH_CALLBACK_ORG_QUERY_PARAM, orgSlug); | |
| 156 | + | |
| 157 | + return { | |
| 158 | + client_id: url.toString(), | |
| 159 | + client_name: "Executor", | |
| 160 | + client_uri: url.origin, | |
| 161 | + redirect_uris: [redirectUri.toString()], | |
| 162 | + grant_types: ["authorization_code"], | |
| 163 | + response_types: ["code"], | |
| 164 | + token_endpoint_auth_method: "none", | |
| 165 | + application_type: "web", | |
| 166 | + }; | |
| 167 | +}; | |
| 168 | + | |
| 169 | +export const makeOAuthClientIdMetadataRoute = ( | |
| 170 | + mountPrefix?: string, | |
| 171 | +): Layer.Layer<never, never, HttpRouter.HttpRouter> => { | |
| 172 | + const handler = Effect.gen(function* () { | |
| 173 | + const request = yield* HttpServerRequest.HttpServerRequest; | |
| 174 | + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: HTTP router request conversion failure is an edge defect, not recoverable route input | |
| 175 | + const webRequest = yield* HttpServerRequest.toWeb(request).pipe(Effect.orDie); | |
| 176 | + const metadata = oauthClientIdMetadataDocumentFromRequest({ | |
| 177 | + requestUrl: request.url, | |
| 178 | + webRequest, | |
| 179 | + mountPrefix, | |
| 180 | + }); | |
| 181 | + | |
| 182 | + return HttpServerResponse.jsonUnsafe(metadata, { | |
| 183 | + headers: { | |
| 184 | + "cache-control": "public, max-age=300", | |
| 185 | + }, | |
| 186 | + }); | |
| 187 | + }); | |
| 188 | + | |
| 189 | + return Layer.mergeAll( | |
| 190 | + HttpRouter.add("GET", pathWithMountPrefix(mountPrefix), handler), | |
| 191 | + HttpRouter.add("GET", targetPathWithMountPrefix(mountPrefix), handler), | |
| 192 | + ); | |
| 193 | +}; | |
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | import { afterEach, describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | |
| 3 | −import { oauthCallbackUrl } from "./oauth-sign-in"; | |
| 3 | +import { oauthCallbackUrl, oauthClientIdMetadataDocumentUrl } from "./oauth-sign-in"; | |
| 4 | 4 | |
| 5 | 5 | const originalWindow = globalThis.window; |
| 6 | 6 | |
| @@ -22,6 +22,32 @@ afterEach(() => { · 15 unmodified lines | ||
| 22 | 22 | Reflect.deleteProperty(globalThis, "window"); |
| 23 | 23 | }); |
| 24 | 24 | |
| 25 | +describe("oauthClientIdMetadataDocumentUrl", () => { | |
| 26 | + it("returns a relative default metadata path outside the browser", () => { | |
| 27 | + Reflect.deleteProperty(globalThis, "window"); | |
| 28 | + expect(oauthClientIdMetadataDocumentUrl()).toBe("/api/oauth/client-id-metadata/default.json"); | |
| 29 | + }); | |
| 30 | + | |
| 31 | + it("carries the active org slug from the console URL", () => { | |
| 32 | + setLocation("https://executor.sh/acme/integrations/posthog"); | |
| 33 | + | |
| 34 | + const url = new URL(oauthClientIdMetadataDocumentUrl()); | |
| 35 | + | |
| 36 | + expect(url.toString()).toBe("https://executor.sh/api/oauth/client-id-metadata/acme.json"); | |
| 37 | + expect(url.search).toBe(""); | |
| 38 | + }); | |
| 39 | + | |
| 40 | + it("uses the hosted local document when configured", () => { | |
| 41 | + setLocation("http://localhost:4788/integrations/posthog"); | |
| 42 | + | |
| 43 | + expect( | |
| 44 | + oauthClientIdMetadataDocumentUrl({ | |
| 45 | + hostedBaseUrl: "https://executor.sh", | |
| 46 | + }), | |
| 47 | + ).toBe("https://executor.sh/api/oauth/client-id-metadata/local.json"); | |
| 48 | + }); | |
| 49 | +}); | |
| 50 | + | |
| 25 | 51 | describe("oauthCallbackUrl", () => { |
| 26 | 52 | it("returns a relative callback path outside the browser", () => { |
| 27 | 53 | Reflect.deleteProperty(globalThis, "window"); |
| @@ -14,6 +14,7 @@ import { · 13 unmodified lines | ||
| 14 | 14 | type OAuthPopupResult, |
| 15 | 15 | } from "../api/oauth-popup"; |
| 16 | 16 | import { connectionWriteKeys } from "../api/reactivity-keys"; |
| 17 | +import { getActiveOrgSlug } from "../api/server-connection"; | |
| 17 | 18 | |
| 18 | 19 | type DesktopBridge = { |
| 19 | 20 | readonly openExternal: (url: string) => Promise<void>; |
| @@ -100,6 +101,39 @@ export function oauthCallbackUrl(path = "/api/oauth/callback"): string { · 80 unmodified lines | ||
| 100 | 101 | return new URL(path, window.location.origin).toString(); |
| 101 | 102 | } |
| 102 | 103 | |
| 104 | +export const OAUTH_CLIENT_ID_METADATA_DOCUMENT_PATH = "/api/oauth/client-id-metadata/default.json"; | |
| 105 | +export const OAUTH_CLIENT_ID_METADATA_DOCUMENT_LOCAL_PATH = | |
| 106 | + "/api/oauth/client-id-metadata/local.json"; | |
| 107 | + | |
| 108 | +const configuredClientIdMetadataBaseUrl = (): string | undefined => { | |
| 109 | + const env = ( | |
| 110 | + import.meta as ImportMeta & { | |
| 111 | + readonly env?: Record<string, string | undefined>; | |
| 112 | + } | |
| 113 | + ).env; | |
| 114 | + const value = env?.VITE_EXECUTOR_CIMD_CLIENT_ID_METADATA_BASE_URL?.trim(); | |
| 115 | + return value ? value : undefined; | |
| 116 | +}; | |
| 117 | + | |
| 118 | +export function oauthClientIdMetadataDocumentUrl(options?: { | |
| 119 | + readonly hostedBaseUrl?: string | null; | |
| 120 | + readonly path?: string; | |
| 121 | +}): string { | |
| 122 | + const hostedBaseUrl = options?.hostedBaseUrl ?? configuredClientIdMetadataBaseUrl(); | |
| 123 | + if (hostedBaseUrl) { | |
| 124 | + return new URL(OAUTH_CLIENT_ID_METADATA_DOCUMENT_LOCAL_PATH, hostedBaseUrl).toString(); | |
| 125 | + } | |
| 126 | + | |
| 127 | + const defaultPath = options?.path ?? OAUTH_CLIENT_ID_METADATA_DOCUMENT_PATH; | |
| 128 | + if (typeof window === "undefined") return defaultPath; | |
| 129 | + const orgSlug = getActiveOrgSlug(); | |
| 130 | + const metadataPath = orgSlug | |
| 131 | + ? `/api/oauth/client-id-metadata/${encodeURIComponent(orgSlug)}.json` | |
| 132 | + : defaultPath; | |
| 133 | + const url = new URL(metadataPath, window.location.origin); | |
| 134 | + return url.toString(); | |
| 135 | +} | |
| 136 | + | |
| 103 | 137 | export function useOAuthPopupFlow< |
| 104 | 138 | TPayload extends OAuthCompletionPayload = OAuthCompletionPayload, |
| 105 | 139 | >(options: { |