Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cimd-private-host-dcr.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@executor-js/react": patch
---

Use Dynamic Client Registration instead of a Client ID Metadata Document when Executor runs on a private host (localhost, a LAN address, or a tailnet name) and the OAuth provider is public. The provider fetches the CIMD URL itself, so these sign-ins previously failed with `invalid_client`. A provider on a private host keeps using CIMD.
92 changes: 92 additions & 0 deletions packages/react/src/components/add-account-modal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,98 @@ describe("runAutomaticOAuthConnect", () => {
});
expect(startArgs!.reservation).toBe(RESERVED);
});

it("uses DCR when the CIMD document is on a private host the provider can't fetch", async () => {
const popup = popupSpy();
const calls: string[] = [];

const outcome = await runAutomaticOAuthConnect(
{
...popup,
isActive: (): boolean => true,
probe: (): Promise<ProbeResult> =>
Promise.resolve({
authorizationUrl: "https://auth.example.com/authorize",
tokenUrl: "https://auth.example.com/token",
resource: "https://mcp.example.com/mcp",
registrationEndpoint: "https://auth.example.com/register",
clientIdMetadataDocumentSupported: true,
}),
createCimdClient: (args: CimdCreateArgs): Promise<OAuthClientSlug> => {
calls.push("create-cimd");
return Promise.resolve(args.slug);
},
register: (): Promise<OAuthClientSlug> => {
calls.push("register-dcr");
return Promise.resolve(OAuthClientSlug.make("dcr-client"));
},
start: (): void => {
calls.push("start");
},
},
{
discoveryUrl: "https://mcp.example.com/mcp",
resourceFallback: "https://mcp.example.com/mcp",
owner: "user",
integration: TEST_INTEGRATION,
cimd: {
integrationName: "Test MCP",
clientIdMetadataDocumentUrl:
"https://box.tailnet.ts.net:8446/api/oauth/client-id-metadata/default.json",
existingClients: [],
},
},
);

expect(outcome).toEqual({ kind: "started", flow: "dcr" });
expect(calls).toEqual(["register-dcr", "start"]);
});

it("keeps CIMD when the provider is on the same private network as the document", async () => {
const popup = popupSpy();
const calls: string[] = [];

const outcome = await runAutomaticOAuthConnect(
{
...popup,
isActive: (): boolean => true,
probe: (): Promise<ProbeResult> =>
Promise.resolve({
authorizationUrl: "https://sso.tailnet.ts.net/authorize",
tokenUrl: "https://sso.tailnet.ts.net/token",
resource: "https://mcp.example.com/mcp",
registrationEndpoint: "https://sso.tailnet.ts.net/register",
clientIdMetadataDocumentSupported: true,
}),
createCimdClient: (args: CimdCreateArgs): Promise<OAuthClientSlug> => {
calls.push("create-cimd");
return Promise.resolve(args.slug);
},
register: (): Promise<OAuthClientSlug> => {
calls.push("register-dcr");
return Promise.resolve(OAuthClientSlug.make("dcr-client"));
},
start: (): void => {
calls.push("start");
},
},
{
discoveryUrl: "https://mcp.example.com/mcp",
resourceFallback: "https://mcp.example.com/mcp",
owner: "user",
integration: TEST_INTEGRATION,
cimd: {
integrationName: "Test MCP",
clientIdMetadataDocumentUrl:
"https://box.tailnet.ts.net:8446/api/oauth/client-id-metadata/default.json",
existingClients: [],
},
},
);

expect(outcome).toEqual({ kind: "started", flow: "cimd" });
expect(calls).toEqual(["create-cimd", "start"]);
});
});

