History

fix(oauth): require exact explicit DCR redirect match (#2032)

Treat a caller-supplied DCR redirectUri as authoritative: only a client registered with that exact callback is reused. A legacy client with no recorded redirect is kept for existing connections while a new uniquely named client is registered for the explicit callback. Callers that rely on the configured default redirect keep the previous reuse behavior.

Swedish Chef committed 1 month ago

Commit 38a77251 parent 67db4f63 files · +163 −72Browse files

Unified · Split

3 files
  1. .changeset/strict-explicit-dcr-redirect.md +5 −0
  2. packages/core/sdk/src/oauth-register-dynamic.test.ts +145 −52
  3. packages/core/sdk/src/oauth-service.ts +13 −20
.changeset/strict-explicit-dcr-redirect.md+5−0
@@ -0,0 +1,5 @@
1---
2"@executor-js/sdk": patch
3---
4
5Treat an explicit dynamic-client redirect URI as authoritative when selecting a reusable OAuth client. Legacy clients with no recorded redirect now remain available to existing connections while a new client is registered for the explicit callback; callers that rely on Executor's configured default retain the previous compatibility behavior.
packages/core/sdk/src/oauth-register-dynamic.test.ts+145−52
@@ -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";
11import { OAuthRegisterDynamicError } from "./oauth-client";12import { 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 legacy413 Effect.scoped(
413 // row's origin_issuer, the reuse lookup keys on it and mints no414 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", () =>
packages/core/sdk/src/oauth-service.ts+13−20
@@ -1266,7 +1266,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 1265 unmodified lines
1266 readonly slug: OAuthClientSlug;1266 readonly slug: OAuthClientSlug;
1267 readonly resource: string | null;1267 readonly resource: string | null;
1268 /** Redirect URI the candidate registered with the AS; null for rows1268 /** Redirect URI the candidate registered with the AS; null for rows
1269 * predating the column (treated as matching any flow callback). */1269 * predating the column. */
1270 readonly redirectUri: string | null;1270 readonly redirectUri: string | null;
1271 };1271 };
1272 1272
@@ -1357,20 +1357,17 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 84 unmodified lines
1357 Effect.gen(function* () {1357 Effect.gen(function* () {
1358 const candidates = yield* dcrCandidatesForIssuer(input.owner, issuer);1358 const candidates = yield* dcrCandidatesForIssuer(input.owner, issuer);
1359 const resource = input.resource ?? null;1359 const resource = input.resource ?? null;
1360 // A candidate is reusable only when the callback it registered with the1360 // A caller-supplied redirect is authoritative: only a client registered
1361 // AS still matches the current flow's callback — strict servers reject an1361 // with that exact callback can be reused. In particular, a legacy row
1362 // authorize request whose redirect_uri differs from the registration1362 // with no recorded redirect is not proof of a match. When the caller
1363 // (e.g. the callback origin changed after a sandbox was recreated while1363 // relies on the executor's configured default, retain the legacy-null
1364 // the persisted client survived). A null stored redirect is a legacy row1364 // compatibility behavior so upgrades do not re-register every client.
1365 // predating the column: treated as matching so an upgrade doesn't1365 const hasExplicitRedirectUri = input.redirectUri != null;
1366 // re-register every client whose callback never changed. A null FLOW1366 const redirectMatches = (candidate: DcrReuseCandidate): boolean => {
1367 // redirect has nothing to compare against, so it also reuses — the only1367 if (candidate.redirectUri === flowRedirectUri) return true;
1368 // alternative is a fresh registration, which the missing-redirectUri1368 if (hasExplicitRedirectUri) return false;
1369 // guard would fail.1369 return candidate.redirectUri === null || flowRedirectUri === null;
1370 const redirectMatches = (candidate: DcrReuseCandidate): boolean =>1370 };
1371 candidate.redirectUri === null ||
1372 flowRedirectUri === null ||
1373 candidate.redirectUri === flowRedirectUri;
1374 // A fresh registration must never take a slug an existing candidate1371 // A fresh registration must never take a slug an existing candidate
1375 // holds: `createClient` deletes any colliding (owner, slug) row first,1372 // holds: `createClient` deletes any colliding (owner, slug) row first,
1376 // which would clobber a client that live connections still refresh1373 // which would clobber a client that live connections still refresh
@@ -1384,11 +1381,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => { · 7 unmodified lines
1384 // resource row is the STRANDED one — but the first drift recovery1381 // resource row is the STRANDED one — but the first drift recovery
1385 // already minted a client bound to the CURRENT callback, and later1382 // already minted a client bound to the CURRENT callback, and later
1386 // reconnects must reuse that instead of registering another duplicate1383 // reconnects must reuse that instead of registering another duplicate
1387 // each time. Known limitation: the legacy null-redirect rule in1384 // each time.
1388 // `redirectMatches` (a legacy row with no stored redirect matches any
1389 // flow redirect) still lets such a row win over a later, exactly-
1390 // matching one; kept deliberately so upgrades don't re-register every
1391 // client whose callback never changed.
1392 const reusable = candidates.find(1385 const reusable = candidates.find(
1393 (client) => client.resource === resource && redirectMatches(client),1386 (client) => client.resource === resource && redirectMatches(client),
1394 );1387 );