From 903126aa4e5da2a5ed263d1e13edeb0350a4fc2a Mon Sep 17 00:00:00 2001
From: Aditya kumar singh <143548997+Adityakk9031@users.noreply.github.com>
Date: Mon, 10 Aug 2026 17:02:25 +0530
Subject: [PATCH 1/2] fix(oauth): route DCR reconnect through registration flow
---
.../src/components/add-account-modal.tsx | 215 ++++++++++++------
1 file changed, 144 insertions(+), 71 deletions(-)
diff --git a/packages/react/src/components/add-account-modal.tsx b/packages/react/src/components/add-account-modal.tsx
index a79eafe560..32bc147705 100644
--- a/packages/react/src/components/add-account-modal.tsx
+++ b/packages/react/src/components/add-account-modal.tsx
@@ -936,7 +936,12 @@ interface AddAccountModalProps {
* which cancels a dangling server OAuth session. That is why abandoning an
* OAuth popup can't wedge a later open: the stuck flow died with its instance.
* The parent owns only open/route intent (deep links, the reconnect handoff). */
-export function AddAccountModal(props: AddAccountModalProps) {
+export const hasDcr = (method: AuthMethod | undefined | null): boolean => {
+ if (!method || method.kind !== "oauth") return false;
+ return method.oauth?.supportsDynamicRegistration === true || method.oauth?.discoveryUrl != null;
+};
+
+export const AddAccountModal = (props: AddAccountModalProps) => {
return props.open ? : null;
}
@@ -1485,10 +1490,7 @@ function AddAccountModalView(props: AddAccountModalProps) {
// DCR-capable: the integration advertises dynamic registration (MCP oauth2),
// OR carries a discovery URL we can probe at connect time. When DCR-capable
// and not yet fallen back, we skip the app picker entirely (Option A).
- const isDcr =
- !cimdActive &&
- isOAuth &&
- (method?.oauth?.supportsDynamicRegistration === true || method?.oauth?.discoveryUrl != null);
+ const isDcr = !cimdActive && hasDcr(method);
const dcrActive = isDcr && !dcrFailed;
const automaticOAuthActive = cimdActive || dcrActive;
@@ -1699,6 +1701,19 @@ function AddAccountModalView(props: AddAccountModalProps) {
oauthReconnectOpenedKey.current = handoff.key;
setMethodId(oauthMethod.id);
+
+ if (hasDcr(oauthMethod)) {
+ void executeDcrConnect({
+ method: oauthMethod,
+ connectionName: ConnectionName.make(connectionName),
+ identityLabel: handoff.identityLabel,
+ dcrOwner: connectionOwner,
+ isReconnect: true,
+ handoffKey: handoff.key,
+ });
+ return;
+ }
+
void oauthPopup.start({
payload: {
client: OAuthClientSlug.make(client),
@@ -1728,6 +1743,7 @@ function AddAccountModalView(props: AddAccountModalProps) {
close();
},
});
+ // eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialState, allMethods, integration, oauthPopup, close]);
const probeAndAutoNameOAuthConnection = async (
@@ -2128,19 +2144,19 @@ function AddAccountModalView(props: AddAccountModalProps) {
}
};
- // Transparent DCR connect: probe → register → start, no app picker. On any
- // failure (probe error, no registration endpoint, or registration failure) we
- // flip `dcrFailed` so the bring-your-own-app picker renders as the recovery
- // path with name/owner kept.
- const handleDcrConnect = async () => {
- const discoveryUrl = method?.oauth?.discoveryUrl ?? method?.oauth?.tokenUrl;
- if (!method || !discoveryUrl) {
- setDcrFailed(true);
+ const executeDcrConnect = async (args: {
+ readonly method: AuthMethod;
+ readonly connectionName: string;
+ readonly identityLabel: string | undefined;
+ readonly dcrOwner: Owner;
+ readonly isReconnect: boolean;
+ readonly handoffKey?: string;
+ }) => {
+ const discoveryUrl = args.method.oauth?.discoveryUrl ?? args.method.oauth?.tokenUrl;
+ if (!discoveryUrl) {
+ if (!args.isReconnect) setDcrFailed(true);
return;
}
- const dcrOwner = owner;
- const connectionName = previewConnectionName(label, dcrOwner);
- const identityLabel = typedIdentityLabel(label);
setDcrBusy(true);
const outcome = await runDcrConnect(
{
@@ -2150,22 +2166,22 @@ function AddAccountModalView(props: AddAccountModalProps) {
return exit.value;
},
register: async (
- args: DcrRegisterArgs,
+ rArgs: DcrRegisterArgs,
): Promise => {
const exit = await doRegisterDynamic({
payload: {
- owner: args.owner,
- slug: args.slug,
- issuer: args.issuer ?? null,
- registrationEndpoint: args.registrationEndpoint,
- authorizationUrl: args.authorizationUrl,
- tokenUrl: args.tokenUrl,
- resource: args.resource ?? null,
- scopes: args.scopes,
- tokenEndpointAuthMethodsSupported: args.tokenEndpointAuthMethodsSupported,
- clientName: args.clientName,
- redirectUri: args.redirectUri,
- originIntegration: args.originIntegration,
+ owner: rArgs.owner,
+ slug: rArgs.slug,
+ issuer: rArgs.issuer ?? null,
+ registrationEndpoint: rArgs.registrationEndpoint,
+ authorizationUrl: rArgs.authorizationUrl,
+ tokenUrl: rArgs.tokenUrl,
+ resource: rArgs.resource ?? null,
+ scopes: rArgs.scopes,
+ tokenEndpointAuthMethodsSupported: rArgs.tokenEndpointAuthMethodsSupported,
+ clientName: rArgs.clientName,
+ redirectUri: rArgs.redirectUri,
+ originIntegration: rArgs.originIntegration,
},
reactivityKeys: oauthClientWriteKeys,
});
@@ -2176,60 +2192,117 @@ function AddAccountModalView(props: AddAccountModalProps) {
}
return exit.value.client;
},
- start: (args: DcrStartArgs): void => {
- void oauthPopup.start({
- payload: {
- client: args.client,
- // DCR registers the client under the connection owner, so the app
- // and connection share one owner.
- clientOwner: args.owner,
- owner: args.owner,
- name: connectionName,
- integration,
- template: method.template,
- newConnection: true,
- ...(identityLabel !== undefined ? { identityLabel } : {}),
- },
- onSuccess: async (connection: OAuthCompletionPayload) => {
- await probeAndAutoNameOAuthConnection(connection, label);
- toast.success("Connection added");
- close();
- },
- });
+ start: (sArgs: DcrStartArgs): void => {
+ if (args.isReconnect) {
+ void oauthPopup.start({
+ payload: {
+ client: sArgs.client,
+ clientOwner: sArgs.owner,
+ owner: args.dcrOwner,
+ name: args.connectionName,
+ integration,
+ template: args.method.template,
+ ...(args.identityLabel !== undefined ? { identityLabel: args.identityLabel } : {}),
+ },
+ onAuthorizationStarted: () => {
+ trackEvent("connection_reconnected", {
+ integration_slug: String(integration),
+ owner: args.dcrOwner,
+ success: true,
+ });
+ },
+ onError: () => {
+ trackEvent("connection_reconnected", {
+ integration_slug: String(integration),
+ owner: args.dcrOwner,
+ success: false,
+ });
+ },
+ onSuccess: () => {
+ toast.success("Reconnected");
+ close();
+ },
+ });
+ } else {
+ void oauthPopup.start({
+ payload: {
+ client: sArgs.client,
+ clientOwner: sArgs.owner,
+ owner: args.dcrOwner,
+ name: args.connectionName,
+ integration,
+ template: args.method.template,
+ newConnection: true,
+ ...(args.identityLabel !== undefined ? { identityLabel: args.identityLabel } : {}),
+ },
+ onSuccess: async (connection: OAuthCompletionPayload) => {
+ await probeAndAutoNameOAuthConnection(connection, label);
+ toast.success("Connection added");
+ close();
+ },
+ });
+ }
},
},
{
+ owner: args.dcrOwner,
+ integrationName,
+ authorizationUrl: args.method.oauth?.authorizationUrl,
+ tokenUrl: args.method.oauth?.tokenUrl,
discoveryUrl,
- // Only a genuine discovery URL (MCP) seeds the RFC 8707 resource
- // indicator; the token-endpoint fallback baked into `discoveryUrl` must
- // not, so pass the un-collapsed method value here.
- resourceFallback: method.oauth?.discoveryUrl,
- owner: dcrOwner,
- // DCR slugs are server-keyed (Part A): the connect path no longer depends
- // on the picker's app list, so it need not be threaded here.
- declaredScopes: method.oauth?.scopes,
+ resourceFallback: args.method.oauth?.discoveryUrl,
+ declaredScopes: args.method.oauth?.scopes,
redirectUri: oauthCallbackUrl(),
integration,
},
);
setDcrBusy(false);
- trackEvent("connection_oauth_started", {
- integration_slug: String(integration),
- owner: dcrOwner,
- flow: "dcr",
- success: outcome.kind === "started",
- ...(outcome.kind === "fallback" ? { dcr_fallback: true } : {}),
- });
- if (outcome.kind === "fallback") {
- setOAuthFallbackProbe("probe" in outcome ? outcome.probe : null);
- setDcrFailed(true);
- // Surface the server's actionable rejection reason on the recovery view as
- // an inline error card. Generic fallbacks (no message) fall through to the
- // "register an app" empty state, which already guides the user.
- setDcrFallbackMessage("message" in outcome ? (outcome.message ?? null) : null);
+
+ if (args.isReconnect) {
+ if (outcome.kind === "fallback" || outcome.kind === "failed") {
+ if (args.handoffKey) {
+ oauthReconnectOpenedKey.current = null;
+ }
+ toast.error(
+ outcome.kind === "fallback" && "message" in outcome && outcome.message
+ ? outcome.message
+ : "Reconnect failed: automatic setup unavailable",
+ );
+ }
+ } else {
+ trackEvent("connection_oauth_started", {
+ integration_slug: String(integration),
+ owner: args.dcrOwner,
+ flow: "dcr",
+ success: outcome.kind === "started",
+ ...(outcome.kind === "fallback" ? { dcr_fallback: true } : {}),
+ });
+ if (outcome.kind === "fallback") {
+ setOAuthFallbackProbe("probe" in outcome ? outcome.probe : null);
+ setDcrFailed(true);
+ setDcrFallbackMessage("message" in outcome ? (outcome.message ?? null) : null);
+ } else if (outcome.kind === "failed") {
+ setDcrFailed(true);
+ toast.error("Automatic setup failed");
+ }
}
};
+ // Transparent DCR connect: probe → register → start, no app picker. On any
+ // failure (probe error, no registration endpoint, or registration failure) we
+ // flip `dcrFailed` so the bring-your-own-app picker renders as the recovery
+ // path with name/owner kept.
+ const handleDcrConnect = async () => {
+ if (!method) return;
+ await executeDcrConnect({
+ method,
+ connectionName: previewConnectionName(label, owner),
+ identityLabel: typedIdentityLabel(label),
+ dcrOwner: owner,
+ isReconnect: false,
+ });
+ };
+
return (
// Non-modal for the same reason as the health-check editor sheet: a modal
// dialog's react-remove-scroll locks the wheel to the dialog subtree, so
From 7bf45c04444d06eaab239f39c324456a3739bd0c Mon Sep 17 00:00:00 2001
From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com>
Date: Thu, 27 Aug 2026 21:46:20 -0700
Subject: [PATCH 2/2] Route DCR reconnect through the registration flow
A dynamically registered OAuth client is bound to the redirect URI it
registered with, so once the app's callback origin changed (127.0.0.1 to
localhost) Reconnect kept starting the flow against the stored client and
the authorization server rejected it, leaving no way to repair the
connection.
Reconnect now takes the same probe -> CIMD-or-register -> start route as
the initial connect, sharing one runner rather than a second copy of the
orchestration. Methods with a fixed, hand-registered app are unchanged.
Fixes #1542
---
.../dcr-reconnect-through-registration.md | 9 +
.../src/components/add-account-modal.test.ts | 118 ++++
.../src/components/add-account-modal.tsx | 549 +++++++++++-------
3 files changed, 459 insertions(+), 217 deletions(-)
create mode 100644 .changeset/dcr-reconnect-through-registration.md
diff --git a/.changeset/dcr-reconnect-through-registration.md b/.changeset/dcr-reconnect-through-registration.md
new file mode 100644
index 0000000000..dd0f1d8158
--- /dev/null
+++ b/.changeset/dcr-reconnect-through-registration.md
@@ -0,0 +1,9 @@
+---
+"@executor-js/react": patch
+---
+
+**Reconnecting a DCR connection now re-registers instead of reusing a stranded client**
+
+A dynamically registered OAuth client is bound to the redirect URI it registered with. Once the app's callback origin changed (127.0.0.1 to localhost), Reconnect still started the flow against the stored client, and the authorization server rejected it — leaving no way to repair the connection.
+
+Reconnect now takes the same probe → CIMD-or-register → start route as the initial connect, so the registration gateway replaces the stranded client against the current redirect URI. Methods with a fixed, hand-registered app are unaffected and keep using their stored client.
diff --git a/packages/react/src/components/add-account-modal.test.ts b/packages/react/src/components/add-account-modal.test.ts
index 7094deeaf5..77114d5216 100644
--- a/packages/react/src/components/add-account-modal.test.ts
+++ b/packages/react/src/components/add-account-modal.test.ts
@@ -17,6 +17,7 @@ import {
connectionLabelForHost,
createCredentialPayloadOrigin,
DEFAULT_CONNECTION_OWNER,
+ hasDcr,
mergeCustomMethods,
oauthIdentityLabelFromHealth,
runAutomaticOAuthConnect,
@@ -499,6 +500,123 @@ describe("runAutomaticOAuthConnect", () => {
});
});
+// ---------------------------------------------------------------------------
+// Reconnect (issue #1542). A DCR client is bound to the redirect URI it
+// registered with, so when the app's callback origin moves (127.0.0.1 ->
+// localhost) re-authorizing against the STORED client is rejected by the
+// authorization server and the connection can never be repaired. Reconnect
+// therefore takes the same probe -> (CIMD | register) -> start route as the
+// initial connect, which re-registers against the CURRENT redirect URI.
+//
+// `hasDcr` is the routing decision the modal's reconnect handoff makes; the
+// orchestrator run below is what that decision buys.
+// ---------------------------------------------------------------------------
+describe("hasDcr (which methods reconnect through the automatic path)", () => {
+ const oauthMethod = (oauth: NonNullable): AuthMethod => ({
+ id: "oauth",
+ label: "OAuth",
+ kind: "oauth",
+ source: "spec",
+ template: AuthTemplateSlug.make("oauth"),
+ placements: [{ carrier: "header", name: "Authorization", prefix: "Bearer " }],
+ oauth,
+ });
+
+ it("routes a method advertising dynamic registration", () => {
+ expect(hasDcr(oauthMethod({ supportsDynamicRegistration: true }))).toBe(true);
+ });
+
+ it("routes a method carrying a discovery URL we can probe at connect time", () => {
+ expect(hasDcr(oauthMethod({ discoveryUrl: "https://mcp.example.com/mcp" }))).toBe(true);
+ });
+
+ // A fixed, hand-registered app has no stranded-client problem: its redirect
+ // URI is whatever the human entered, so reconnect keeps using it directly.
+ it("leaves a plain registered-app OAuth method on the stored-client path", () => {
+ expect(hasDcr(oauthMethod({ authorizationUrl: "https://auth.example.com/authorize" }))).toBe(
+ false,
+ );
+ expect(hasDcr(oauthMethod({ supportsDynamicRegistration: false }))).toBe(false);
+ });
+
+ it("never routes a non-OAuth or absent method", () => {
+ expect(hasDcr(apiKeyMethod("api", "spec"))).toBe(false);
+ expect(hasDcr(undefined)).toBe(false);
+ expect(hasDcr(null)).toBe(false);
+ });
+});
+
+describe("runAutomaticOAuthConnect (reconnect)", () => {
+ // The fix for #1542: reconnect must MINT a client against the redirect URI in
+ // force now, not reuse the one the connection was originally bound to.
+ it("re-registers against the current redirect URI and starts on the fresh client", async () => {
+ const popup = popupSpy();
+ let registerArgs: RegisterArgs | null = null;
+ let startArgs: StartArgs | null = null;
+
+ const outcome = await runDcrConnect(
+ {
+ ...popup,
+ probe: (): Promise =>
+ Promise.resolve({
+ authorizationUrl: "https://auth.example.com/authorize",
+ tokenUrl: "https://auth.example.com/token",
+ registrationEndpoint: "https://auth.example.com/register",
+ }),
+ register: (args: RegisterArgs): Promise => {
+ registerArgs = args;
+ return Promise.resolve(OAuthClientSlug.make("reconnected-app"));
+ },
+ start: (args: StartArgs): void => {
+ startArgs = args;
+ },
+ },
+ {
+ discoveryUrl: "https://mcp.example.com/mcp",
+ // The connection was registered under the old origin; this is the one
+ // the app serves its callback on now.
+ redirectUri: "http://localhost:4788/api/oauth/callback",
+ owner: "user" as Owner,
+ integration: TEST_INTEGRATION,
+ },
+ );
+
+ expect(outcome).toEqual({ kind: "started", flow: "dcr" });
+ expect(registerArgs!.redirectUri).toBe("http://localhost:4788/api/oauth/callback");
+ // The stranded client is replaced, not reused.
+ expect(startArgs!.client).toBe(OAuthClientSlug.make("reconnected-app"));
+ expect(startArgs!.owner).toBe("user");
+ });
+
+ // Reconnect keeps the BYO picker as its recovery path, exactly as connect
+ // does, rather than dead-ending on a server that cannot self-register.
+ it("falls back with the probe when the server advertises no registration endpoint", async () => {
+ const popup = popupSpy();
+ const outcome = await runDcrConnect(
+ {
+ ...popup,
+ probe: (): Promise =>
+ Promise.resolve({
+ authorizationUrl: "https://auth.example.com/authorize",
+ tokenUrl: "https://auth.example.com/token",
+ }),
+ register: (): Promise =>
+ Promise.resolve(OAuthClientSlug.make("unexpected")),
+ start: (): void => {},
+ },
+ {
+ discoveryUrl: "https://mcp.example.com/mcp",
+ redirectUri: "http://localhost:4788/api/oauth/callback",
+ owner: "user" as Owner,
+ integration: TEST_INTEGRATION,
+ },
+ );
+
+ expect(outcome).toMatchObject({ kind: "fallback", reason: "no-registration-endpoint" });
+ expect(popup.calls).toEqual(["reserve", "release"]);
+ });
+});
+
describe("runDcrConnect popup reservation", () => {
const probeOk = (): Promise =>
Promise.resolve({
diff --git a/packages/react/src/components/add-account-modal.tsx b/packages/react/src/components/add-account-modal.tsx
index 43f72ef5e6..3213fcf364 100644
--- a/packages/react/src/components/add-account-modal.tsx
+++ b/packages/react/src/components/add-account-modal.tsx
@@ -933,6 +933,32 @@ export async function runAutomaticOAuthConnect(
return { kind: "started", flow: "dcr" };
}
+/**
+ * Can this method go through {@link runAutomaticOAuthConnect} at all?
+ *
+ * True when the integration advertises dynamic registration (MCP oauth2) OR
+ * carries a discovery URL we can probe at connect time — the probe decides
+ * between CIMD and DCR from there. Shared by the connect button and the
+ * reconnect handoff so both take the same route for the same method.
+ */
+export const hasDcr = (method: AuthMethod | undefined | null): boolean =>
+ method?.kind === "oauth" &&
+ (method.oauth?.supportsDynamicRegistration === true || method.oauth?.discoveryUrl != null);
+
+/** What a caller of the modal's `startAutomaticOAuthConnect` decides for itself. */
+type AutomaticOAuthConnectRequest = {
+ readonly method: AuthMethod;
+ readonly owner: Owner;
+ readonly connectionName: ConnectionName;
+ /** Stored on the connection; undefined leaves it untouched. */
+ readonly identityLabel: string | undefined;
+ /** What the user typed, used to auto-name a NEW connection after connect. */
+ readonly typedLabel: string;
+ /** A reconnect re-authorizes a connection that already exists; a connect
+ * creates one. The only behavioral difference between the two paths. */
+ readonly mode: "connect" | "reconnect";
+};
+
// ---------------------------------------------------------------------------
// One row in the OAuth app picker: a radio-select Label plus an actions menu
// (Edit / Remove) so the registered app can be managed inline. The page that
@@ -1574,13 +1600,9 @@ function AddAccountModalView(props: AddAccountModalProps) {
: `?${placement.name || "api_key"}=`;
return `${lead}${placement.prefix ?? ""}`;
}, [method, singleInput, isEnvMethod]);
- // DCR-capable: the integration advertises dynamic registration (MCP oauth2),
- // OR carries a discovery URL we can probe at connect time. When DCR-capable
- // and not yet fallen back, we skip the app picker entirely (Option A).
- const isDcr =
- !cimdActive &&
- isOAuth &&
- (method?.oauth?.supportsDynamicRegistration === true || method?.oauth?.discoveryUrl != null);
+ // DCR-capable (see `hasDcr`). When DCR-capable and not yet fallen back, we
+ // skip the app picker entirely (Option A).
+ const isDcr = !cimdActive && hasDcr(method);
const dcrActive = isDcr && !dcrFailed;
const automaticOAuthActive = cimdActive || dcrActive;
@@ -1780,90 +1802,42 @@ function AddAccountModalView(props: AddAccountModalProps) {
// OAuth popup flow's busy state die with this instance.
const close = useCallback(() => onOpenChange(false), [onOpenChange]);
- useEffect(() => {
- const handoff = initialState;
- const oauthClient = handoff?.oauthClient;
- if (!handoff || oauthClient?.action !== "reconnect") return;
- if (oauthReconnectOpenedKey.current === handoff.key) return;
- const client = oauthClient.slug;
- const clientOwner = oauthClient.owner ?? handoff.owner;
- const connectionOwner = handoff.owner;
- const connectionName = handoff.label;
- const oauthMethod = handoff.template
- ? allMethods.find(
- (m: AuthMethod) =>
- m.kind === "oauth" &&
- (m.id === handoff.template || String(m.template) === handoff.template),
- )
- : allMethods.find((m: AuthMethod) => m.kind === "oauth");
- if (!client || !clientOwner || !connectionOwner || !connectionName || !oauthMethod) return;
-
- oauthReconnectOpenedKey.current = handoff.key;
- setMethodId(oauthMethod.id);
- void oauthPopup.start({
- payload: {
- client: OAuthClientSlug.make(client),
- clientOwner,
- owner: connectionOwner,
- name: ConnectionName.make(connectionName),
- integration,
- template: oauthMethod.template,
- ...(handoff.identityLabel !== undefined ? { identityLabel: handoff.identityLabel } : {}),
- },
- onAuthorizationStarted: () => {
- trackEvent("connection_reconnected", {
- integration_slug: String(integration),
- owner: connectionOwner,
- success: true,
- });
- },
- onError: () => {
- trackEvent("connection_reconnected", {
- integration_slug: String(integration),
- owner: connectionOwner,
- success: false,
- });
- },
- onSuccess: () => {
- toast.success("Reconnected");
- close();
- },
- });
- }, [initialState, allMethods, integration, oauthPopup, close]);
-
- const probeAndAutoNameOAuthConnection = async (
- connection: OAuthCompletionPayload,
- typedLabel: string,
- ): Promise => {
- const check = await doCheckConnectionHealth({
- params: {
- owner: connection.owner,
- integration: connection.integration,
- name: connection.name,
- },
- query: {},
- reactivityKeys: connectionCheckKeys,
- });
- if (Exit.isFailure(check)) return;
- const nextIdentityLabel = oauthIdentityLabelFromHealth({
- result: check.value,
- typedLabel,
- storedIdentityLabel: connection.identityLabel,
- });
- if (nextIdentityLabel === null) return;
- const updated = await doUpdateConnection({
- params: {
- owner: connection.owner,
- integration: connection.integration,
- name: connection.name,
- },
- payload: { identityLabel: nextIdentityLabel },
- reactivityKeys: connectionWriteKeys,
- });
- if (Exit.isFailure(updated)) {
- toast.error(messageFromExit(updated, "Couldn't update connection name"));
- }
- };
+ // Stable identity: the reconnect effect below reaches this through
+ // `startAutomaticOAuthConnect`, so an identity that changed every render
+ // would re-run that effect on every keystroke.
+ const probeAndAutoNameOAuthConnection = useCallback(
+ async (connection: OAuthCompletionPayload, typedLabel: string): Promise => {
+ const check = await doCheckConnectionHealth({
+ params: {
+ owner: connection.owner,
+ integration: connection.integration,
+ name: connection.name,
+ },
+ query: {},
+ reactivityKeys: connectionCheckKeys,
+ });
+ if (Exit.isFailure(check)) return;
+ const nextIdentityLabel = oauthIdentityLabelFromHealth({
+ result: check.value,
+ typedLabel,
+ storedIdentityLabel: connection.identityLabel,
+ });
+ if (nextIdentityLabel === null) return;
+ const updated = await doUpdateConnection({
+ params: {
+ owner: connection.owner,
+ integration: connection.integration,
+ name: connection.name,
+ },
+ payload: { identityLabel: nextIdentityLabel },
+ reactivityKeys: connectionWriteKeys,
+ });
+ if (Exit.isFailure(updated)) {
+ toast.error(messageFromExit(updated, "Couldn't update connection name"));
+ }
+ },
+ [doCheckConnectionHealth, doUpdateConnection],
+ );
const credentialPayloadOrigin = createCredentialPayloadOrigin({
origin: credentialOrigin,
@@ -2155,23 +2129,27 @@ function AddAccountModalView(props: AddAccountModalProps) {
});
};
- const createCimdClient = async (args: CimdCreateClientArgs): Promise => {
- const exit = await doCreateOAuthClient({
- payload: {
- owner: args.owner,
- slug: args.slug,
- authorizationUrl: args.authorizationUrl,
- tokenUrl: args.tokenUrl,
- resource: args.resource ?? null,
- grant: args.grant,
- clientId: args.clientId,
- clientSecret: args.clientSecret,
- },
- reactivityKeys: oauthClientWriteKeys,
- });
- if (Exit.isFailure(exit)) return null;
- return exit.value.client;
- };
+ // Stable identity for the same reason as probeAndAutoNameOAuthConnection.
+ const createCimdClient = useCallback(
+ async (args: CimdCreateClientArgs): Promise => {
+ const exit = await doCreateOAuthClient({
+ payload: {
+ owner: args.owner,
+ slug: args.slug,
+ authorizationUrl: args.authorizationUrl,
+ tokenUrl: args.tokenUrl,
+ resource: args.resource ?? null,
+ grant: args.grant,
+ clientId: args.clientId,
+ clientSecret: args.clientSecret,
+ },
+ reactivityKeys: oauthClientWriteKeys,
+ });
+ if (Exit.isFailure(exit)) return null;
+ return exit.value.client;
+ },
+ [doCreateOAuthClient],
+ );
const handleCimdConnect = async () => {
const authorizationUrl = method?.oauth?.authorizationUrl;
@@ -2234,124 +2212,261 @@ function AddAccountModalView(props: AddAccountModalProps) {
}
};
- // Automatic discovered OAuth connect: probe once, then prefer CIMD or use DCR
+ // Automatic discovered OAuth: probe once, then prefer CIMD or use DCR
// according to the authorization server's advertised metadata. On failure we
// flip `dcrFailed` so the bring-your-own-app picker remains the recovery path.
+ //
+ // Reconnect runs through here too, and must (issue #1542): a DCR client is
+ // bound to the redirect URI it registered with, so once the app's callback
+ // origin moves (127.0.0.1 -> localhost) re-authorizing against the STORED
+ // client fails at the authorization server. Re-probing and re-registering is
+ // what replaces that stranded client, and it is exactly what the connect path
+ // already does — so both take one route rather than two that drift.
+ const startAutomaticOAuthConnect = useCallback(
+ async (request: AutomaticOAuthConnectRequest): Promise => {
+ const { method: requestMethod, owner: dcrOwner, mode } = request;
+ const reconnect = mode === "reconnect";
+ const discoveryUrl = requestMethod.oauth?.discoveryUrl ?? requestMethod.oauth?.tokenUrl;
+ if (!discoveryUrl) {
+ setDcrFailed(true);
+ return;
+ }
+ setDcrBusy(true);
+ const outcome = await runAutomaticOAuthConnect(
+ {
+ reserve: oauthPopup.reserve,
+ release: oauthPopup.releaseReservation,
+ probe: async (url: string): Promise => {
+ const exit = await doProbe({ payload: { url }, reactivityKeys: [] });
+ if (Exit.isFailure(exit)) return null;
+ return exit.value;
+ },
+ createCimdClient,
+ register: async (
+ args: DcrRegisterArgs,
+ ): Promise => {
+ const exit = await doRegisterDynamic({
+ payload: {
+ owner: args.owner,
+ slug: args.slug,
+ issuer: args.issuer ?? null,
+ registrationEndpoint: args.registrationEndpoint,
+ authorizationUrl: args.authorizationUrl,
+ tokenUrl: args.tokenUrl,
+ resource: args.resource ?? null,
+ scopes: args.scopes,
+ tokenEndpointAuthMethodsSupported: args.tokenEndpointAuthMethodsSupported,
+ clientName: args.clientName,
+ redirectUri: args.redirectUri,
+ originIntegration: args.originIntegration,
+ },
+ reactivityKeys: oauthClientWriteKeys,
+ });
+ if (Exit.isFailure(exit)) {
+ return {
+ error: messageFromExit(
+ exit,
+ "Automatic setup unavailable. Register an app instead.",
+ ),
+ };
+ }
+ return exit.value.client;
+ },
+ start: (args: DcrStartArgs): void => {
+ void oauthPopup.start({
+ reservation: args.reservation,
+ payload: {
+ client: args.client,
+ // DCR/CIMD mints the client under the connection owner, so the
+ // app and connection share one owner.
+ clientOwner: args.owner,
+ owner: dcrOwner,
+ name: request.connectionName,
+ integration,
+ template: requestMethod.template,
+ ...(reconnect ? {} : { newConnection: true }),
+ ...(request.identityLabel !== undefined
+ ? { identityLabel: request.identityLabel }
+ : {}),
+ },
+ ...(reconnect
+ ? {
+ onAuthorizationStarted: () => {
+ trackEvent("connection_reconnected", {
+ integration_slug: String(integration),
+ owner: dcrOwner,
+ success: true,
+ });
+ },
+ onError: () => {
+ trackEvent("connection_reconnected", {
+ integration_slug: String(integration),
+ owner: dcrOwner,
+ success: false,
+ });
+ },
+ }
+ : {}),
+ onSuccess: async (connection: OAuthCompletionPayload) => {
+ // A reconnect keeps the connection's existing name; only a new
+ // connection gets auto-named from what was probed.
+ if (!reconnect) {
+ await probeAndAutoNameOAuthConnection(connection, request.typedLabel);
+ }
+ toast.success(reconnect ? "Reconnected" : "Connection added");
+ close();
+ },
+ });
+ },
+ },
+ {
+ discoveryUrl,
+ // Only a genuine discovery URL (MCP) seeds the RFC 8707 resource
+ // indicator; the token-endpoint fallback baked into `discoveryUrl` must
+ // not, so pass the un-collapsed method value here.
+ resourceFallback: requestMethod.oauth?.discoveryUrl,
+ owner: dcrOwner,
+ // DCR slugs are server-keyed (Part A): the connect path no longer depends
+ // on the picker's app list, so it need not be threaded here.
+ declaredScopes: requestMethod.oauth?.scopes,
+ redirectUri: oauthCallbackUrl(),
+ integration,
+ cimd: {
+ integrationName,
+ clientIdMetadataDocumentUrl: oauthClientIdMetadataDocumentUrl(),
+ existingClients: clientSummaries,
+ },
+ },
+ );
+ setDcrBusy(false);
+ // `connection_oauth_started` measures the connect funnel; a reconnect
+ // reports through `connection_reconnected` on the popup callbacks above,
+ // so it must not also land here.
+ if (!reconnect) {
+ trackEvent("connection_oauth_started", {
+ integration_slug: String(integration),
+ owner: dcrOwner,
+ flow:
+ outcome.kind === "started"
+ ? outcome.flow
+ : "probe" in outcome && outcome.probe.clientIdMetadataDocumentSupported === true
+ ? "cimd"
+ : "dcr",
+ success: outcome.kind === "started",
+ ...(outcome.kind === "fallback" ? { dcr_fallback: true } : {}),
+ });
+ }
+ // Deliberately absent: a "popup-blocked" branch. Registering an app by hand
+ // does not make the browser open a window, so dropping to the BYO picker
+ // would send the user down a path that cannot succeed either. `reserve`
+ // already put the reason in `oauthPopup.error`, which the footer renders.
+ if (outcome.kind === "fallback") {
+ setOAuthFallbackProbe("probe" in outcome ? outcome.probe : null);
+ setDcrFailed(true);
+ // Surface the server's actionable rejection reason on the recovery view as
+ // an inline error card. Generic fallbacks (no message) fall through to the
+ // "register an app" empty state, which already guides the user.
+ setDcrFallbackMessage("message" in outcome ? (outcome.message ?? null) : null);
+ }
+ },
+ [
+ close,
+ clientSummaries,
+ createCimdClient,
+ doProbe,
+ doRegisterDynamic,
+ integration,
+ integrationName,
+ oauthPopup,
+ probeAndAutoNameOAuthConnection,
+ ],
+ );
+
const handleAutomaticOAuthConnect = async () => {
- const discoveryUrl = method?.oauth?.discoveryUrl ?? method?.oauth?.tokenUrl;
- if (!method || !discoveryUrl) {
+ if (!method) {
setDcrFailed(true);
return;
}
- const dcrOwner = owner;
- const connectionName = previewConnectionName(label, dcrOwner);
- const identityLabel = typedIdentityLabel(label);
- setDcrBusy(true);
- const outcome = await runAutomaticOAuthConnect(
- {
- reserve: oauthPopup.reserve,
- release: oauthPopup.releaseReservation,
- probe: async (url: string): Promise => {
- const exit = await doProbe({ payload: { url }, reactivityKeys: [] });
- if (Exit.isFailure(exit)) return null;
- return exit.value;
- },
- createCimdClient,
- register: async (
- args: DcrRegisterArgs,
- ): Promise => {
- const exit = await doRegisterDynamic({
- payload: {
- owner: args.owner,
- slug: args.slug,
- issuer: args.issuer ?? null,
- registrationEndpoint: args.registrationEndpoint,
- authorizationUrl: args.authorizationUrl,
- tokenUrl: args.tokenUrl,
- resource: args.resource ?? null,
- scopes: args.scopes,
- tokenEndpointAuthMethodsSupported: args.tokenEndpointAuthMethodsSupported,
- clientName: args.clientName,
- redirectUri: args.redirectUri,
- originIntegration: args.originIntegration,
- },
- reactivityKeys: oauthClientWriteKeys,
- });
- if (Exit.isFailure(exit)) {
- return {
- error: messageFromExit(exit, "Automatic setup unavailable. Register an app instead."),
- };
- }
- return exit.value.client;
- },
- start: (args: DcrStartArgs): void => {
- void oauthPopup.start({
- reservation: args.reservation,
- payload: {
- client: args.client,
- // DCR registers the client under the connection owner, so the app
- // and connection share one owner.
- clientOwner: args.owner,
- owner: args.owner,
- name: connectionName,
- integration,
- template: method.template,
- newConnection: true,
- ...(identityLabel !== undefined ? { identityLabel } : {}),
- },
- onSuccess: async (connection: OAuthCompletionPayload) => {
- await probeAndAutoNameOAuthConnection(connection, label);
- toast.success("Connection added");
- close();
- },
- });
- },
- },
- {
- discoveryUrl,
- // Only a genuine discovery URL (MCP) seeds the RFC 8707 resource
- // indicator; the token-endpoint fallback baked into `discoveryUrl` must
- // not, so pass the un-collapsed method value here.
- resourceFallback: method.oauth?.discoveryUrl,
- owner: dcrOwner,
- // DCR slugs are server-keyed (Part A): the connect path no longer depends
- // on the picker's app list, so it need not be threaded here.
- declaredScopes: method.oauth?.scopes,
- redirectUri: oauthCallbackUrl(),
+ await startAutomaticOAuthConnect({
+ method,
+ owner,
+ connectionName: previewConnectionName(label, owner),
+ identityLabel: typedIdentityLabel(label),
+ typedLabel: label,
+ mode: "connect",
+ });
+ };
+
+ // The reconnect handoff: a connection asked to be re-authorized, so open its
+ // OAuth flow immediately. Fires once per handoff key (tracked by ref), which
+ // is also what makes a re-render mid-flight harmless.
+ //
+ // A DCR-capable method re-runs the automatic path rather than reusing the
+ // stored client — see `startAutomaticOAuthConnect`. Everything else has a
+ // fixed, registered app, so it starts the popup against that client directly.
+ useEffect(() => {
+ const handoff = initialState;
+ const oauthClient = handoff?.oauthClient;
+ if (!handoff || oauthClient?.action !== "reconnect") return;
+ if (oauthReconnectOpenedKey.current === handoff.key) return;
+ const client = oauthClient.slug;
+ const clientOwner = oauthClient.owner ?? handoff.owner;
+ const connectionOwner = handoff.owner;
+ const connectionName = handoff.label;
+ const oauthMethod = handoff.template
+ ? allMethods.find(
+ (m: AuthMethod) =>
+ m.kind === "oauth" &&
+ (m.id === handoff.template || String(m.template) === handoff.template),
+ )
+ : allMethods.find((m: AuthMethod) => m.kind === "oauth");
+ if (!client || !clientOwner || !connectionOwner || !connectionName || !oauthMethod) return;
+
+ oauthReconnectOpenedKey.current = handoff.key;
+ setMethodId(oauthMethod.id);
+
+ if (hasDcr(oauthMethod)) {
+ void startAutomaticOAuthConnect({
+ method: oauthMethod,
+ owner: connectionOwner,
+ connectionName: ConnectionName.make(connectionName),
+ identityLabel: handoff.identityLabel,
+ typedLabel: connectionName,
+ mode: "reconnect",
+ });
+ return;
+ }
+
+ void oauthPopup.start({
+ payload: {
+ client: OAuthClientSlug.make(client),
+ clientOwner,
+ owner: connectionOwner,
+ name: ConnectionName.make(connectionName),
integration,
- cimd: {
- integrationName,
- clientIdMetadataDocumentUrl: oauthClientIdMetadataDocumentUrl(),
- existingClients: clientSummaries,
- },
+ template: oauthMethod.template,
+ ...(handoff.identityLabel !== undefined ? { identityLabel: handoff.identityLabel } : {}),
+ },
+ onAuthorizationStarted: () => {
+ trackEvent("connection_reconnected", {
+ integration_slug: String(integration),
+ owner: connectionOwner,
+ success: true,
+ });
+ },
+ onError: () => {
+ trackEvent("connection_reconnected", {
+ integration_slug: String(integration),
+ owner: connectionOwner,
+ success: false,
+ });
+ },
+ onSuccess: () => {
+ toast.success("Reconnected");
+ close();
},
- );
- setDcrBusy(false);
- trackEvent("connection_oauth_started", {
- integration_slug: String(integration),
- owner: dcrOwner,
- flow:
- outcome.kind === "started"
- ? outcome.flow
- : "probe" in outcome && outcome.probe.clientIdMetadataDocumentSupported === true
- ? "cimd"
- : "dcr",
- success: outcome.kind === "started",
- ...(outcome.kind === "fallback" ? { dcr_fallback: true } : {}),
});
- // Deliberately absent: a "popup-blocked" branch. Registering an app by hand
- // does not make the browser open a window, so dropping to the BYO picker
- // would send the user down a path that cannot succeed either. `reserve`
- // already put the reason in `oauthPopup.error`, which the footer renders.
- if (outcome.kind === "fallback") {
- setOAuthFallbackProbe("probe" in outcome ? outcome.probe : null);
- setDcrFailed(true);
- // Surface the server's actionable rejection reason on the recovery view as
- // an inline error card. Generic fallbacks (no message) fall through to the
- // "register an app" empty state, which already guides the user.
- setDcrFallbackMessage("message" in outcome ? (outcome.message ?? null) : null);
- }
- };
+ }, [initialState, allMethods, integration, oauthPopup, close, startAutomaticOAuthConnect]);
return (
// Non-modal for the same reason as the health-check editor sheet: a modal