// ---------------------------------------------------------------------------
Expand Down
17 changes: 15 additions & 2 deletions packages/react/src/components/add-account-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import {
type ConnectionOwnerOption,
} from "../plugins/connection-owner";
import {
canUseClientIdMetadataDocument,
oauthCallbackUrl,
oauthClientIdMetadataDocumentUrl,
useOAuthPopupFlow,
Expand Down Expand Up @@ -1022,7 +1023,13 @@ export async function runAutomaticOAuthConnect(
? null
: (probe.resource ?? input.storedResource)
: (probe.resource ?? input.resourceFallback ?? null);
if (probe.clientIdMetadataDocumentSupported === true) {
if (
probe.clientIdMetadataDocumentSupported === true &&
canUseClientIdMetadataDocument({
clientIdMetadataDocumentUrl: input.cimd.clientIdMetadataDocumentUrl,
authorizationUrl: probe.authorizationUrl,
})
) {
const resolved = await resolveCimdClient(
{ createClient: deps.createCimdClient },
{
Expand Down Expand Up @@ -1769,7 +1776,13 @@ function AddAccountModalView(props: AddAccountModalProps) {
// CIMD-capable: the provider accepts a client_id that is a metadata-document
// URL, so we can create a public local client and skip provider app
// registration entirely.
const isCimd = isOAuth && method?.oauth?.supportsClientIdMetadataDocument === true;
const isCimd =
isOAuth &&
method?.oauth?.supportsClientIdMetadataDocument === true &&
canUseClientIdMetadataDocument({
clientIdMetadataDocumentUrl: oauthClientIdMetadataDocumentUrl(),
authorizationUrl: method.oauth.authorizationUrl,
});
const cimdActive = isCimd;
// Single-input header/query methods: the placement's lead + prefix (e.g.
// "Authorization: Bearer ") merges INTO the credential field as a non-editable
Expand Down
81 changes: 80 additions & 1 deletion packages/react/src/plugins/oauth-sign-in.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { afterEach, describe, expect, it } from "@effect/vitest";

import { oauthCallbackUrl, oauthClientIdMetadataDocumentUrl } from "./oauth-sign-in";
import {
canUseClientIdMetadataDocument,
isPubliclyReachableUrl,
oauthCallbackUrl,
oauthClientIdMetadataDocumentUrl,
} from "./oauth-sign-in";

// The legacy query-param org selector the metadata-document URL must NOT use
// (org is carried in the path instead). Mirrors the server-side constant.
Expand Down Expand Up @@ -89,3 +94,77 @@ describe("oauthClientIdMetadataDocumentUrl", () => {
expect(url.searchParams.has(OAUTH_CALLBACK_ORG_QUERY_PARAM)).toBe(false);
});
});

describe("isPubliclyReachableUrl", () => {
it("accepts public https hosts", () => {
expect(
isPubliclyReachableUrl("https://executor.sh/api/oauth/client-id-metadata/default.json"),
).toBe(true);
expect(isPubliclyReachableUrl("https://8.8.8.8:8446/x.json")).toBe(true);
});

it("rejects hosts an authorization server can't fetch", () => {
for (const url of [
"/api/oauth/client-id-metadata/default.json",
"http://executor.example.com/x.json",
"https://localhost:4788/x.json",
"https://executor/x.json",
"https://box.example.ts.net:8446/x.json",
"https://nas.local/x.json",
"https://10.0.0.5/x.json",
"https://192.168.1.20/x.json",
"https://172.20.0.3/x.json",
"https://100.64.0.7/x.json",
"https://[::1]/x.json",
"https://[fd7a:115c:a1e0::1]/x.json",
]) {
expect(isPubliclyReachableUrl(url), url).toBe(false);
}
});
});

describe("canUseClientIdMetadataDocument", () => {
const privateDocument =
"https://box.example.ts.net:8446/api/oauth/client-id-metadata/default.json";

it("uses CIMD when the document is public", () => {
expect(
canUseClientIdMetadataDocument({
clientIdMetadataDocumentUrl:
"https://executor.sh/api/oauth/client-id-metadata/default.json",
authorizationUrl: "https://auth.example.com/authorize",
}),
).toBe(true);
});

it("uses DCR when a public provider can't fetch a private document", () => {
expect(
canUseClientIdMetadataDocument({
clientIdMetadataDocumentUrl: privateDocument,
authorizationUrl: "https://auth.example.com/authorize",
}),
).toBe(false);
});

it("uses CIMD when the provider is on a private host too", () => {
expect(
canUseClientIdMetadataDocument({
clientIdMetadataDocumentUrl:
"http://localhost:4788/api/oauth/client-id-metadata/default.json",
authorizationUrl: "http://localhost:9000/authorize",
}),
).toBe(true);
expect(
canUseClientIdMetadataDocument({
clientIdMetadataDocumentUrl: privateDocument,
authorizationUrl: "https://sso.example.ts.net/authorize",
}),
).toBe(true);
});

it("keeps CIMD when the provider is unknown", () => {
expect(canUseClientIdMetadataDocument({ clientIdMetadataDocumentUrl: privateDocument })).toBe(
true,
);
});
});
54 changes: 54 additions & 0 deletions packages/react/src/plugins/oauth-sign-in.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,60 @@ export function oauthClientIdMetadataDocumentUrl(options?: {
return url.toString();
}

// An authorization server fetches the CIMD client_id URL itself. A self-hosted
// Executor on localhost, a LAN, or a tailnet serves the document at an address
// a public provider can't reach, and the provider fails the sign-in with
// `invalid_client`. Those connects use Dynamic Client Registration instead.
const PRIVATE_HOST_SUFFIXES = [
".localhost",
".local",
".internal",
".lan",
".home.arpa",
".ts.net",
];

const isPrivateIpv4 = (host: string): boolean => {
const parts = host.split(".").map(Number);
if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) {
return false;
}
const [a, b] = parts as [number, number, number, number];
return (
a === 10 ||
a === 127 ||
a === 0 ||
(a === 169 && b === 254) ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 100 && b >= 64 && b <= 127)
);
};

export function isPubliclyReachableUrl(value: string): boolean {
if (!/^https:\/\//i.test(value)) return false;
const host = new URL(value).hostname.toLowerCase().replace(/\.$/, "");
if (host.startsWith("[")) {
const ip = host.slice(1, -1);
return !(ip === "::1" || ip.startsWith("fc") || ip.startsWith("fd") || ip.startsWith("fe80"));
}
if (!host.includes(".") || host === "localhost") return false;
if (PRIVATE_HOST_SUFFIXES.some((suffix) => host.endsWith(suffix))) return false;
return !isPrivateIpv4(host);
}

// CIMD works when the provider can fetch the document: either the document is
// public, or the provider is itself private (same machine or network). When
// the authorization server is unknown, keep CIMD.
export function canUseClientIdMetadataDocument(input: {
readonly clientIdMetadataDocumentUrl: string;
readonly authorizationUrl?: string | null;
}): boolean {
if (isPubliclyReachableUrl(input.clientIdMetadataDocumentUrl)) return true;
if (input.authorizationUrl == null) return true;
return !isPubliclyReachableUrl(input.authorizationUrl);
}

export function useOAuthPopupFlow<
TPayload extends OAuthCompletionPayload = OAuthCompletionPayload,
>(options: {
Expand Down
Loading