Unified · Split
| @@ -25,6 +25,18 @@ const TEMPLATE = AuthTemplateSlug.make("oauth"); · 24 unmodified lines | |||
| 25 | const CLIENT = OAuthClientSlug.make("acme-dcr"); | 25 | const CLIENT = OAuthClientSlug.make("acme-dcr"); |
| 26 | const FLOW_REDIRECT_URI = "https://localhost:5394/api/oauth/callback"; | 26 | const FLOW_REDIRECT_URI = "https://localhost:5394/api/oauth/callback"; |
| 27 | 27 | ||
| 28 | const 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 | |||
| 36 | const registerRequestCount = ( | ||
| 37 | requests: readonly { readonly path: string; readonly method: string }[], | ||
| 38 | ): number => requests.filter((r) => r.path === "/register" && r.method === "POST").length; | ||
| 39 | |||
| 28 | const oauthPlugin = definePlugin(() => ({ | 40 | const 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 so | 68 | // 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-issued | 94 | // 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) only | 370 | // Regression: issue #770. Vercel (and other RFC 8252-strict servers) only |
| 149 | // approve loopback redirect URIs for anonymous DCR, so a hosted/tailnet/LAN | 371 | // approve loopback redirect URIs for anonymous DCR, so a hosted/tailnet/LAN |
| 150 | // origin trips `invalid_redirect_uri`. The failure must explain the loopback | 372 | // origin trips `invalid_redirect_uri`. The failure must explain the loopback |
| @@ -31,6 +31,7 @@ const apiKeyMethod = (id: string, source: "spec" | "custom", template = id): Aut · 30 unmodified lines | |||
| 31 | }); | 31 | }); |
| 32 | 32 | ||
| 33 | type ProbeResult = { | 33 | type 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 | |||
| 42 | type RegisterArgs = { | 43 | type 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 choose | 345 | // 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"); |
| @@ -47,6 +47,7 @@ import { · 46 unmodified lines | |||
| 47 | import { | 47 | import { |
| 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). */ |
| 615 | type DcrProbeResult = { | 616 | type 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 | |||
| 624 | type DcrRegisterArgs = { | 626 | type 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 ?? |
| @@ -9,7 +9,7 @@ import { ownerLabelForHost } from "../api/owner-display"; · 8 unmodified lines | |||
| 9 | import { trackEvent } from "../api/analytics"; | 9 | import { trackEvent } from "../api/analytics"; |
| 10 | import { useOrganizationId } from "../api/organization-context"; | 10 | import { useOrganizationId } from "../api/organization-context"; |
| 11 | import { oauthClientWriteKeys } from "../api/reactivity-keys"; | 11 | import { oauthClientWriteKeys } from "../api/reactivity-keys"; |
| 12 | import { uniqueClientSlug } from "../plugins/use-effective-oauth-client"; | 12 | import { dcrClientSlug, uniqueClientSlug } from "../plugins/use-effective-oauth-client"; |
| 13 | import { oauthCallbackUrl } from "../plugins/oauth-sign-in"; | 13 | import { oauthCallbackUrl } from "../plugins/oauth-sign-in"; |
| 14 | import { | 14 | import { |
| 15 | ConnectionOwnerDropdown, | 15 | ConnectionOwnerDropdown, |
| @@ -36,6 +36,7 @@ import { RadioGroup, RadioGroupItem } from "./radio-group"; · 20 unmodified lines | |||
| 36 | // --------------------------------------------------------------------------- | 36 | // --------------------------------------------------------------------------- |
| 37 | 37 | ||
| 38 | export interface OAuthClientFormPrefill { | 38 | export 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 () => { |
| @@ -2,6 +2,7 @@ import { describe, expect, it } from "@effect/vitest"; · 1 unmodified line | |||
| 2 | import { OAuthClientSlug, type Owner } from "@executor-js/sdk/shared"; | 2 | import { OAuthClientSlug, type Owner } from "@executor-js/sdk/shared"; |
| 3 | 3 | ||
| 4 | import { | 4 | import { |
| 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 | |||
| 149 | describe("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 | }); | ||
| @@ -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. */ | ||
| 203 | export 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"). */ |
| 203 | export function clientDisplayName(slug: string): string { | 210 | export function clientDisplayName(slug: string): string { |
| 204 | const text = slug.replace(/[-_]/g, " ").trim(); | 211 | const text = slug.replace(/[-_]/g, " ").trim(); |