History

Deterministic DCR slugs; thread issuer through probe and connect paths

Rhys Sullivan committed 4 months ago

Commit 8155ab21 parent a5d2cd26 files · +262 −8Browse files

Unified · Split

6 files
  1. packages/core/sdk/src/oauth-register-dynamic.test.ts +225 −3
  2. packages/react/src/components/add-account-modal.test.ts +5 −0
  3. packages/react/src/components/add-account-modal.tsx +8 −2
  4. packages/react/src/components/oauth-client-form.tsx +7 −3
  5. packages/react/src/plugins/use-effective-oauth-client.test.ts +10 −0
  6. packages/react/src/plugins/use-effective-oauth-client.tsx +7 −0
packages/core/sdk/src/oauth-register-dynamic.test.ts+225−3
@@ -25,6 +25,18 @@ const TEMPLATE = AuthTemplateSlug.make("oauth"); · 24 unmodified lines
25const CLIENT = OAuthClientSlug.make("acme-dcr");25const CLIENT = OAuthClientSlug.make("acme-dcr");
26const FLOW_REDIRECT_URI = "https://localhost:5394/api/oauth/callback";26const FLOW_REDIRECT_URI = "https://localhost:5394/api/oauth/callback";
27 27
28const dcrSlugFor = (issuerUrl: string): OAuthClientSlug =>
29 OAuthClientSlug.make(
30 `dcr-${new URL(issuerUrl).host
31 .toLowerCase()
32 .replace(/[^a-z0-9]+/g, "-")
33 .replace(/^-+|-+$/g, "")}`,
34 );
35
36const registerRequestCount = (
37 requests: readonly { readonly path: string; readonly method: string }[],
38): number => requests.filter((r) => r.path === "/register" && r.method === "POST").length;
39
28const oauthPlugin = definePlugin(() => ({40const oauthPlugin = definePlugin(() => ({
29 id: "acme" as const,41 id: "acme" as const,
30 storage: () => ({}),42 storage: () => ({}),
@@ -56,6 +68,7 @@ describe("oauth.registerDynamicClient", () => { · 25 unmodified lines
56 // Probe surfaces the registration endpoint + advertised auth methods so68 // Probe surfaces the registration endpoint + advertised auth methods so
57 // the caller knows a public client is allowed.69 // the caller knows a public client is allowed.
58 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });70 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });
71 expect(probe.issuer).toBe(server.issuerUrl);
59 expect(probe.registrationEndpoint).toBe(server.registrationEndpoint);72 expect(probe.registrationEndpoint).toBe(server.registrationEndpoint);
60 expect(probe.tokenEndpointAuthMethodsSupported).toContain("none");73 expect(probe.tokenEndpointAuthMethodsSupported).toContain("none");
61 expect(probe.resource).toBe(server.mcpResourceUrl);74 expect(probe.resource).toBe(server.mcpResourceUrl);
@@ -64,6 +77,7 @@ describe("oauth.registerDynamicClient", () => { · 2 unmodified lines
64 const slug = yield* executor.oauth.registerDynamicClient({77 const slug = yield* executor.oauth.registerDynamicClient({
65 owner: "org",78 owner: "org",
66 slug: CLIENT,79 slug: CLIENT,
80 issuer: probe.issuer,
67 registrationEndpoint: probe.registrationEndpoint!,81 registrationEndpoint: probe.registrationEndpoint!,
68 authorizationUrl: probe.authorizationUrl,82 authorizationUrl: probe.authorizationUrl,
69 tokenUrl: probe.tokenUrl,83 tokenUrl: probe.tokenUrl,
@@ -74,12 +88,13 @@ describe("oauth.registerDynamicClient", () => { · 4 unmodified lines
74 redirectUri: FLOW_REDIRECT_URI,88 redirectUri: FLOW_REDIRECT_URI,
75 originIntegration: INTEG,89 originIntegration: INTEG,
76 });90 });
77 expect(String(slug)).toBe(String(CLIENT));91 const expectedSlug = dcrSlugFor(server.issuerUrl);
92 expect(String(slug)).toBe(String(expectedSlug));
78 93
79 // The minted client appears in listClients with a server-issued94 // The minted client appears in listClients with a server-issued
80 // client_id and NO secret ever projected.95 // client_id and NO secret ever projected.
81 const clients = yield* executor.oauth.listClients();96 const clients = yield* executor.oauth.listClients();
82 const minted = clients.find((c) => String(c.slug) === String(CLIENT));97 const minted = clients.find((c) => String(c.slug) === String(expectedSlug));
83 expect(minted).toBeDefined();98 expect(minted).toBeDefined();
84 expect(minted!.owner).toBe("org");99 expect(minted!.owner).toBe("org");
85 expect(minted!.grant).toBe("authorization_code");100 expect(minted!.grant).toBe("authorization_code");
@@ -98,7 +113,7 @@ describe("oauth.registerDynamicClient", () => { · 12 unmodified lines
98 // PKCE flow with NO client_secret on the token exchange.113 // PKCE flow with NO client_secret on the token exchange.
99 const started = yield* executor.oauth.start({114 const started = yield* executor.oauth.start({
100 owner: "org",115 owner: "org",
101 client: CLIENT,116 client: expectedSlug,
102 clientOwner: "org",117 clientOwner: "org",
103 name: ConnectionName.make("main"),118 name: ConnectionName.make("main"),
104 integration: INTEG,119 integration: INTEG,
@@ -145,6 +160,213 @@ describe("oauth.registerDynamicClient", () => { · 40 unmodified lines
145 ),160 ),
146 );161 );
147 162
163 it.effect("reuses an existing DCR client for the same owner and authorization server", () =>
164 Effect.scoped(
165 Effect.gen(function* () {
166 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
167 const { executor } = yield* makeTestWorkspaceHarness({ plugins });
168 yield* executor.acme.seed();
169 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });
170
171 const first = yield* executor.oauth.registerDynamicClient({
172 owner: "org",
173 slug: OAuthClientSlug.make("first-attempt"),
174 issuer: probe.issuer,
175 registrationEndpoint: probe.registrationEndpoint!,
176 authorizationUrl: probe.authorizationUrl,
177 tokenUrl: probe.tokenUrl,
178 resource: probe.resource,
179 scopes: ["read"],
180 tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported,
181 clientName: "Acme DCR",
182 redirectUri: FLOW_REDIRECT_URI,
183 originIntegration: INTEG,
184 });
185 yield* server.clearRequests;
186
187 const second = yield* executor.oauth.registerDynamicClient({
188 owner: "org",
189 slug: OAuthClientSlug.make("second-attempt"),
190 issuer: probe.issuer,
191 registrationEndpoint: probe.registrationEndpoint!,
192 authorizationUrl: probe.authorizationUrl,
193 tokenUrl: probe.tokenUrl,
194 resource: probe.resource,
195 scopes: ["read"],
196 tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported,
197 clientName: "Other integration DCR",
198 redirectUri: FLOW_REDIRECT_URI,
199 originIntegration: IntegrationSlug.make("other"),
200 });
201
202 expect(second).toBe(first);
203 const requests = yield* server.requests;
204 expect(registerRequestCount(requests)).toBe(0);
205 }),
206 ),
207 );
208
209 it.effect("does not reuse a DCR client across owners", () =>
210 Effect.scoped(
211 Effect.gen(function* () {
212 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
213 const { executor } = yield* makeTestWorkspaceHarness({ plugins });
214 yield* executor.acme.seed();
215 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });
216
217 const orgSlug = yield* executor.oauth.registerDynamicClient({
218 owner: "org",
219 slug: OAuthClientSlug.make("org-attempt"),
220 issuer: probe.issuer,
221 registrationEndpoint: probe.registrationEndpoint!,
222 authorizationUrl: probe.authorizationUrl,
223 tokenUrl: probe.tokenUrl,
224 resource: probe.resource,
225 scopes: ["read"],
226 tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported,
227 clientName: "Org DCR",
228 redirectUri: FLOW_REDIRECT_URI,
229 originIntegration: INTEG,
230 });
231 yield* server.clearRequests;
232
233 const userSlug = yield* executor.oauth.registerDynamicClient({
234 owner: "user",
235 slug: OAuthClientSlug.make("user-attempt"),
236 issuer: probe.issuer,
237 registrationEndpoint: probe.registrationEndpoint!,
238 authorizationUrl: probe.authorizationUrl,
239 tokenUrl: probe.tokenUrl,
240 resource: probe.resource,
241 scopes: ["read"],
242 tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported,
243 clientName: "User DCR",
244 redirectUri: FLOW_REDIRECT_URI,
245 originIntegration: INTEG,
246 });
247
248 expect(String(userSlug)).toBe(String(orgSlug));
249 const requests = yield* server.requests;
250 expect(registerRequestCount(requests)).toBe(1);
251 }),
252 ),
253 );
254
255 it.effect("reuses legacy DCR-looking rows with no stored issuer by token host", () =>
256 Effect.scoped(
257 Effect.gen(function* () {
258 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
259 const { config, executor } = yield* makeTestWorkspaceHarness({ plugins });
260 yield* executor.acme.seed();
261 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });
262 const legacySlug = OAuthClientSlug.make("cloudflare-mcp");
263
264 yield* executor.oauth.createClient({
265 owner: "org",
266 slug: legacySlug,
267 authorizationUrl: probe.authorizationUrl,
268 tokenUrl: probe.tokenUrl,
269 resource: server.mcpResourceUrl,
270 grant: "authorization_code",
271 clientId: "legacy-dcr-client",
272 clientSecret: "",
273 });
274 yield* Effect.promise(() =>
275 config.db.updateMany("oauth_client", {
276 where: (b) => b("slug", "=", String(legacySlug)),
277 set: { origin_kind: null, origin_integration: null, origin_issuer: null },
278 }),
279 );
280 yield* server.clearRequests;
281
282 const reused = yield* executor.oauth.registerDynamicClient({
283 owner: "org",
284 slug: OAuthClientSlug.make("new-attempt"),
285 issuer: probe.issuer,
286 registrationEndpoint: probe.registrationEndpoint!,
287 authorizationUrl: probe.authorizationUrl,
288 tokenUrl: probe.tokenUrl,
289 resource: server.mcpResourceUrl,
290 scopes: ["read"],
291 tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported,
292 clientName: "Acme DCR",
293 redirectUri: FLOW_REDIRECT_URI,
294 originIntegration: INTEG,
295 });
296
297 expect(reused).toBe(legacySlug);
298 const requests = yield* server.requests;
299 expect(registerRequestCount(requests)).toBe(0);
300 }),
301 ),
302 );
303
304 it.effect("uses resource to distinguish DCR clients only after an issuer already differs", () =>
305 Effect.scoped(
306 Effect.gen(function* () {
307 const server = yield* serveOAuthTestServer({ scopes: ["read"] });
308 const { executor } = yield* makeTestWorkspaceHarness({ plugins });
309 yield* executor.acme.seed();
310 const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });
311 const resourceA = `${server.issuerUrl}/mcp/a`;
312 const resourceB = `${server.issuerUrl}/mcp/b`;
313
314 const first = yield* executor.oauth.registerDynamicClient({
315 owner: "org",
316 slug: OAuthClientSlug.make("first-resource"),
317 issuer: probe.issuer,
318 registrationEndpoint: probe.registrationEndpoint!,
319 authorizationUrl: probe.authorizationUrl,
320 tokenUrl: probe.tokenUrl,
321 resource: resourceA,
322 scopes: ["read"],
323 tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported,
324 clientName: "Resource A",
325 redirectUri: FLOW_REDIRECT_URI,
326 originIntegration: INTEG,
327 });
328 expect(String(first)).toBe(String(dcrSlugFor(server.issuerUrl)));
329
330 const second = yield* executor.oauth.registerDynamicClient({
331 owner: "org",
332 slug: OAuthClientSlug.make("second-resource"),
333 issuer: probe.issuer,
334 registrationEndpoint: probe.registrationEndpoint!,
335 authorizationUrl: probe.authorizationUrl,
336 tokenUrl: probe.tokenUrl,
337 resource: resourceB,
338 scopes: ["read"],
339 tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported,
340 clientName: "Resource B",
341 redirectUri: FLOW_REDIRECT_URI,
342 originIntegration: INTEG,
343 });
344 expect(String(second)).not.toBe(String(first));
345 expect(String(second)).toContain(String(first));
346 yield* server.clearRequests;
347
348 const third = yield* executor.oauth.registerDynamicClient({
349 owner: "org",
350 slug: OAuthClientSlug.make("third-resource"),
351 issuer: probe.issuer,
352 registrationEndpoint: probe.registrationEndpoint!,
353 authorizationUrl: probe.authorizationUrl,
354 tokenUrl: probe.tokenUrl,
355 resource: resourceB,
356 scopes: ["read"],
357 tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported,
358 clientName: "Resource B again",
359 redirectUri: FLOW_REDIRECT_URI,
360 originIntegration: IntegrationSlug.make("other"),
361 });
362
363 expect(third).toBe(second);
364 const requests = yield* server.requests;
365 expect(registerRequestCount(requests)).toBe(0);
366 }),
367 ),
368 );
369
148 // Regression: issue #770. Vercel (and other RFC 8252-strict servers) only370 // Regression: issue #770. Vercel (and other RFC 8252-strict servers) only
149 // approve loopback redirect URIs for anonymous DCR, so a hosted/tailnet/LAN371 // approve loopback redirect URIs for anonymous DCR, so a hosted/tailnet/LAN
150 // origin trips `invalid_redirect_uri`. The failure must explain the loopback372 // origin trips `invalid_redirect_uri`. The failure must explain the loopback
packages/react/src/components/add-account-modal.test.ts+5−0
@@ -31,6 +31,7 @@ const apiKeyMethod = (id: string, source: "spec" | "custom", template = id): Aut · 30 unmodified lines
31});31});
32 32
33type ProbeResult = {33type ProbeResult = {
34 readonly issuer?: string | null;
34 readonly authorizationUrl: string;35 readonly authorizationUrl: string;
35 readonly tokenUrl: string;36 readonly tokenUrl: string;
36 readonly resource?: string | null;37 readonly resource?: string | null;
@@ -42,6 +43,7 @@ type ProbeResult = { · 5 unmodified lines
42type RegisterArgs = {43type RegisterArgs = {
43 readonly owner: Owner;44 readonly owner: Owner;
44 readonly slug: OAuthClientSlug;45 readonly slug: OAuthClientSlug;
46 readonly issuer?: string | null;
45 readonly registrationEndpoint: string;47 readonly registrationEndpoint: string;
46 readonly authorizationUrl: string;48 readonly authorizationUrl: string;
47 readonly tokenUrl: string;49 readonly tokenUrl: string;
@@ -306,6 +308,7 @@ describe("runDcrConnect", () => { · 258 unmodified lines
306 const probe = (_url: string): Promise<ProbeResult | null> => {308 const probe = (_url: string): Promise<ProbeResult | null> => {
307 calls.push("probe");309 calls.push("probe");
308 return Promise.resolve({310 return Promise.resolve({
311 issuer: "https://auth.example.com",
309 authorizationUrl: "https://auth.example.com/authorize",312 authorizationUrl: "https://auth.example.com/authorize",
310 tokenUrl: "https://auth.example.com/token",313 tokenUrl: "https://auth.example.com/token",
311 resource: "https://mcp.example.com/mcp",314 resource: "https://mcp.example.com/mcp",
@@ -342,6 +345,8 @@ describe("runDcrConnect", () => { · 30 unmodified lines
342 // DCR always mints an authorization-code/PKCE client; callers do not choose345 // DCR always mints an authorization-code/PKCE client; callers do not choose
343 // a grant here.346 // a grant here.
344 expect(registerArgs).not.toBeNull();347 expect(registerArgs).not.toBeNull();
348 expect(String(registerArgs!.slug)).toBe("dcr-auth-example-com");
349 expect(registerArgs!.issuer).toBe("https://auth.example.com");
345 expect(registerArgs!.registrationEndpoint).toBe("https://auth.example.com/register");350 expect(registerArgs!.registrationEndpoint).toBe("https://auth.example.com/register");
346 expect(registerArgs!.authorizationUrl).toBe("https://auth.example.com/authorize");351 expect(registerArgs!.authorizationUrl).toBe("https://auth.example.com/authorize");
347 expect(registerArgs!.tokenUrl).toBe("https://auth.example.com/token");352 expect(registerArgs!.tokenUrl).toBe("https://auth.example.com/token");
packages/react/src/components/add-account-modal.tsx+8−2
@@ -47,6 +47,7 @@ import { · 46 unmodified lines
47import {47import {
48 clientDisplayName,48 clientDisplayName,
49 clientHost,49 clientHost,
50 dcrClientSlug,
50 uniqueClientSlug,51 uniqueClientSlug,
51 useOAuthClientsForIntegration,52 useOAuthClientsForIntegration,
52 type OAuthClientOption,53 type OAuthClientOption,
@@ -613,6 +614,7 @@ export async function runCimdConnect( · 560 unmodified lines
613 614
614/** Discovery result from the probe step (subset of the `probeOAuth` response). */615/** Discovery result from the probe step (subset of the `probeOAuth` response). */
615type DcrProbeResult = {616type DcrProbeResult = {
617 readonly issuer?: string | null;
616 readonly authorizationUrl: string;618 readonly authorizationUrl: string;
617 readonly tokenUrl: string;619 readonly tokenUrl: string;
618 readonly resource?: string | null;620 readonly resource?: string | null;
@@ -624,6 +626,7 @@ type DcrProbeResult = { · 5 unmodified lines
624type DcrRegisterArgs = {626type DcrRegisterArgs = {
625 readonly owner: Owner;627 readonly owner: Owner;
626 readonly slug: OAuthClientSlug;628 readonly slug: OAuthClientSlug;
629 readonly issuer?: string | null;
627 readonly registrationEndpoint: string;630 readonly registrationEndpoint: string;
628 readonly authorizationUrl: string;631 readonly authorizationUrl: string;
629 readonly tokenUrl: string;632 readonly tokenUrl: string;
@@ -684,7 +687,7 @@ type RunDcrConnectInput = { · 54 unmodified lines
684 readonly resourceFallback?: string;687 readonly resourceFallback?: string;
685 readonly owner: Owner;688 readonly owner: Owner;
686 readonly integrationName: string;689 readonly integrationName: string;
687 /** The owner's existing client slugs, so the minted slug stays unique. */690 /** Kept for callers that still pass picker state. DCR slugs are server keyed. */
688 readonly existingSlugs: readonly string[];691 readonly existingSlugs: readonly string[];
689 /** Scopes declared by the integration's method (override the probed ones). */692 /** Scopes declared by the integration's method (override the probed ones). */
690 readonly declaredScopes?: readonly string[];693 readonly declaredScopes?: readonly string[];
@@ -718,11 +721,12 @@ export async function runDcrConnect( · 27 unmodified lines
718 const registrationEndpoint = probe.registrationEndpoint;721 const registrationEndpoint = probe.registrationEndpoint;
719 if (!registrationEndpoint) return { kind: "fallback", reason: "no-registration-endpoint", probe };722 if (!registrationEndpoint) return { kind: "fallback", reason: "no-registration-endpoint", probe };
720 723
721 const slug = uniqueClientSlug(input.integrationName, input.existingSlugs);724 const slug = dcrClientSlug(probe.issuer ?? registrationEndpoint);
722 const scopes = registrationScopes(input.declaredScopes ?? [], probe.scopesSupported ?? []);725 const scopes = registrationScopes(input.declaredScopes ?? [], probe.scopesSupported ?? []);
723 const minted = await deps.register({726 const minted = await deps.register({
724 owner: input.owner,727 owner: input.owner,
725 slug,728 slug,
729 issuer: probe.issuer ?? null,
726 registrationEndpoint,730 registrationEndpoint,
727 authorizationUrl: probe.authorizationUrl,731 authorizationUrl: probe.authorizationUrl,
728 tokenUrl: probe.tokenUrl,732 tokenUrl: probe.tokenUrl,
@@ -1444,6 +1448,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 715 unmodified lines
1444 payload: {1448 payload: {
1445 owner: args.owner,1449 owner: args.owner,
1446 slug: args.slug,1450 slug: args.slug,
1451 issuer: args.issuer ?? null,
1447 registrationEndpoint: args.registrationEndpoint,1452 registrationEndpoint: args.registrationEndpoint,
1448 authorizationUrl: args.authorizationUrl,1453 authorizationUrl: args.authorizationUrl,
1449 tokenUrl: args.tokenUrl,1454 tokenUrl: args.tokenUrl,
@@ -1602,6 +1607,7 @@ function AddAccountModalView(props: AddAccountModalProps) { · 152 unmodified lines
1602 null,1607 null,
1603 scopes: method.oauth?.scopes,1608 scopes: method.oauth?.scopes,
1604 discoveredScopes: oauthFallbackProbe?.scopesSupported,1609 discoveredScopes: oauthFallbackProbe?.scopesSupported,
1610 issuer: oauthFallbackProbe?.issuer ?? null,
1605 registrationEndpoint:1611 registrationEndpoint:
1606 method.oauth?.registrationEndpoint ??1612 method.oauth?.registrationEndpoint ??
1607 oauthFallbackProbe?.registrationEndpoint ??1613 oauthFallbackProbe?.registrationEndpoint ??
packages/react/src/components/oauth-client-form.tsx+7−3
@@ -9,7 +9,7 @@ import { ownerLabelForHost } from "../api/owner-display"; · 8 unmodified lines
9import { trackEvent } from "../api/analytics";9import { trackEvent } from "../api/analytics";
10import { useOrganizationId } from "../api/organization-context";10import { useOrganizationId } from "../api/organization-context";
11import { oauthClientWriteKeys } from "../api/reactivity-keys";11import { oauthClientWriteKeys } from "../api/reactivity-keys";
12import { uniqueClientSlug } from "../plugins/use-effective-oauth-client";12import { dcrClientSlug, uniqueClientSlug } from "../plugins/use-effective-oauth-client";
13import { oauthCallbackUrl } from "../plugins/oauth-sign-in";13import { oauthCallbackUrl } from "../plugins/oauth-sign-in";
14import {14import {
15 ConnectionOwnerDropdown,15 ConnectionOwnerDropdown,
@@ -36,6 +36,7 @@ import { RadioGroup, RadioGroupItem } from "./radio-group"; · 20 unmodified lines
36// ---------------------------------------------------------------------------36// ---------------------------------------------------------------------------
37 37
38export interface OAuthClientFormPrefill {38export interface OAuthClientFormPrefill {
39 readonly issuer?: string | null;
39 readonly authorizationUrl?: string;40 readonly authorizationUrl?: string;
40 readonly tokenUrl?: string;41 readonly tokenUrl?: string;
41 readonly resource?: string | null;42 readonly resource?: string | null;
@@ -147,6 +148,7 @@ export function OAuthClientForm(props: { · 105 unmodified lines
147 const [clientId, setClientId] = useState(prefill?.clientId ?? "");148 const [clientId, setClientId] = useState(prefill?.clientId ?? "");
148 const [clientSecret, setClientSecret] = useState("");149 const [clientSecret, setClientSecret] = useState("");
149 const [issuerUrl, setIssuerUrl] = useState("");150 const [issuerUrl, setIssuerUrl] = useState("");
151 const [discoveredIssuer, setDiscoveredIssuer] = useState<string | null>(prefill?.issuer ?? null);
150 const [authorizationUrl, setAuthorizationUrl] = useState(prefill?.authorizationUrl ?? "");152 const [authorizationUrl, setAuthorizationUrl] = useState(prefill?.authorizationUrl ?? "");
151 const [tokenUrl, setTokenUrl] = useState(prefill?.tokenUrl ?? "");153 const [tokenUrl, setTokenUrl] = useState(prefill?.tokenUrl ?? "");
152 const [resource, setResource] = useState(prefill?.resource ?? null);154 const [resource, setResource] = useState(prefill?.resource ?? null);
@@ -226,6 +228,7 @@ export function OAuthClientForm(props: { · 73 unmodified lines
226 return;228 return;
227 }229 }
228 const result = exit.value;230 const result = exit.value;
231 setDiscoveredIssuer(result.issuer ?? null);
229 setAuthorizationUrl(result.authorizationUrl);232 setAuthorizationUrl(result.authorizationUrl);
230 setTokenUrl(result.tokenUrl);233 setTokenUrl(result.tokenUrl);
231 setResource(result.resource ?? null);234 setResource(result.resource ?? null);
@@ -247,11 +250,12 @@ export function OAuthClientForm(props: { · 15 unmodified lines
247 const handleRegisterDynamic = async () => {250 const handleRegisterDynamic = async () => {
248 if (!canRegisterDynamic || registering) return;251 if (!canRegisterDynamic || registering) return;
249 setRegistering(true);252 setRegistering(true);
250 const slug = fixedSlug ?? uniqueClientSlug(name, existingSlugs);253 const slug = fixedSlug ?? dcrClientSlug(discoveredIssuer ?? registrationEndpoint.trim());
251 const exit = await doRegisterDynamic({254 const exit = await doRegisterDynamic({
252 payload: {255 payload: {
253 owner,256 owner,
254 slug,257 slug,
258 issuer: discoveredIssuer,
255 registrationEndpoint: registrationEndpoint.trim(),259 registrationEndpoint: registrationEndpoint.trim(),
256 authorizationUrl: authorizationUrl.trim(),260 authorizationUrl: authorizationUrl.trim(),
257 tokenUrl: tokenUrl.trim(),261 tokenUrl: tokenUrl.trim(),
@@ -273,7 +277,7 @@ export function OAuthClientForm(props: { · 15 unmodified lines
273 }277 }
274 trackEvent("oauth_client_registered", { owner, grant, via_dcr: true, success: true });278 trackEvent("oauth_client_registered", { owner, grant, via_dcr: true, success: true });
275 toast.success(`Registered ${integrationName} OAuth app`);279 toast.success(`Registered ${integrationName} OAuth app`);
276 onCreated({ owner, slug });280 onCreated({ owner, slug: exit.value.client });
277 };281 };
278 282
279 const handleSubmit = async () => {283 const handleSubmit = async () => {
packages/react/src/plugins/use-effective-oauth-client.test.ts+10−0
@@ -2,6 +2,7 @@ import { describe, expect, it } from "@effect/vitest"; · 1 unmodified line
2import { OAuthClientSlug, type Owner } from "@executor-js/sdk/shared";2import { OAuthClientSlug, type Owner } from "@executor-js/sdk/shared";
3 3
4import {4import {
5 dcrClientSlug,
5 selectClientsForEndpoints,6 selectClientsForEndpoints,
6 uniqueClientSlug,7 uniqueClientSlug,
7 type OAuthClientOption,8 type OAuthClientOption,
@@ -144,3 +145,12 @@ describe("uniqueClientSlug", () => { · 136 unmodified lines
144 );145 );
145 });146 });
146});147});
148
149describe("dcrClientSlug", () => {
150 it("derives a deterministic DCR slug from the authorization server host", () => {
151 expect(String(dcrClientSlug("https://mcp.cloudflare.com/register"))).toBe(
152 "dcr-mcp-cloudflare-com",
153 );
154 expect(String(dcrClientSlug("http://127.0.0.1:8787/register"))).toBe("dcr-127-0-0-1-8787");
155 });
156});
packages/react/src/plugins/use-effective-oauth-client.tsx+7−0
@@ -199,6 +199,13 @@ export function uniqueClientSlug(name: string, existing: readonly string[]): OAu · 198 unmodified lines
199 return OAuthClientSlug.make(`${base}-${suffix}`);199 return OAuthClientSlug.make(`${base}-${suffix}`);
200}200}
201 201
202/** Deterministic DCR client slug derived from the authorization server host. */
203export function dcrClientSlug(issuerOrEndpoint: string): OAuthClientSlug {
204 const host = hostOf(issuerOrEndpoint);
205 const base = host === undefined ? "" : slugifyName(host);
206 return OAuthClientSlug.make(`dcr-${base || "authorization-server"}`);
207}
208
202/** Humanize a client slug for display ("spotify-prod" → "Spotify prod"). */209/** Humanize a client slug for display ("spotify-prod" → "Spotify prod"). */
203export function clientDisplayName(slug: string): string {210export function clientDisplayName(slug: string): string {
204 const text = slug.replace(/[-_]/g, " ").trim();211 const text = slug.replace(/[-_]/g, " ").trim();