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
2 changes: 1 addition & 1 deletion packages/core/sdk/src/oauth-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export interface OAuthEndpointUrlPolicy {
readonly allowHttp?: boolean;
}

const isLoopbackHttpUrl = (value: string): boolean => {
export const isLoopbackHttpUrl = (value: string): boolean => {
if (!URL.canParse(value)) return false;
const url = new URL(value);
if (url.protocol !== "http:") return false;
Expand Down
88 changes: 87 additions & 1 deletion packages/core/sdk/src/oauth-register-dynamic.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from "@effect/vitest";
import { Effect } from "effect";
import { Effect, Predicate } from "effect";

import {
AuthTemplateSlug,
Expand All @@ -8,6 +8,7 @@ import {
OAuthClientSlug,
ToolName,
} from "./ids";
import { OAuthRegisterDynamicError } from "./oauth-client";
import { definePlugin } from "./plugin";
import { makeTestWorkspaceHarness, memoryCredentialsPlugin } from "./test-config";
import { serveOAuthTestServer } from "./testing/oauth-test-server";
Expand Down Expand Up @@ -143,4 +144,89 @@ describe("oauth.registerDynamicClient", () => {
}),
),
);

// Regression: issue #770. Vercel (and other RFC 8252-strict servers) only
// approve loopback redirect URIs for anonymous DCR, so a hosted/tailnet/LAN
// origin trips `invalid_redirect_uri`. The failure must explain the loopback
// requirement and name the offending URI, not dump the raw RFC code.
it.effect("DCR rejection of a non-loopback redirect URI yields an actionable loopback hint", () =>
Effect.scoped(
Effect.gen(function* () {
const server = yield* serveOAuthTestServer({
scopes: ["read"],
// Mirror Vercel: approve only loopback redirect URIs for anonymous DCR.
approveRedirectUri: (uri) =>
uri.startsWith("http://localhost") || uri.startsWith("http://127."),
});
const { executor } = yield* makeTestWorkspaceHarness({ plugins });
yield* executor.acme.seed();
const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });

const nonLoopback = "https://app.example.com/api/oauth/callback";
const error = yield* Effect.flip(
executor.oauth.registerDynamicClient({
owner: "org",
slug: CLIENT,
registrationEndpoint: probe.registrationEndpoint!,
authorizationUrl: probe.authorizationUrl,
tokenUrl: probe.tokenUrl,
resource: probe.resource,
scopes: ["read"],
tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported,
clientName: "Acme DCR",
redirectUri: nonLoopback,
originIntegration: INTEG,
}),
);
// Predicate guard narrows the union so `.message` reads off a typed failure.
expect(Predicate.isTagged("OAuthRegisterDynamicError")(error)).toBe(true);
const registerError = error as OAuthRegisterDynamicError;
const message = registerError.message;
// Names the loopback-only requirement, the localhost fix, and the URI.
expect(message).toContain("loopback");
expect(message).toContain("http://localhost");
expect(message).toContain(nonLoopback);
}),
),
);

