Unified · Split
| @@ -13,6 +13,7 @@ describe("isAppOwnedPath", () => { · 12 unmodified lines | |||
| 13 | "/api/executions", | 13 | "/api/executions", |
| 14 | "/api/auth/me", | 14 | "/api/auth/me", |
| 15 | "/api/openapi.json", | 15 | "/api/openapi.json", |
| 16 | "/api/oauth/client-id-metadata/default.json", | ||
| 16 | "/api/billing/customer", // AutumnProvider pathPrefix — the billing UI | 17 | "/api/billing/customer", // AutumnProvider pathPrefix — the billing UI |
| 17 | "/api/billing/attach", | 18 | "/api/billing/attach", |
| 18 | "/api/docs", // Swagger UI | 19 | "/api/docs", // Swagger UI |
| @@ -54,6 +54,9 @@ export default defineConfig({ · 53 unmodified lines | |||
| 54 | process.env.npm_package_version ?? "0.0.0", | 54 | process.env.npm_package_version ?? "0.0.0", |
| 55 | ), | 55 | ), |
| 56 | "import.meta.env.VITE_GITHUB_URL": JSON.stringify("https://github.com/RhysSullivan/executor"), | 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 | resolve: { | 61 | resolve: { |
| 59 | alias: { | 62 | alias: { |
| @@ -4,7 +4,9 @@ import { · 3 unmodified lines | |||
| 4 | DEFAULT_ALLOWED_HOSTS, | 4 | DEFAULT_ALLOWED_HOSTS, |
| 5 | hasBearerToken, | 5 | hasBearerToken, |
| 6 | isAllowedOrigin, | 6 | isAllowedOrigin, |
| 7 | isUnauthenticatedOAuthClientMetadataPath, | ||
| 7 | isUnauthenticatedOAuthCallbackPath, | 8 | isUnauthenticatedOAuthCallbackPath, |
| 9 | isUnauthenticatedOAuthPath, | ||
| 8 | makeIsAuthorized, | 10 | makeIsAuthorized, |
| 9 | } from "./serve-shared"; | 11 | } from "./serve-shared"; |
| 10 | 12 | ||
| @@ -49,3 +51,24 @@ describe("isUnauthenticatedOAuthCallbackPath", () => { · 38 unmodified lines | |||
| 49 | expect(isUnauthenticatedOAuthCallbackPath("/api/scope")).toBe(false); | 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 | export const isUnauthenticatedOAuthCallbackPath = (pathname: string): boolean => | 87 | export const isUnauthenticatedOAuthCallbackPath = (pathname: string): boolean => |
| 88 | /^\/api\/oauth\/callback(\/|$)/.test(pathname); | 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 | expect(response.status).toBe(200); | 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 | it("requires the bearer token on the OAuth await poll", async () => { | 156 | it("requires the bearer token on the OAuth await poll", async () => { |
| 138 | const baseUrl = await startTestServer(); | 157 | const baseUrl = await startTestServer(); |
| 139 | const response = await fetch(`${baseUrl}/api/oauth/await/session-1`); | 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 | import { readdirSync } from "node:fs"; | 11 | import { readdirSync } from "node:fs"; |
| 12 | import type { Subprocess } from "bun"; | 12 | import type { Subprocess } from "bun"; |
| 13 | import { setOAuthCompletionListener } from "@executor-js/api"; | 13 | import { setOAuthCompletionListener } from "@executor-js/api"; |
| 14 | import { oauthClientIdMetadataDocumentFromRequest } from "@executor-js/api/server"; | ||
| 14 | import { loadOrMintLocalAuthToken } from "./auth"; | 15 | import { loadOrMintLocalAuthToken } from "./auth"; |
| 15 | import { consumeOAuthResult, publishOAuthResult } from "./oauth-result-store"; | 16 | import { consumeOAuthResult, publishOAuthResult } from "./oauth-result-store"; |
| 16 | import { startIntegrationsRefresh } from "./integrations"; | 17 | import { startIntegrationsRefresh } from "./integrations"; |
| @@ -19,7 +20,8 @@ import { · 2 unmodified lines | |||
| 19 | DEFAULT_ALLOWED_HOSTS, | 20 | DEFAULT_ALLOWED_HOSTS, |
| 20 | hasFileExtension, | 21 | hasFileExtension, |
| 21 | isAllowedOrigin, | 22 | isAllowedOrigin, |
| 22 | isUnauthenticatedOAuthCallbackPath, | 23 | isUnauthenticatedOAuthClientMetadataPath, |
| 24 | isUnauthenticatedOAuthPath, | ||
| 23 | makeIsAuthorized, | 25 | makeIsAuthorized, |
| 24 | normalizeCredential, | 26 | normalizeCredential, |
| 25 | } from "./serve-shared"; | 27 | } from "./serve-shared"; |
| @@ -35,6 +37,23 @@ const htmlResponse = (file: Bun.BunFile): Response => · 9 unmodified lines | |||
| 35 | headers: { "content-type": "text/html", "cache-control": "no-store" }, | 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 | function collectStaticRoutes(dir: string, prefix = ""): Record<string, StaticHandler> { | 57 | function collectStaticRoutes(dir: string, prefix = ""): Record<string, StaticHandler> { |
| 39 | const routes: Record<string, StaticHandler> = {}; | 58 | const routes: Record<string, StaticHandler> = {}; |
| 40 | // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: filesystem route discovery is best-effort for optional built assets | 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 | return withCors(new Response("ok", { headers: { "content-type": "text/plain" } })); | 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 | 361 | // OAuth callbacks and CIMD documents are reached by the external |
| 343 | // can't carry our bearer. The OAuth `state` parameter is the security | 362 | // provider, which cannot carry our local bearer. Everything else under |
| 344 | // gate — see isUnauthenticatedOAuthCallbackPath. Everything else under | ||
| 345 | // /api and /mcp requires the bearer. | 363 | // /api and /mcp requires the bearer. |
| 346 | const skipAuth = isUnauthenticatedOAuthCallbackPath(url.pathname); | 364 | const skipAuth = isUnauthenticatedOAuthPath(url.pathname); |
| 347 | const isGatedSurface = url.pathname.startsWith("/api") || url.pathname.startsWith("/mcp"); | 365 | const isGatedSurface = url.pathname.startsWith("/api") || url.pathname.startsWith("/mcp"); |
| 348 | 366 | ||
| 349 | if (isGatedSurface && !skipAuth && !isAuthorized(req)) { | 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 | if (url.pathname.startsWith("/mcp")) { | 380 | if (url.pathname.startsWith("/mcp")) { |
| 359 | return withCors(await handlers.mcp.handleRequest(req)); | 381 | return withCors(await handlers.mcp.handleRequest(req)); |
| 360 | } | 382 | } |
| @@ -4,9 +4,14 @@ import { Readable } from "node:stream"; · 3 unmodified lines | |||
| 4 | import { fileURLToPath } from "node:url"; | 4 | import { fileURLToPath } from "node:url"; |
| 5 | import { defineConfig, type Plugin } from "vite"; | 5 | import { defineConfig, type Plugin } from "vite"; |
| 6 | import appPlugin from "@executor-js/app/vite"; | 6 | import appPlugin from "@executor-js/app/vite"; |
| 7 | import { oauthClientIdMetadataDocumentFromRequest } from "@executor-js/api/server"; | ||
| 7 | import { loadOrMintLocalAuthToken } from "./src/auth"; | 8 | import { loadOrMintLocalAuthToken } from "./src/auth"; |
| 8 | import { consumeOAuthResult } from "./src/oauth-result-store"; | 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 | // oxlint-disable-next-line executor/no-json-parse -- boundary: Vite config reads package metadata from package.json | 16 | // oxlint-disable-next-line executor/no-json-parse -- boundary: Vite config reads package metadata from package.json |
| 12 | const rootPackage = JSON.parse( | 17 | const rootPackage = JSON.parse( |
| @@ -33,6 +38,23 @@ const EXECUTOR_GITHUB_URL = ( · 20 unmodified lines | |||
| 33 | const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url)); | 38 | const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url)); |
| 34 | const APP_ROOT = fileURLToPath(new URL("../../packages/app/", import.meta.url)); | 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 | * Vite plugin that forwards /api and /mcp requests to the Effect handlers | 59 | * Vite plugin that forwards /api and /mcp requests to the Effect handlers |
| 38 | * during development, so you don't need a separate server process. | 60 | * during development, so you don't need a separate server process. |
| @@ -85,8 +107,7 @@ function executorApiPlugin(): Plugin { · 46 unmodified lines | |||
| 85 | // bootstrap, so the UI is unaffected; external MCP clients use the | 107 | // bootstrap, so the UI is unaffected; external MCP clients use the |
| 86 | // daemon port. | 108 | // daemon port. |
| 87 | const pathOnly = rawUrl.split("?")[0] ?? "/"; | 109 | const pathOnly = rawUrl.split("?")[0] ?? "/"; |
| 88 | const authExempt = | 110 | const authExempt = pathOnly === "/api/health" || isUnauthenticatedOAuthPath(pathOnly); |
| 89 | pathOnly === "/api/health" || isUnauthenticatedOAuthCallbackPath(pathOnly); | ||
| 90 | if (!authExempt) { | 111 | if (!authExempt) { |
| 91 | const presented = req.headers.authorization; | 112 | const presented = req.headers.authorization; |
| 92 | const authValue = Array.isArray(presented) ? presented[0] : presented; | 113 | const authValue = Array.isArray(presented) ? presented[0] : presented; |
| @@ -125,7 +146,9 @@ function executorApiPlugin(): Plugin { · 32 unmodified lines | |||
| 125 | } as RequestInit); | 146 | } as RequestInit); |
| 126 | 147 | ||
| 127 | let response: Response; | 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 | response = await handlers.mcp.handleRequest(webRequest(rawUrl)); | 152 | response = await handlers.mcp.handleRequest(webRequest(rawUrl)); |
| 130 | } else if (pathOnly === "/api/health" && req.method === "GET") { | 153 | } else if (pathOnly === "/api/health" && req.method === "GET") { |
| 131 | response = new Response("ok", { headers: { "content-type": "text/plain" } }); | 154 | response = new Response("ok", { headers: { "content-type": "text/plain" } }); |
| @@ -182,6 +205,9 @@ export default defineConfig({ · 50 unmodified lines | |||
| 182 | "import.meta.env.VITE_APP_VERSION": JSON.stringify(EXECUTOR_VERSION), | 205 | "import.meta.env.VITE_APP_VERSION": JSON.stringify(EXECUTOR_VERSION), |
| 183 | "import.meta.env.VITE_GITHUB_URL": JSON.stringify(EXECUTOR_GITHUB_URL), | 206 | "import.meta.env.VITE_GITHUB_URL": JSON.stringify(EXECUTOR_GITHUB_URL), |
| 184 | "import.meta.env.VITE_EXECUTOR_DEV_CLI_CWD": JSON.stringify(REPO_ROOT), | 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 | "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? "development"), | 211 | "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? "development"), |
| 186 | }, | 212 | }, |
| 187 | resolve: { | 213 | resolve: { |
| @@ -95,6 +95,18 @@ export { · 94 unmodified lines | |||
| 95 | type MakeAccountApiLayerOptions, | 95 | type MakeAccountApiLayerOptions, |
| 96 | type ApiHandler, | 96 | type ApiHandler, |
| 97 | } from "./server/host-foundation"; | 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 | export * as ExecutorApp from "./server/executor-app"; | 110 | export * as ExecutorApp from "./server/executor-app"; |
| 99 | export type { | 111 | export type { |
| 100 | ExecutorAppOptions, | 112 | ExecutorAppOptions, |
| @@ -74,6 +74,7 @@ import { · 73 unmodified lines | |||
| 74 | toApiHandler, | 74 | toApiHandler, |
| 75 | type ApiHandler, | 75 | type ApiHandler, |
| 76 | } from "./host-foundation"; | 76 | } from "./host-foundation"; |
| 77 | import { makeOAuthClientIdMetadataRoute } from "./oauth-client-metadata"; | ||
| 77 | 78 | ||
| 78 | // A fully-resolved route/app Layer with its channels erased. Used at the | 79 | // A fully-resolved route/app Layer with its channels erased. Used at the |
| 79 | // assembly boundaries (mirrors `toApiHandler`'s loose typing): each host's | 80 | // assembly boundaries (mirrors `toApiHandler`'s loose typing): each host's |
| @@ -562,7 +563,10 @@ export const make = < · 482 unmodified lines | |||
| 562 | // `provideMerge(boot)` resolves the seams' residual requirements (the | 563 | // `provideMerge(boot)` resolves the seams' residual requirements (the |
| 563 | // long-lived DB handle, the control-plane services); the runtime contract is | 564 | // long-lived DB handle, the control-plane services); the runtime contract is |
| 564 | // the same — every route requirement is provided. | 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 | if (mcpRouteLive) routeLayers.push(mcpRouteLive); | 570 | if (mcpRouteLive) routeLayers.push(mcpRouteLive); |
| 567 | for (const route of extensionRoutes) routeLayers.push(route); | 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 | import { afterEach, describe, expect, it } from "@effect/vitest"; | 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 | const originalWindow = globalThis.window; | 5 | const originalWindow = globalThis.window; |
| 6 | 6 | ||
| @@ -22,6 +22,32 @@ afterEach(() => { · 15 unmodified lines | |||
| 22 | Reflect.deleteProperty(globalThis, "window"); | 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 | describe("oauthCallbackUrl", () => { | 51 | describe("oauthCallbackUrl", () => { |
| 26 | it("returns a relative callback path outside the browser", () => { | 52 | it("returns a relative callback path outside the browser", () => { |
| 27 | Reflect.deleteProperty(globalThis, "window"); | 53 | Reflect.deleteProperty(globalThis, "window"); |
| @@ -14,6 +14,7 @@ import { · 13 unmodified lines | |||
| 14 | type OAuthPopupResult, | 14 | type OAuthPopupResult, |
| 15 | } from "../api/oauth-popup"; | 15 | } from "../api/oauth-popup"; |
| 16 | import { connectionWriteKeys } from "../api/reactivity-keys"; | 16 | import { connectionWriteKeys } from "../api/reactivity-keys"; |
| 17 | import { getActiveOrgSlug } from "../api/server-connection"; | ||
| 17 | 18 | ||
| 18 | type DesktopBridge = { | 19 | type DesktopBridge = { |
| 19 | readonly openExternal: (url: string) => Promise<void>; | 20 | readonly openExternal: (url: string) => Promise<void>; |
| @@ -100,6 +101,39 @@ export function oauthCallbackUrl(path = "/api/oauth/callback"): string { · 80 unmodified lines | |||
| 100 | return new URL(path, window.location.origin).toString(); | 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 | export function useOAuthPopupFlow< | 137 | export function useOAuthPopupFlow< |
| 104 | TPayload extends OAuthCompletionPayload = OAuthCompletionPayload, | 138 | TPayload extends OAuthCompletionPayload = OAuthCompletionPayload, |
| 105 | >(options: { | 139 | >(options: { |