Skip to content

Commit ea7fc60

Browse files
committed
Make the refresh-gate key collision-free across tenants
1 parent a885f61 commit ea7fc60

2 files changed

Lines changed: 143 additions & 2 deletions

File tree

packages/core/sdk/src/executor.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1969,9 +1969,13 @@ export const createExecutor = <const TPlugins extends readonly AnyPlugin[] = rea
19691969
// every execution stack over one DB handle, so it spans tenants too:
19701970
// without it, two tenants whose rows agree on owner/subject/integration/
19711971
// name would collide on one entry and one tenant's caller could be handed
1972-
// the other's access token.
1972+
// the other's access token. JSON.stringify keeps the components
1973+
// unambiguous: tenant, subject, integration, and name are opaque strings
1974+
// that may contain any delimiter, so a delimiter-joined key would let
1975+
// tenant "a" + subject "user:b" collide with tenant "a:user" + subject
1976+
// "b" — the same cross-tenant bleed by another route.
19731977
const connectionKey = (row: ConnectionRow): string =>
1974-
`${tenant}:${row.owner}:${row.subject}:${row.integration}:${row.name}`;
1978+
JSON.stringify([tenant, row.owner, row.subject, row.integration, row.name]);
19751979

19761980
const loadOAuthClientRow = (
19771981
owner: Owner,

packages/core/sdk/src/oauth-flow.test.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import {
99
OAuthClientSlug,
1010
OAuthState,
1111
ProviderKey,
12+
Subject,
13+
Tenant,
1214
ToolAddress,
1315
ToolName,
1416
} from "./ids";
@@ -1039,6 +1041,141 @@ describe("oauth token refresh in resolveConnectionValue", () => {
10391041
),
10401042
);
10411043

1044+
// The gate spans tenants (one map per root DB handle), so its key must keep
1045+
// tenant and subject unambiguous. Both are opaque strings that may contain
1046+
// any delimiter: under a colon-joined key, tenant "a" + subject "user:b" and
1047+
// tenant "a:user" + subject "b" both flatten to "a:user:user:b:…", so two
1048+
// DIFFERENT tenants' refreshes would share one gate entry and one tenant's
1049+
// caller would be handed the other tenant's access token.
1050+
it.effect("colliding tenant/subject pairs never share a refresh gate entry", () =>
1051+
Effect.scoped(
1052+
Effect.gen(function* () {
1053+
const serverA = yield* serveOAuthTestServer({ scopes: ["read"] });
1054+
const serverB = yield* serveOAuthTestServer({ scopes: ["read"] });
1055+
const parkA = makeTokenRequestPark();
1056+
const parkB = makeTokenRequestPark();
1057+
1058+
// ONE root DB handle under TWO tenants — the shape a multi-tenant host
1059+
// holds — so both executors share one refresh gate. `shared.db` stays
1060+
// bound to tenant A's owner-policy context; tenant B's row edits below
1061+
// build their own scoped handle by hand. Each tenant gets its OWN
1062+
// credential store instance: the memory store keys items without a
1063+
// tenant, so sharing one across tenants would cross their tokens at
1064+
// the store layer and mask the gate-key collision this test is about.
1065+
const pluginsA = [memoryCredentialsPlugin(), oauthPlugin] as const;
1066+
const pluginsB = [memoryCredentialsPlugin(), oauthPlugin] as const;
1067+
const shared = makeTestConfig({ plugins: pluginsA, tenant: "a", subject: "user:b" });
1068+
const configA = { ...shared, fetch: parkA.fetch };
1069+
const configB = {
1070+
...shared,
1071+
plugins: pluginsB,
1072+
tenant: Tenant.make("a:user"),
1073+
subject: Subject.make("b"),
1074+
fetch: parkB.fetch,
1075+
};
1076+
const sessionA = yield* createExecutor(configA);
1077+
const sessionB = yield* createExecutor(configB);
1078+
yield* Effect.addFinalizer(() => sessionA.close().pipe(Effect.ignore));
1079+
yield* Effect.addFinalizer(() => sessionB.close().pipe(Effect.ignore));
1080+
yield* Effect.addFinalizer(() =>
1081+
Effect.promise(() => shared.testDb.close()).pipe(Effect.ignore),
1082+
);
1083+
1084+
// Each tenant mints its own USER-owned connection (user rows carry the
1085+
// session subject, which is what the colliding pair needs) against its
1086+
// own authorization server, so token provenance is observable.
1087+
const connect = (session: typeof sessionA, server: typeof serverA) =>
1088+
Effect.gen(function* () {
1089+
yield* session.acme.seed();
1090+
yield* session.oauth.createClient({
1091+
owner: "org",
1092+
slug: CLIENT,
1093+
authorizationUrl: server.authorizationEndpoint,
1094+
tokenUrl: server.tokenEndpoint,
1095+
grant: "authorization_code",
1096+
clientId: "test-client",
1097+
clientSecret: "test-secret",
1098+
});
1099+
const started = yield* session.oauth.start({
1100+
owner: "user",
1101+
client: CLIENT,
1102+
clientOwner: "org",
1103+
name: ConnectionName.make("mine"),
1104+
integration: INTEG,
1105+
template: TEMPLATE,
1106+
});
1107+
expect(started.status).toBe("redirect");
1108+
if (started.status !== "redirect") return;
1109+
const callback = yield* server.completeAuthorizationCodeFlow({
1110+
authorizationUrl: started.authorizationUrl,
1111+
});
1112+
yield* session.oauth.complete({ state: started.state, code: callback.code });
1113+
});
1114+
yield* connect(sessionA, serverA);
1115+
yield* connect(sessionB, serverB);
1116+
1117+
const address = ToolAddress.make("tools.acme.user.mine.whoami");
1118+
const originalA = (yield* sessionA.execute(address, {})) as { token: string };
1119+
const originalB = (yield* sessionB.execute(address, {})) as { token: string };
1120+
expect(originalB.token).not.toBe(originalA.token);
1121+
1122+
// Expire BOTH rows so both tenants must refresh. `shared.db` is bound
1123+
// to tenant A; tenant B's partition needs its own scoped handle.
1124+
const dbB = withQueryContext(shared.testDb.db, { tenant: "a:user", subject: "b" });
1125+
yield* Effect.promise(() =>
1126+
shared.db.updateMany("connection", {
1127+
where: (b) => b("name", "=", "mine"),
1128+
set: { expires_at: Date.now() - 60_000 },
1129+
}),
1130+
);
1131+
yield* Effect.promise(() =>
1132+
dbB.updateMany("connection", {
1133+
where: (b) => b("name", "=", "mine"),
1134+
set: { expires_at: Date.now() - 60_000 },
1135+
}),
1136+
);
1137+
1138+
parkA.arm();
1139+
parkB.arm();
1140+
const first = yield* Effect.forkChild(sessionA.execute(address, {}));
1141+
// Hold tenant A's grant open so its gate entry is still registered
1142+
// when tenant B performs its lookup of the would-be colliding key.
1143+
yield* Effect.promise(() => parkA.seen);
1144+
const second = yield* Effect.forkChild(sessionB.execute(address, {}));
1145+
// With a collision-free key, tenant B misses the gate and sends its
1146+
// OWN grant. Under a colliding key it would await tenant A's deferred
1147+
// and never reach its server, so cap the wait with a real timer (the
1148+
// test clock is virtual, so Effect.sleep would never fire) instead of
1149+
// hanging the suite; the assertions below then report the bleed.
1150+
yield* Effect.promise(() =>
1151+
Promise.race([parkB.seen, new Promise((resolve) => setTimeout(resolve, 2_000))]),
1152+
);
1153+
parkA.release();
1154+
parkB.release();
1155+
1156+
const tokenA = (yield* Fiber.join(first)) as { token: string };
1157+
const tokenB = (yield* Fiber.join(second)) as { token: string };
1158+
1159+
expect(tokenA.token, "tenant A refreshed to a new token").not.toBe(originalA.token);
1160+
expect(tokenB.token, "tenant B refreshed to a new token").not.toBe(originalB.token);
1161+
expect(tokenB.token, "no cross-tenant token bleed").not.toBe(tokenA.token);
1162+
expect(yield* serverA.acceptsAccessToken(tokenA.token)).toBe(true);
1163+
expect(
1164+
yield* serverB.acceptsAccessToken(tokenB.token),
1165+
"tenant B's token was minted by tenant B's own authorization server",
1166+
).toBe(true);
1167+
expect(
1168+
refreshGrantsIn(yield* serverA.requests),
1169+
"tenant A ran its own refresh grant",
1170+
).toHaveLength(1);
1171+
expect(
1172+
refreshGrantsIn(yield* serverB.requests),
1173+
"tenant B ran its own refresh grant — two distinct refresh executions",
1174+
).toHaveLength(1);
1175+
}),
1176+
),
1177+
);
1178+
10421179
// The gate entry is shared, so the stack that REGISTERS a grant is only the
10431180
// first arrival, not its owner. Running the grant on that caller's fiber
10441181
// would hand it that caller's interruption — a disconnected MCP client, an

0 commit comments

Comments
 (0)