// The loopback hint is gated on the redirect URI actually being non-loopback.
// A server that rejects even a loopback URI keeps the generic message so we
// never tell the user "use localhost" when they already are.
it.effect(
"DCR rejection of a loopback redirect URI keeps the generic message (no false hint)",
() =>
Effect.scoped(
Effect.gen(function* () {
const server = yield* serveOAuthTestServer({
scopes: ["read"],
approveRedirectUri: () => false, // reject every redirect URI, even loopback
});
const { executor } = yield* makeTestWorkspaceHarness({ plugins });
yield* executor.acme.seed();
const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });

const error = yield* Effect.flip(
executor.oauth.registerDynamicClient({
owner: "org",
slug: CLIENT,
registrationEndpoint: probe.registrationEndpoint!,
authorizationUrl: probe.authorizationUrl,
tokenUrl: probe.tokenUrl,
resource: probe.resource,
scopes: ["read"],
tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported,
clientName: "Acme DCR",
redirectUri: "http://127.0.0.1:5394/api/oauth/callback",
originIntegration: INTEG,
}),
);
expect(Predicate.isTagged("OAuthRegisterDynamicError")(error)).toBe(true);
const registerError = error as OAuthRegisterDynamicError;
const message = registerError.message;
expect(message).toContain("Dynamic Client Registration failed: invalid_redirect_uri");
expect(message).not.toContain("Automatic OAuth setup failed");
}),
),
);
});
26 changes: 19 additions & 7 deletions packages/core/sdk/src/oauth-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import {
createPkceCodeVerifier,
exchangeAuthorizationCode,
exchangeClientCredentials,
isLoopbackHttpUrl,
rebindTokenEndpointHostToCallbackDomain,
type OAuth2TokenResponse,
type OAuthEndpointUrlPolicy,
Expand Down Expand Up @@ -496,13 +497,24 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
},
{ httpClientLayer, endpointUrlPolicy: deps.endpointUrlPolicy },
).pipe(
Effect.mapError(
(cause) =>
new OAuthRegisterDynamicError({
// oxlint-disable-next-line executor/no-unknown-error-message -- boundary: OAuthDiscoveryError carries a typed `message` field
message: `Dynamic Client Registration failed: ${cause.message}`,
}),
),
Effect.mapError((cause) => {
// Some authorization servers (Vercel, and others that follow RFC 8252
// strictly) reject anonymous Dynamic Client Registration unless the
// redirect URI is loopback (http://localhost or http://127.0.0.1).
// Executor registers its browser origin, so any hosted, tailnet, or
// LAN origin trips `invalid_redirect_uri`. Turn that opaque RFC code
// into guidance the user can act on instead of the raw error.
// oxlint-disable-next-line executor/no-unknown-error-message -- boundary: OAuthDiscoveryError carries a typed `message`
const rawMessage = cause.message;
const message =
cause.error === "invalid_redirect_uri" && !isLoopbackHttpUrl(flowRedirectUri)
? `Automatic OAuth setup failed: this server only approves loopback redirect ` +
`URLs (http://localhost or http://127.0.0.1) for automatic registration, but ` +
`Executor is using ${flowRedirectUri}. Register an OAuth app manually with that ` +
`redirect URL approved by the server, or run Executor on http://localhost.`
: `Dynamic Client Registration failed: ${rawMessage}`;
return new OAuthRegisterDynamicError({ message });
}),
);

// Persist the minted client. DCR-minted public clients have no secret; we
Expand Down
15 changes: 15 additions & 0 deletions packages/core/sdk/src/testing/oauth-test-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ export interface OAuthTestServerOptions {
readonly scopes?: readonly string[];
readonly omitTokenResponseScopes?: readonly string[];
readonly supportRefresh?: boolean;
/** Gate Dynamic Client Registration on the requested redirect URIs. When set,
* `/register` returns `400 invalid_redirect_uri` unless every requested
* `redirect_uris` entry is approved. Mirrors authorization servers (e.g.
* Vercel) that only accept loopback redirect URIs for anonymous DCR. */
readonly approveRedirectUri?: (uri: string) => boolean;
}

export interface OAuthTestServerShape {
Expand Down Expand Up @@ -496,6 +501,16 @@ export const serveOAuthTestServer = (
? `secret_${randomUUID()}`
: null;
const redirectUris = new Set(arrayOfStrings(json.redirect_uris));
if (
options.approveRedirectUri &&
[...redirectUris].some((uri) => !options.approveRedirectUri!(uri))
) {
return oauthError(
400,
"invalid_redirect_uri",
"The provided redirect URIs are not approved for use by this authorization server.",
);
}
clients.set(clientId, {
clientSecret,
redirectUris,
Expand Down
36 changes: 35 additions & 1 deletion packages/react/src/components/add-account-modal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,41 @@ describe("runDcrConnect", () => {
integration: TEST_INTEGRATION,
},
);
expect(outcome.kind).toBe("fallback");
expect(outcome).toEqual({ kind: "fallback", reason: "registration-failed" });
expect(calls).toEqual(["register"]);
});

it("threads the register failure message into the fallback (registration-failed)", async () => {
const message =
"Automatic OAuth setup failed: this server only approves loopback redirect URLs " +
"(http://localhost or http://127.0.0.1) for automatic registration, but Executor is " +
"using https://app.example.com/api/oauth/callback. Register an OAuth app manually with " +
"that redirect URL approved by the server, or run Executor on http://localhost.";
let started = false;
const outcome = await runDcrConnect(
{
probe: (): Promise<ProbeResult | null> =>
Promise.resolve({
authorizationUrl: "https://auth.example.com/authorize",
tokenUrl: "https://auth.example.com/token",
registrationEndpoint: "https://auth.example.com/register",
}),
register: (): Promise<{ readonly error: string }> => Promise.resolve({ error: message }),
start: (): void => {
started = true;
},
},
{
discoveryUrl: "https://mcp.example.com/mcp",
owner: "user",
integrationName: "App",
existingSlugs: [],
integration: TEST_INTEGRATION,
},
);
// The redirect-URI rejection reaches the caller verbatim so the BYO fallback
// can show why instead of the generic copy, and the OAuth start is skipped.
expect(outcome).toEqual({ kind: "fallback", reason: "registration-failed", message });
expect(started).toBe(false);
});
});
38 changes: 29 additions & 9 deletions packages/react/src/components/add-account-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -454,15 +454,23 @@ type DcrOutcome =
| { readonly kind: "started" }
| {
readonly kind: "fallback";
readonly reason: "probe-failed" | "no-registration-endpoint";
readonly reason: "probe-failed" | "no-registration-endpoint" | "registration-failed";
/** A specific, user-facing reason to surface instead of the generic
* "register an app" copy (e.g. a server that rejects the DCR redirect
* URI). Absent when the fallback carries no actionable detail. */
readonly message?: string;
};

