History

add CIMD client metadata document registry

Rhys Sullivan committed 4 months ago

Commit dd84a1e1 parent 4e7411813 files · +466 −11Browse files

Unified · Split

13 files
  1. apps/cloud/src/app-paths.test.ts +1 −0
  2. apps/desktop/electron.vite.config.ts +3 −0
  3. apps/local/src/serve-shared.test.ts +23 −0
  4. apps/local/src/serve-shared.ts +8 −0
  5. apps/local/src/serve.test.ts +19 −0
  6. apps/local/src/serve.ts +27 −5
  7. apps/local/vite.config.ts +30 −4
  8. packages/core/api/src/server.ts +12 −0
  9. packages/core/api/src/server/executor-app.ts +5 −1
  10. packages/core/api/src/server/oauth-client-metadata.test.ts +84 −0
  11. packages/core/api/src/server/oauth-client-metadata.ts +193 −0
  12. packages/react/src/plugins/oauth-sign-in.test.ts +27 −1
  13. packages/react/src/plugins/oauth-sign-in.tsx +34 −0
apps/cloud/src/app-paths.test.ts+1−0
@@ -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 UI17 "/api/billing/customer", // AutumnProvider pathPrefix — the billing UI
17 "/api/billing/attach",18 "/api/billing/attach",
18 "/api/docs", // Swagger UI19 "/api/docs", // Swagger UI
apps/desktop/electron.vite.config.ts+3−0
@@ -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: {
apps/local/src/serve-shared.test.ts+23−0
@@ -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
55describe("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});
apps/local/src/serve-shared.ts+8−0
@@ -86,3 +86,11 @@ export const hasFileExtension = (pathname: string): boolean => { · 85 unmodified lines
86 */86 */
87export const isUnauthenticatedOAuthCallbackPath = (pathname: string): boolean =>87export const isUnauthenticatedOAuthCallbackPath = (pathname: string): boolean =>
88 /^\/api\/oauth\/callback(\/|$)/.test(pathname);88 /^\/api\/oauth\/callback(\/|$)/.test(pathname);
89
90export const isUnauthenticatedOAuthClientMetadataPath = (pathname: string): boolean =>
91 pathname === "/api/oauth/client-id-metadata.json" ||
92 /^\/api\/oauth\/client-id-metadata\/[^/]+\.json$/.test(pathname);
93
94export const isUnauthenticatedOAuthPath = (pathname: string): boolean =>
95 isUnauthenticatedOAuthCallbackPath(pathname) ||
96 isUnauthenticatedOAuthClientMetadataPath(pathname);
apps/local/src/serve.test.ts+19−0
@@ -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`);
apps/local/src/serve.ts+27−5
@@ -11,6 +11,7 @@ import { resolve, join } from "node:path"; · 10 unmodified lines
11import { readdirSync } from "node:fs";11import { readdirSync } from "node:fs";
12import type { Subprocess } from "bun";12import type { Subprocess } from "bun";
13import { setOAuthCompletionListener } from "@executor-js/api";13import { setOAuthCompletionListener } from "@executor-js/api";
14import { oauthClientIdMetadataDocumentFromRequest } from "@executor-js/api/server";
14import { loadOrMintLocalAuthToken } from "./auth";15import { loadOrMintLocalAuthToken } from "./auth";
15import { consumeOAuthResult, publishOAuthResult } from "./oauth-result-store";16import { consumeOAuthResult, publishOAuthResult } from "./oauth-result-store";
16import { startIntegrationsRefresh } from "./integrations";17import { 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
40const 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
38function collectStaticRoutes(dir: string, prefix = ""): Record<string, StaticHandler> {57function 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 assets59 // 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 and361 // OAuth callbacks and CIMD documents are reached by the external
343 // can't carry our bearer. The OAuth `state` parameter is the security362 // 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 }
apps/local/vite.config.ts+30−4
@@ -4,9 +4,14 @@ import { Readable } from "node:stream"; · 3 unmodified lines
4import { fileURLToPath } from "node:url";4import { fileURLToPath } from "node:url";
5import { defineConfig, type Plugin } from "vite";5import { defineConfig, type Plugin } from "vite";
6import appPlugin from "@executor-js/app/vite";6import appPlugin from "@executor-js/app/vite";
7import { oauthClientIdMetadataDocumentFromRequest } from "@executor-js/api/server";
7import { loadOrMintLocalAuthToken } from "./src/auth";8import { loadOrMintLocalAuthToken } from "./src/auth";
8import { consumeOAuthResult } from "./src/oauth-result-store";9import { consumeOAuthResult } from "./src/oauth-result-store";
9import { isUnauthenticatedOAuthCallbackPath, makeIsAuthorized } from "./src/serve-shared";10import {
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.json16// oxlint-disable-next-line executor/no-json-parse -- boundary: Vite config reads package metadata from package.json
12const rootPackage = JSON.parse(17const rootPackage = JSON.parse(
@@ -33,6 +38,23 @@ const EXECUTOR_GITHUB_URL = ( · 20 unmodified lines
33const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url));38const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url));
34const APP_ROOT = fileURLToPath(new URL("../../packages/app/", import.meta.url));39const APP_ROOT = fileURLToPath(new URL("../../packages/app/", import.meta.url));
35 40
41const 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 handlers59 * 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 the107 // 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: {
packages/core/api/src/server.ts+12−0
@@ -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";
98export {
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";
98export * as ExecutorApp from "./server/executor-app";110export * as ExecutorApp from "./server/executor-app";
99export type {111export type {
100 ExecutorAppOptions,112 ExecutorAppOptions,
packages/core/api/src/server/executor-app.ts+5−1
@@ -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";
77import { makeOAuthClientIdMetadataRoute } from "./oauth-client-metadata";
77 78
78// A fully-resolved route/app Layer with its channels erased. Used at the79// A fully-resolved route/app Layer with its channels erased. Used at the
79// assembly boundaries (mirrors `toApiHandler`'s loose typing): each host's80// 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 (the563 // `provideMerge(boot)` resolves the seams' residual requirements (the
563 // long-lived DB handle, the control-plane services); the runtime contract is564 // 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
packages/core/api/src/server/oauth-client-metadata.test.ts+84−0
@@ -0,0 +1,84 @@
1import { describe, expect, it } from "@effect/vitest";
2
3import { oauthClientIdMetadataDocumentFromRequest } from "./oauth-client-metadata";
4
5describe("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});
packages/core/api/src/server/oauth-client-metadata.ts+193−0
@@ -0,0 +1,193 @@
1import { Effect, Layer, Option } from "effect";
2import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
3
4export const OAUTH_CLIENT_ID_METADATA_DOCUMENT_BASE_PATH = "/oauth/client-id-metadata" as const;
5export const OAUTH_CLIENT_ID_METADATA_DOCUMENT_PATH =
6 `${OAUTH_CLIENT_ID_METADATA_DOCUMENT_BASE_PATH}.json` as const;
7export const OAUTH_CLIENT_ID_METADATA_DOCUMENT_TARGET_PATH_PREFIX =
8 `${OAUTH_CLIENT_ID_METADATA_DOCUMENT_BASE_PATH}/` as const;
9export const OAUTH_CLIENT_ID_METADATA_DOCUMENT_DEFAULT_TARGET = "default" as const;
10export const OAUTH_CLIENT_ID_METADATA_DOCUMENT_LOCAL_TARGET = "local" as const;
11const OAUTH_CALLBACK_ORG_QUERY_PARAM = "executor_org" as const;
12
13type MetadataTarget =
14 | typeof OAUTH_CLIENT_ID_METADATA_DOCUMENT_DEFAULT_TARGET
15 | typeof OAUTH_CLIENT_ID_METADATA_DOCUMENT_LOCAL_TARGET
16 | string;
17
18interface 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
29const pathWithMountPrefix = (mountPrefix?: string): `/${string}` =>
30 `${mountPrefix ?? ""}${OAUTH_CLIENT_ID_METADATA_DOCUMENT_PATH}` as `/${string}`;
31
32const targetPathWithMountPrefix = (mountPrefix?: string): `/${string}` =>
33 `${mountPrefix ?? ""}${OAUTH_CLIENT_ID_METADATA_DOCUMENT_TARGET_PATH_PREFIX}*` as `/${string}`;
34
35const callbackPathWithMountPrefix = (mountPrefix?: string): `/${string}` =>
36 `${mountPrefix ?? ""}/oauth/callback` as `/${string}`;
37
38export const oauthClientIdMetadataDocumentPath = (mountPrefix?: string): `/${string}` =>
39 pathWithMountPrefix(mountPrefix);
40
41export 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
49const firstForwardedHeaderValue = (headers: Headers, name: string): string | undefined => {
50 const value = headers.get(name)?.split(",")[0]?.trim();
51 return value ? value : undefined;
52};
53
54const parseAbsoluteUrl = (value: string): URL | undefined => {
55 if (!/^[a-z][a-z\d+.-]*:\/\//i.test(value)) return undefined;
56 return new URL(value);
57};
58const decodeUriComponentOption = Option.liftThrowable(decodeURIComponent);
59
60const 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
66const 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
83const 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
94export 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
117const 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
126export 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
169export 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};
packages/react/src/plugins/oauth-sign-in.test.ts+27−1
@@ -1,6 +1,6 @@
1import { afterEach, describe, expect, it } from "@effect/vitest";1import { afterEach, describe, expect, it } from "@effect/vitest";
2 2
3import { oauthCallbackUrl } from "./oauth-sign-in";3import { oauthCallbackUrl, oauthClientIdMetadataDocumentUrl } from "./oauth-sign-in";
4 4
5const originalWindow = globalThis.window;5const 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
25describe("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
25describe("oauthCallbackUrl", () => {51describe("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");
packages/react/src/plugins/oauth-sign-in.tsx+34−0
@@ -14,6 +14,7 @@ import { · 13 unmodified lines
14 type OAuthPopupResult,14 type OAuthPopupResult,
15} from "../api/oauth-popup";15} from "../api/oauth-popup";
16import { connectionWriteKeys } from "../api/reactivity-keys";16import { connectionWriteKeys } from "../api/reactivity-keys";
17import { getActiveOrgSlug } from "../api/server-connection";
17 18
18type DesktopBridge = {19type 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
104export const OAUTH_CLIENT_ID_METADATA_DOCUMENT_PATH = "/api/oauth/client-id-metadata/default.json";
105export const OAUTH_CLIENT_ID_METADATA_DOCUMENT_LOCAL_PATH =
106 "/api/oauth/client-id-metadata/local.json";
107
108const 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
118export 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
103export function useOAuthPopupFlow<137export function useOAuthPopupFlow<
104 TPayload extends OAuthCompletionPayload = OAuthCompletionPayload,138 TPayload extends OAuthCompletionPayload = OAuthCompletionPayload,
105>(options: {139>(options: {

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

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

Repository code

Go to file
t