|
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", () => { |
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", () => |
|
0 commit comments