type RunDcrConnectDeps = {
/** Probe the discovery URL → resolved endpoints + (maybe) a registration
* endpoint. Resolves to null when the probe fails. */
readonly probe: (url: string) => Promise<DcrProbeResult | null>;
/** Register a DCR client → the minted client slug, or null on failure. */
readonly register: (args: DcrRegisterArgs) => Promise<OAuthClientSlug | null>;
/** Register a DCR client → the minted client slug, `{ error }` with a
* user-facing message when the server rejects registration, or null on an
* unexplained failure. */
readonly register: (
args: DcrRegisterArgs,
) => Promise<OAuthClientSlug | { readonly error: string } | null>;
/** Start the OAuth flow with the minted client (popup / inline). */
readonly start: (args: DcrStartArgs) => void;
};
Expand All @@ -486,8 +494,9 @@ type RunDcrConnectInput = {
*
* - Probe failure → `{ kind: "fallback", reason: "probe-failed" }` (caller shows BYO).
* - No registration endpoint → `{ kind: "fallback", reason: "no-registration-endpoint" }`.
* - Register failure → throws via the injected `register` rejecting; the caller
* treats a thrown/rejected register as fallback (kept out of the happy path).
* - Register rejected with a message → `{ kind: "fallback", reason: "registration-failed", message }`
* so the caller can show why (e.g. a redirect-URI rejection) over the generic copy.
* - Register failed without detail (null) → `{ kind: "fallback", reason: "registration-failed" }`.
* - Success → registers, calls `start`, returns `{ kind: "started" }`.
*/
export async function runDcrConnect(
Expand Down Expand Up @@ -517,7 +526,12 @@ export async function runDcrConnect(
redirectUri: input.redirectUri,
originIntegration: input.integration,
});
if (minted === null) return { kind: "fallback", reason: "probe-failed" };
if (minted === null) return { kind: "fallback", reason: "registration-failed" };
// OAuthClientSlug is a branded string; an object return carries the failure
// message (e.g. the server rejected the redirect URI) for the BYO fallback.
if (typeof minted === "object") {
return { kind: "fallback", reason: "registration-failed", message: minted.error };
}
deps.start({ client: minted, owner: input.owner });
return { kind: "started" };
}
Expand Down Expand Up @@ -1102,7 +1116,9 @@ export function AddAccountModal(props: {
if (Exit.isFailure(exit)) return null;
return exit.value;
},
register: async (args: DcrRegisterArgs): Promise<OAuthClientSlug | null> => {
register: async (
args: DcrRegisterArgs,
): Promise<OAuthClientSlug | { readonly error: string } | null> => {
const exit = await doRegisterDynamic({
payload: {
owner: args.owner,
Expand All @@ -1119,7 +1135,11 @@ export function AddAccountModal(props: {
},
reactivityKeys: oauthClientWriteKeys,
});
if (Exit.isFailure(exit)) return null;
if (Exit.isFailure(exit)) {
return {
error: messageFromExit(exit, "Automatic setup unavailable. Register an app instead."),
};
}
return exit.value.client;
},
start: (args: DcrStartArgs): void => {
Expand Down Expand Up @@ -1164,7 +1184,7 @@ export function AddAccountModal(props: {
});
if (outcome.kind === "fallback") {
setDcrFailed(true);
toast.error("Automatic setup unavailable — register an app");
toast.error(outcome.message ?? "Automatic setup unavailable. Register an app instead.");

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Long actionable message surfaced in a toast

outcome.message can be the full ~330-character loopback hint. Most toast implementations (including sonner) wrap text at a fixed width, so the toast can grow to four or five lines and still be legible, but the recovery instructions ("Register an OAuth app manually…, or run Executor on http://localhost") are the kind of guidance a user needs to act on — not just read before the toast auto-dismisses. Consider copying the message into the BYO form as inline helper text (e.g., below the registration endpoint field) so it persists while the user fills in the form.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

}
};

Expand Down