| @@ -6,6 +6,7 @@ import { · 5 unmodified lines |
| 6 | 6 | ConnectionName, |
| 7 | 7 | IntegrationSlug, |
| 8 | 8 | OAuthClientSlug, |
| 9 | + ToolAddress, |
| 9 | 10 | ToolName, |
| 10 | 11 | } from "./ids"; |
| 11 | 12 | import { OAuthRegisterDynamicError } from "./oauth-client"; |
| @@ -406,61 +407,153 @@ describe("oauth.registerDynamicClient", () => { · 394 unmodified lines |
| 406 | 407 | ), |
| 407 | 408 | ); |
| 408 | 409 | |
| 409 | | − it.effect("reuses a legacy DCR row once its origin_issuer is backfilled", () => |
| 410 | | − Effect.scoped( |
| 411 | | − Effect.gen(function* () { |
| 412 | | − // The post-backfill counterpart: after the GC migration stamps a legacy |
| 413 | | − // row's origin_issuer, the reuse lookup keys on it and mints no |
| 414 | | − // duplicate. This is the steady state the migration establishes. |
| 415 | | − const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 416 | | − const { config, executor } = yield* makeTestWorkspaceHarness({ plugins }); |
| 417 | | − yield* executor.acme.seed(); |
| 418 | | − const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); |
| 419 | | − const legacySlug = OAuthClientSlug.make("cloudflare-mcp"); |
| 410 | + it.effect( |
| 411 | + "reuses a legacy DCR row without an explicit redirect once its issuer is backfilled", |
| 412 | + () => |
| 413 | + Effect.scoped( |
| 414 | + Effect.gen(function* () { |
| 415 | + // The post-backfill counterpart: after the GC migration stamps a legacy |
| 416 | + // row's origin_issuer, the reuse lookup keys on it and mints no |
| 417 | + // duplicate. This is the steady state the migration establishes. |
| 418 | + const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 419 | + const { config, executor } = yield* makeTestWorkspaceHarness({ plugins }); |
| 420 | + yield* executor.acme.seed(); |
| 421 | + const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); |
| 422 | + const legacySlug = OAuthClientSlug.make("cloudflare-mcp"); |
| 420 | 423 | |
| 421 | | − yield* executor.oauth.createClient({ |
| 422 | | − owner: "org", |
| 423 | | − slug: legacySlug, |
| 424 | | − authorizationUrl: probe.authorizationUrl, |
| 425 | | − tokenUrl: probe.tokenUrl, |
| 426 | | − resource: server.mcpResourceUrl, |
| 427 | | − grant: "authorization_code", |
| 428 | | − clientId: "legacy-dcr-client", |
| 429 | | − clientSecret: "", |
| 430 | | − }); |
| 431 | | − // Simulate the migration's backfill: legacy DCR stamp + issuer set. |
| 432 | | − yield* Effect.promise(() => |
| 433 | | − config.db.updateMany("oauth_client", { |
| 434 | | − where: (b) => b("slug", "=", String(legacySlug)), |
| 435 | | − set: { |
| 436 | | − origin_kind: "dynamic_client_registration", |
| 437 | | − origin_integration: null, |
| 438 | | − origin_issuer: probe.issuer, |
| 439 | | − }, |
| 440 | | − }), |
| 441 | | − ); |
| 442 | | − yield* server.clearRequests; |
| 424 | + yield* executor.oauth.createClient({ |
| 425 | + owner: "org", |
| 426 | + slug: legacySlug, |
| 427 | + authorizationUrl: probe.authorizationUrl, |
| 428 | + tokenUrl: probe.tokenUrl, |
| 429 | + resource: server.mcpResourceUrl, |
| 430 | + grant: "authorization_code", |
| 431 | + clientId: "legacy-dcr-client", |
| 432 | + clientSecret: "", |
| 433 | + }); |
| 434 | + // Simulate the migration's backfill: legacy DCR stamp + issuer set. |
| 435 | + yield* Effect.promise(() => |
| 436 | + config.db.updateMany("oauth_client", { |
| 437 | + where: (b) => b("slug", "=", String(legacySlug)), |
| 438 | + set: { |
| 439 | + origin_kind: "dynamic_client_registration", |
| 440 | + origin_integration: null, |
| 441 | + origin_issuer: probe.issuer, |
| 442 | + }, |
| 443 | + }), |
| 444 | + ); |
| 445 | + yield* server.clearRequests; |
| 443 | 446 | |
| 444 | | − const reused = yield* executor.oauth.registerDynamicClient({ |
| 445 | | − owner: "org", |
| 446 | | − slug: OAuthClientSlug.make("new-attempt"), |
| 447 | | − issuer: probe.issuer, |
| 448 | | − registrationEndpoint: probe.registrationEndpoint!, |
| 449 | | − authorizationUrl: probe.authorizationUrl, |
| 450 | | − tokenUrl: probe.tokenUrl, |
| 451 | | − resource: server.mcpResourceUrl, |
| 452 | | − scopes: ["read"], |
| 453 | | − tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported, |
| 454 | | − clientName: "Acme DCR", |
| 455 | | − redirectUri: FLOW_REDIRECT_URI, |
| 456 | | − originIntegration: INTEG, |
| 457 | | − }); |
| 447 | + const reused = yield* executor.oauth.registerDynamicClient({ |
| 448 | + owner: "org", |
| 449 | + slug: OAuthClientSlug.make("new-attempt"), |
| 450 | + issuer: probe.issuer, |
| 451 | + registrationEndpoint: probe.registrationEndpoint!, |
| 452 | + authorizationUrl: probe.authorizationUrl, |
| 453 | + tokenUrl: probe.tokenUrl, |
| 454 | + resource: server.mcpResourceUrl, |
| 455 | + scopes: ["read"], |
| 456 | + tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported, |
| 457 | + clientName: "Acme DCR", |
| 458 | + originIntegration: INTEG, |
| 459 | + }); |
| 458 | 460 | |
| 459 | | − expect(reused).toBe(legacySlug); |
| 460 | | − const requests = yield* server.requests; |
| 461 | | − expect(registerRequestCount(requests)).toBe(0); |
| 462 | | − }), |
| 463 | | − ), |
| 461 | + expect(reused).toBe(legacySlug); |
| 462 | + const requests = yield* server.requests; |
| 463 | + expect(registerRequestCount(requests)).toBe(0); |
| 464 | + }), |
| 465 | + ), |
| 466 | + ); |
| 467 | + |
| 468 | + it.effect( |
| 469 | + "does not reuse a legacy null-redirect client when the caller supplies an explicit redirect", |
| 470 | + () => |
| 471 | + Effect.scoped( |
| 472 | + Effect.gen(function* () { |
| 473 | + const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 474 | + const { config, executor } = yield* makeTestWorkspaceHarness({ plugins }); |
| 475 | + yield* executor.acme.seed(); |
| 476 | + const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); |
| 477 | + |
| 478 | + const legacySlug = yield* executor.oauth.registerDynamicClient({ |
| 479 | + owner: "org", |
| 480 | + slug: OAuthClientSlug.make("legacy-client"), |
| 481 | + issuer: probe.issuer, |
| 482 | + registrationEndpoint: probe.registrationEndpoint!, |
| 483 | + authorizationUrl: probe.authorizationUrl, |
| 484 | + tokenUrl: probe.tokenUrl, |
| 485 | + resource: probe.resource, |
| 486 | + scopes: ["read"], |
| 487 | + tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported, |
| 488 | + clientName: "Legacy DCR", |
| 489 | + redirectUri: FLOW_REDIRECT_URI, |
| 490 | + originIntegration: INTEG, |
| 491 | + }); |
| 492 | + |
| 493 | + const started = yield* executor.oauth.start({ |
| 494 | + owner: "org", |
| 495 | + client: legacySlug, |
| 496 | + clientOwner: "org", |
| 497 | + name: ConnectionName.make("legacy"), |
| 498 | + integration: INTEG, |
| 499 | + template: TEMPLATE, |
| 500 | + redirectUri: FLOW_REDIRECT_URI, |
| 501 | + }); |
| 502 | + expect(started.status).toBe("redirect"); |
| 503 | + if (started.status !== "redirect") return; |
| 504 | + const callback = yield* server.completeAuthorizationCodeFlow({ |
| 505 | + authorizationUrl: started.authorizationUrl, |
| 506 | + }); |
| 507 | + yield* executor.oauth.complete({ state: started.state, code: callback.code }); |
| 508 | + |
| 509 | + // Simulate a client written before origin_redirect_uri was persisted. |
| 510 | + yield* Effect.promise(() => |
| 511 | + config.db.updateMany("oauth_client", { |
| 512 | + where: (b) => b("slug", "=", String(legacySlug)), |
| 513 | + set: { origin_redirect_uri: null }, |
| 514 | + }), |
| 515 | + ); |
| 516 | + yield* server.clearRequests; |
| 517 | + |
| 518 | + const stableRedirectUri = "https://agent.example.test/executor/oauth/callback"; |
| 519 | + const replacementSlug = yield* executor.oauth.registerDynamicClient({ |
| 520 | + owner: "org", |
| 521 | + slug: OAuthClientSlug.make("stable-callback"), |
| 522 | + issuer: probe.issuer, |
| 523 | + registrationEndpoint: probe.registrationEndpoint!, |
| 524 | + authorizationUrl: probe.authorizationUrl, |
| 525 | + tokenUrl: probe.tokenUrl, |
| 526 | + resource: probe.resource, |
| 527 | + scopes: ["read"], |
| 528 | + tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported, |
| 529 | + clientName: "Stable callback DCR", |
| 530 | + redirectUri: stableRedirectUri, |
| 531 | + originIntegration: INTEG, |
| 532 | + }); |
| 533 | + |
| 534 | + expect(String(replacementSlug)).not.toBe(String(legacySlug)); |
| 535 | + expect(registerRequestCount(yield* server.requests)).toBe(1); |
| 536 | + const clientSlugs = yield* Effect.map(executor.oauth.listClients(), (clients) => |
| 537 | + clients.map((client) => String(client.slug)), |
| 538 | + ); |
| 539 | + expect(clientSlugs).toContain(String(legacySlug)); |
| 540 | + expect(clientSlugs).toContain(String(replacementSlug)); |
| 541 | + |
| 542 | + // The legacy row may still back live connections. Keeping it allows |
| 543 | + // those grants to refresh while new flows use the stable callback. |
| 544 | + yield* Effect.promise(() => |
| 545 | + config.db.updateMany("connection", { |
| 546 | + where: (b) => b("name", "=", "legacy"), |
| 547 | + set: { expires_at: Date.now() - 60_000 }, |
| 548 | + }), |
| 549 | + ); |
| 550 | + const refreshed = (yield* executor.execute( |
| 551 | + ToolAddress.make("tools.acme.org.legacy.whoami"), |
| 552 | + {}, |
| 553 | + )) as { token: string }; |
| 554 | + expect(refreshed.token).toMatch(/^at_/); |
| 555 | + }), |
| 556 | + ), |
| 464 | 557 | ); |
| 465 | 558 | |
| 466 | 559 | it.effect("uses resource to distinguish DCR clients only after an issuer already differs", () => |