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
1313 "/api/executions",
1414 "/api/auth/me",
1515 "/api/openapi.json",
16+ "/api/oauth/client-id-metadata/default.json",
1617 "/api/billing/customer", // AutumnProvider pathPrefix — the billing UI
1718 "/api/billing/attach",
1819 "/api/docs", // Swagger UI
apps/desktop/electron.vite.config.ts+3−0
@@ -54,6 +54,9 @@ export default defineConfig({ · 53 unmodified lines
5454 process.env.npm_package_version ?? "0.0.0",
5555 ),
5656 "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+ ),
5760 },
5861 resolve: {
5962 alias: {
apps/local/src/serve-shared.test.ts+23−0
@@ -4,7 +4,9 @@ import { · 3 unmodified lines
44 DEFAULT_ALLOWED_HOSTS,
55 hasBearerToken,
66 isAllowedOrigin,
7+ isUnauthenticatedOAuthClientMetadataPath,
78 isUnauthenticatedOAuthCallbackPath,
9+ isUnauthenticatedOAuthPath,
810 makeIsAuthorized,
911 } from "./serve-shared";
1012
@@ -49,3 +51,24 @@ describe("isUnauthenticatedOAuthCallbackPath", () => { · 38 unmodified lines
4951 expect(isUnauthenticatedOAuthCallbackPath("/api/scope")).toBe(false);
5052 });
5153 });
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+});
apps/local/src/serve-shared.ts+8−0
@@ -86,3 +86,11 @@ export const hasFileExtension = (pathname: string): boolean => { · 85 unmodified lines
8686 */
8787 export const isUnauthenticatedOAuthCallbackPath = (pathname: string): boolean =>
8888 /^\/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);
apps/local/src/serve.test.ts+19−0
@@ -134,6 +134,25 @@ describe("startServer bearer auth", () => { · 133 unmodified lines
134134 expect(response.status).toBe(200);
135135 });
136136
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+
137156 it("requires the bearer token on the OAuth await poll", async () => {
138157 const baseUrl = await startTestServer();
139158 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
1111 import { readdirSync } from "node:fs";
1212 import type { Subprocess } from "bun";
1313 import { setOAuthCompletionListener } from "@executor-js/api";
14+import { oauthClientIdMetadataDocumentFromRequest } from "@executor-js/api/server";
1415 import { loadOrMintLocalAuthToken } from "./auth";
1516 import { consumeOAuthResult, publishOAuthResult } from "./oauth-result-store";
1617 import { startIntegrationsRefresh } from "./integrations";
@@ -19,7 +20,8 @@ import { · 2 unmodified lines
1920 DEFAULT_ALLOWED_HOSTS,
2021 hasFileExtension,
2122 isAllowedOrigin,
22− isUnauthenticatedOAuthCallbackPath,
23+ isUnauthenticatedOAuthClientMetadataPath,
24+ isUnauthenticatedOAuthPath,
2325 makeIsAuthorized,
2426 normalizeCredential,
2527 } from "./serve-shared";
@@ -35,6 +37,23 @@ const htmlResponse = (file: Bun.BunFile): Response => · 9 unmodified lines
3537 headers: { "content-type": "text/html", "cache-control": "no-store" },
3638 });
3739
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+
3857 function collectStaticRoutes(dir: string, prefix = ""): Record<string, StaticHandler> {
3958 const routes: Record<string, StaticHandler> = {};
4059 // 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
339358 return withCors(new Response("ok", { headers: { "content-type": "text/plain" } }));
340359 }
341360
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
345363 // /api and /mcp requires the bearer.
346− const skipAuth = isUnauthenticatedOAuthCallbackPath(url.pathname);
364+ const skipAuth = isUnauthenticatedOAuthPath(url.pathname);
347365 const isGatedSurface = url.pathname.startsWith("/api") || url.pathname.startsWith("/mcp");
348366
349367 if (isGatedSurface && !skipAuth && !isAuthorized(req)) {
@@ -355,6 +373,10 @@ export async function startServer(opts: StartServerOptions = {}): Promise<Server · 5 unmodified lines
355373 );
356374 }
357375
376+ if (isUnauthenticatedOAuthClientMetadataPath(url.pathname) && req.method === "GET") {
377+ return withCors(oauthClientMetadataResponse(`${url.pathname}${url.search}`, req));
378+ }
379+
358380 if (url.pathname.startsWith("/mcp")) {
359381 return withCors(await handlers.mcp.handleRequest(req));
360382 }
apps/local/vite.config.ts+30−4
@@ -4,9 +4,14 @@ import { Readable } from "node:stream"; · 3 unmodified lines
44 import { fileURLToPath } from "node:url";
55 import { defineConfig, type Plugin } from "vite";
66 import appPlugin from "@executor-js/app/vite";
7+import { oauthClientIdMetadataDocumentFromRequest } from "@executor-js/api/server";
78 import { loadOrMintLocalAuthToken } from "./src/auth";
89 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";
1015
1116 // oxlint-disable-next-line executor/no-json-parse -- boundary: Vite config reads package metadata from package.json
1217 const rootPackage = JSON.parse(
@@ -33,6 +38,23 @@ const EXECUTOR_GITHUB_URL = ( · 20 unmodified lines
3338 const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url));
3439 const APP_ROOT = fileURLToPath(new URL("../../packages/app/", import.meta.url));
3540
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+
3658 /**
3759 * Vite plugin that forwards /api and /mcp requests to the Effect handlers
3860 * during development, so you don't need a separate server process.
@@ -85,8 +107,7 @@ function executorApiPlugin(): Plugin { · 46 unmodified lines
85107 // bootstrap, so the UI is unaffected; external MCP clients use the
86108 // daemon port.
87109 const pathOnly = rawUrl.split("?")[0] ?? "/";
88− const authExempt =
89− pathOnly === "/api/health" || isUnauthenticatedOAuthCallbackPath(pathOnly);
110+ const authExempt = pathOnly === "/api/health" || isUnauthenticatedOAuthPath(pathOnly);
90111 if (!authExempt) {
91112 const presented = req.headers.authorization;
92113 const authValue = Array.isArray(presented) ? presented[0] : presented;
@@ -125,7 +146,9 @@ function executorApiPlugin(): Plugin { · 32 unmodified lines
125146 } as RequestInit);
126147
127148 let response: Response;
128− if (isMcp) {
149+ if (isUnauthenticatedOAuthClientMetadataPath(pathOnly) && req.method === "GET") {
150+ response = oauthClientMetadataResponse(rawUrl, webRequest(rawUrl));
151+ } else if (isMcp) {
129152 response = await handlers.mcp.handleRequest(webRequest(rawUrl));
130153 } else if (pathOnly === "/api/health" && req.method === "GET") {
131154 response = new Response("ok", { headers: { "content-type": "text/plain" } });
@@ -182,6 +205,9 @@ export default defineConfig({ · 50 unmodified lines
182205 "import.meta.env.VITE_APP_VERSION": JSON.stringify(EXECUTOR_VERSION),
183206 "import.meta.env.VITE_GITHUB_URL": JSON.stringify(EXECUTOR_GITHUB_URL),
184207 "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+ ),
185211 "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? "development"),
186212 },
187213 resolve: {
packages/core/api/src/server.ts+12−0
@@ -95,6 +95,18 @@ export { · 94 unmodified lines
9595 type MakeAccountApiLayerOptions,
9696 type ApiHandler,
9797 } 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";
98110 export * as ExecutorApp from "./server/executor-app";
99111 export type {
100112 ExecutorAppOptions,
packages/core/api/src/server/executor-app.ts+5−1
@@ -74,6 +74,7 @@ import { · 73 unmodified lines
7474 toApiHandler,
7575 type ApiHandler,
7676 } from "./host-foundation";
77+import { makeOAuthClientIdMetadataRoute } from "./oauth-client-metadata";
7778
7879 // A fully-resolved route/app Layer with its channels erased. Used at the
7980 // assembly boundaries (mirrors `toApiHandler`'s loose typing): each host's
@@ -562,7 +563,10 @@ export const make = < · 482 unmodified lines
562563 // `provideMerge(boot)` resolves the seams' residual requirements (the
563564 // long-lived DB handle, the control-plane services); the runtime contract is
564565 // 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+ ];
566570 if (mcpRouteLive) routeLayers.push(mcpRouteLive);
567571 for (const route of extensionRoutes) routeLayers.push(route);
568572
packages/core/api/src/server/oauth-client-metadata.test.ts+84−0
@@ -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+});
packages/core/api/src/server/oauth-client-metadata.ts+193−0
@@ -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+};
packages/react/src/plugins/oauth-sign-in.test.ts+27−1
@@ -1,6 +1,6 @@
11 import { afterEach, describe, expect, it } from "@effect/vitest";
22
3−import { oauthCallbackUrl } from "./oauth-sign-in";
3+import { oauthCallbackUrl, oauthClientIdMetadataDocumentUrl } from "./oauth-sign-in";
44
55 const originalWindow = globalThis.window;
66
@@ -22,6 +22,32 @@ afterEach(() => { · 15 unmodified lines
2222 Reflect.deleteProperty(globalThis, "window");
2323 });
2424
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+
2551 describe("oauthCallbackUrl", () => {
2652 it("returns a relative callback path outside the browser", () => {
2753 Reflect.deleteProperty(globalThis, "window");
packages/react/src/plugins/oauth-sign-in.tsx+34−0
@@ -14,6 +14,7 @@ import { · 13 unmodified lines
1414 type OAuthPopupResult,
1515 } from "../api/oauth-popup";
1616 import { connectionWriteKeys } from "../api/reactivity-keys";
17+import { getActiveOrgSlug } from "../api/server-connection";
1718
1819 type DesktopBridge = {
1920 readonly openExternal: (url: string) => Promise<void>;
@@ -100,6 +101,39 @@ export function oauthCallbackUrl(path = "/api/oauth/callback"): string { · 80 unmodified lines
100101 return new URL(path, window.location.origin).toString();
101102 }
102103
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+
103137 export function useOAuthPopupFlow<
104138 TPayload extends OAuthCompletionPayload = OAuthCompletionPayload,
105139 >(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