Skip to content

Commit e9ee666

Browse files
committed
Drop the token-host fallback from the DCR reuse lookup
dcrCandidatesForIssuer previously matched a null-origin_issuer row by comparing registrable token hosts. Now a candidate matches only via dcrIssuerMatches on a non-null, canonicalized stored issuer. The GC migration backfills origin_issuer on every surviving DCR row, so post-migration the token-host fallback is dead code; in the brief deploy->migration window a missed lookup just mints one duplicate that the migration immediately GCs, which is safer than reusing on a fuzzy token-host guess. Drops the now-unused tokenUrl parameter and the registrableHostOfUrl import. register-dynamic tests: the old null-issuer-token-host reuse test becomes two cases: a null-issuer legacy row is NOT reused (mints fresh), and a backfilled row (origin_issuer set) IS reused with no duplicate.
1 parent 69b9bc6 commit e9ee666

3 files changed

Lines changed: 79 additions & 20 deletions

File tree

packages/core/sdk/src/oauth-gc.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,10 +104,12 @@ export interface OAuthClientGcRow {
104104
* is a DCR client. This was validated against the production database: of the
105105
* 394 rows that carry an explicit origin_kind stamp, `grant =
106106
* 'authorization_code' AND resource IS NOT NULL` matches all 329 stamped DCR
107-
* rows and zero stamped-manual rows (100% recall, no false positives). The
108-
* earlier slug/resource `…mcp…` regex heuristic had bad recall (it missed
109-
* legacy rows whose slug was a bare provider name like `linear` / `notion` /
110-
* `cloudflare`), so it is gone; the resource presence check subsumes it.
107+
* rows (100% recall). It also matches a handful of stamped-manual BYO rows
108+
* that point at MCP servers, but those never reach this arm: an explicit
109+
* stamp always wins. The earlier slug/resource `…mcp…` regex heuristic had
110+
* bad recall (it missed legacy rows whose slug was a bare provider name like
111+
* `linear` / `notion` / `cloudflare`), so it is gone; the resource presence
112+
* check subsumes it.
111113
*
112114
* Explicit stamps always win (arm 1): a stamped `manual` BYO row keeps its
113115
* classification even when it carries a resource, and a stamped DCR row is DCR

packages/core/sdk/src/oauth-register-dynamic.test.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -252,9 +252,14 @@ describe("oauth.registerDynamicClient", () => {
252252
),
253253
);
254254

255-
it.effect("reuses legacy DCR-looking rows with no stored issuer by token host", () =>
255+
it.effect("does NOT reuse a legacy DCR row with no stored issuer (mints fresh)", () =>
256256
Effect.scoped(
257257
Effect.gen(function* () {
258+
// Post-migration, the reuse lookup keys strictly on a non-null
259+
// origin_issuer: the fuzzy token-host fallback is gone. A legacy row that
260+
// has not yet been backfilled (null origin_issuer) is therefore NOT
261+
// reused, so a fresh DCR registration happens. The GC migration then
262+
// backfills/GCs any duplicate this transient window mints.
258263
const server = yield* serveOAuthTestServer({ scopes: ["read"] });
259264
const { config, executor } = yield* makeTestWorkspaceHarness({ plugins });
260265
yield* executor.acme.seed();
@@ -279,6 +284,64 @@ describe("oauth.registerDynamicClient", () => {
279284
);
280285
yield* server.clearRequests;
281286

287+
const registered = yield* executor.oauth.registerDynamicClient({
288+
owner: "org",
289+
slug: OAuthClientSlug.make("new-attempt"),
290+
issuer: probe.issuer,
291+
registrationEndpoint: probe.registrationEndpoint!,
292+
authorizationUrl: probe.authorizationUrl,
293+
tokenUrl: probe.tokenUrl,
294+
resource: server.mcpResourceUrl,
295+
scopes: ["read"],
296+
tokenEndpointAuthMethodsSupported: probe.tokenEndpointAuthMethodsSupported,
297+
clientName: "Acme DCR",
298+
redirectUri: FLOW_REDIRECT_URI,
299+
originIntegration: INTEG,
300+
});
301+
302+
// A fresh client is registered, not the null-issuer legacy row.
303+
expect(registered).not.toBe(legacySlug);
304+
const requests = yield* server.requests;
305+
expect(registerRequestCount(requests)).toBe(1);
306+
}),
307+
),
308+
);
309+
310+
it.effect("reuses a legacy DCR row once its origin_issuer is backfilled", () =>
311+
Effect.scoped(
312+
Effect.gen(function* () {
313+
// The post-backfill counterpart: after the GC migration stamps a legacy
314+
// row's origin_issuer, the reuse lookup keys on it and mints no
315+
// duplicate. This is the steady state the migration establishes.
316+
const server = yield* serveOAuthTestServer({ scopes: ["read"] });
317+
const { config, executor } = yield* makeTestWorkspaceHarness({ plugins });
318+
yield* executor.acme.seed();
319+
const probe = yield* executor.oauth.probe({ url: server.mcpResourceUrl });
320+
const legacySlug = OAuthClientSlug.make("cloudflare-mcp");
321+
322+
yield* executor.oauth.createClient({
323+
owner: "org",
324+
slug: legacySlug,
325+
authorizationUrl: probe.authorizationUrl,
326+
tokenUrl: probe.tokenUrl,
327+
resource: server.mcpResourceUrl,
328+
grant: "authorization_code",
329+
clientId: "legacy-dcr-client",
330+
clientSecret: "",
331+
});
332+
// Simulate the migration's backfill: legacy DCR stamp + issuer set.
333+
yield* Effect.promise(() =>
334+
config.db.updateMany("oauth_client", {
335+
where: (b) => b("slug", "=", String(legacySlug)),
336+
set: {
337+
origin_kind: "dynamic_client_registration",
338+
origin_integration: null,
339+
origin_issuer: probe.issuer,
340+
},
341+
}),
342+
);
343+
yield* server.clearRequests;
344+
282345
const reused = yield* executor.oauth.registerDynamicClient({
283346
owner: "org",
284347
slug: OAuthClientSlug.make("new-attempt"),

packages/core/sdk/src/oauth-service.ts

Lines changed: 9 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -70,13 +70,7 @@ import {
7070
type OAuthEndpointUrlPolicy,
7171
} from "./oauth-helpers";
7272
import { OAUTH2_SESSION_TTL_MS, encodeOAuthCallbackState } from "./oauth";
73-
import {
74-
canonicalIssuerUrl,
75-
hostOfUrl,
76-
isDcrClassifiedRow,
77-
parseUrl,
78-
registrableHostOfUrl,
79-
} from "./oauth-gc";
73+
import { canonicalIssuerUrl, hostOfUrl, isDcrClassifiedRow, parseUrl } from "./oauth-gc";
8074

8175
/** Connection-minting input for the OAuth flow — extends a connection create
8276
* with the OAuth lifecycle fields (client slug, refresh material, expiry,
@@ -677,7 +671,6 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
677671
const dcrCandidatesForIssuer = (
678672
owner: Owner,
679673
issuer: string | null,
680-
tokenUrl: string,
681674
): Effect.Effect<readonly DcrReuseCandidate[], StorageFailure> =>
682675
deps.fuma
683676
.use("oauth_client.findMany", (db) =>
@@ -687,17 +680,18 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
687680
)
688681
.pipe(
689682
Effect.map((rows) => {
690-
const inputTokenHost = registrableHostOfUrl(tokenUrl);
691683
const matches = rows.flatMap(
692684
(row): readonly (DcrReuseCandidate & { readonly createdAt: number })[] => {
693685
if (parseOAuthClientOrigin(row).kind !== "dynamic_client_registration") return [];
686+
// A candidate matches only via a non-null, canonicalized stored
687+
// issuer. The GC migration backfills origin_issuer on every
688+
// surviving DCR row, so post-migration a null-issuer row is a
689+
// transient (unmigrated) row; skipping it just mints one duplicate
690+
// the migration then GCs, rather than reusing on a fuzzy token-host
691+
// guess.
694692
const rowIssuer =
695693
row.origin_issuer == null ? null : canonicalIssuerUrl(String(row.origin_issuer));
696-
const issuerMatches =
697-
rowIssuer !== null
698-
? dcrIssuerMatches(rowIssuer, issuer)
699-
: inputTokenHost !== null &&
700-
registrableHostOfUrl(String(row.token_url)) === inputTokenHost;
694+
const issuerMatches = rowIssuer !== null && dcrIssuerMatches(rowIssuer, issuer);
701695
if (!issuerMatches) return [];
702696
return [
703697
{
@@ -733,7 +727,7 @@ export const makeOAuthService = (deps: OAuthServiceDeps): OAuthService => {
733727
StorageFailure
734728
> =>
735729
Effect.gen(function* () {
736-
const candidates = yield* dcrCandidatesForIssuer(input.owner, issuer, input.tokenUrl);
730+
const candidates = yield* dcrCandidatesForIssuer(input.owner, issuer);
737731
const resource = input.resource ?? null;
738732
if (resource !== null) {
739733
const matchingResource = candidates.find((client) => client.resource === resource);

0 commit comments

Comments
 (0)