| @@ -6,6 +6,7 @@ import { · 5 unmodified lines |
| 6 | ConnectionName, | 6 | ConnectionName, |
| 7 | IntegrationSlug, | 7 | IntegrationSlug, |
| 8 | OAuthClientSlug, | 8 | OAuthClientSlug, |
| | 9 | ToolAddress, |
| 9 | ToolName, | 10 | ToolName, |
| 10 | } from "./ids"; | 11 | } from "./ids"; |
| 11 | import { OAuthRegisterDynamicError } from "./oauth-client"; | 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 | it.effect( |
| 410 | Effect.scoped( | 411 | "reuses a legacy DCR row without an explicit redirect once its issuer is backfilled", |
| 411 | Effect.gen(function* () { | 412 | () => |
| 412 | // The post-backfill counterpart: after the GC migration stamps a legacy | 413 | Effect.scoped( |
| 413 | // row's origin_issuer, the reuse lookup keys on it and mints no | 414 | Effect.gen(function* () { |
| 414 | // duplicate. This is the steady state the migration establishes. | 415 | // The post-backfill counterpart: after the GC migration stamps a legacy |
| 415 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); | 416 | // row's origin_issuer, the reuse lookup keys on it and mints no |
| 416 | const { config, executor } = yield* makeTestWorkspaceHarness({ plugins }); | 417 | // duplicate. This is the steady state the migration establishes. |
| 417 | yield* executor.acme.seed(); | 418 | const server = yield* serveOAuthTestServer({ scopes: ["read"] }); |
| 418 | const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl }); | 419 | const { config, executor } = yield* makeTestWorkspaceHarness({ plugins }); |
| 419 | const legacySlug = OAuthClientSlug.make("cloudflare-mcp"); | 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({ | 424 | yield* executor.oauth.createClient({ |
| 422 | owner: "org", | 425 | owner: "org", |
| 423 | slug: legacySlug, | 426 | slug: legacySlug, |
| 424 | authorizationUrl: probe.authorizationUrl, | 427 | authorizationUrl: probe.authorizationUrl, |
| 425 | tokenUrl: probe.tokenUrl, | 428 | tokenUrl: probe.tokenUrl, |
| 426 | resource: server.mcpResourceUrl, | 429 | resource: server.mcpResourceUrl, |
| 427 | grant: "authorization_code", | 430 | grant: "authorization_code", |
| 428 | clientId: "legacy-dcr-client", | 431 | clientId: "legacy-dcr-client", |
| 429 | clientSecret: "", | 432 | clientSecret: "", |
| 430 | }); | 433 | }); |
| 431 | // Simulate the migration's backfill: legacy DCR stamp + issuer set. | 434 | // Simulate the migration's backfill: legacy DCR stamp + issuer set. |
| 432 | yield* Effect.promise(() => | 435 | yield* Effect.promise(() => |
| 433 | config.db.updateMany("oauth_client", { | 436 | config.db.updateMany("oauth_client", { |
| 434 | where: (b) => b("slug", "=", String(legacySlug)), | 437 | where: (b) => b("slug", "=", String(legacySlug)), |
| 435 | set: { | 438 | set: { |
| 436 | origin_kind: "dynamic_client_registration", | 439 | origin_kind: "dynamic_client_registration", |
| 437 | origin_integration: null, | 440 | origin_integration: null, |
| 438 | origin_issuer: probe.issuer, | 441 | origin_issuer: probe.issuer, |
| 439 | }, | 442 | }, |
| 440 | }), | 443 | }), |
| 441 | ); | 444 | ); |
| 442 | yield* server.clearRequests; | 445 | yield* server.clearRequests; |
| 443 | | 446 | |
| 444 | const reused = yield* executor.oauth.registerDynamicClient({ | 447 | const reused = yield* executor.oauth.registerDynamicClient({ |
| 445 | owner: "org", | 448 | owner: "org", |
| 446 | slug: OAuthClientSlug.make("new-attempt"), | 449 | slug: OAuthClientSlug.make("new-attempt"), |
| 447 | issuer: probe.issuer, | 450 | issuer: probe.issuer, |
| 448 | registrationEndpoint: probe.registrationEndpoint!, | 451 | registrationEndpoint: probe.registrationEndpoint!, |
| 449 | authorizationUrl: probe.authorizationUrl, | 452 | authorizationUrl: probe.authorizationUrl, |
| 450 | tokenUrl: probe.tokenUrl, | 453 | tokenUrl: probe.tokenUrl, |
| 451 | resource: server.mcpResourceUrl, | 454 | resource: server.mcpResourceUrl, |
| 452 | scopes: ["read"], | 455 | scopes: ["read"], |
| 453 | tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported, | 456 | tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported, |
| 454 | clientName: "Acme DCR", | 457 | clientName: "Acme DCR", |
| 455 | redirectUri: FLOW_REDIRECT_URI, | 458 | originIntegration: INTEG, |
| 456 | originIntegration: INTEG, | 459 | }); |
| 457 | }); | | |
| 458 | | 460 | |
| 459 | expect(reused).toBe(legacySlug); | 461 | expect(reused).toBe(legacySlug); |
| 460 | const requests = yield* server.requests; | 462 | const requests = yield* server.requests; |
| 461 | expect(registerRequestCount(requests)).toBe(0); | 463 | expect(registerRequestCount(requests)).toBe(0); |
| 462 | }), | 464 | }), |
| 463 | ), | 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 | it.effect("uses resource to distinguish DCR clients only after an issuer already differs", () => | 559 | it.effect("uses resource to distinguish DCR clients only after an issuer already differs", () => |