|
9 | 9 | OAuthClientSlug, |
10 | 10 | OAuthState, |
11 | 11 | ProviderKey, |
| 12 | + Subject, |
| 13 | + Tenant, |
12 | 14 | ToolAddress, |
13 | 15 | ToolName, |
14 | 16 | } from "./ids"; |
@@ -1039,6 +1041,141 @@ describe("oauth token refresh in resolveConnectionValue", () => { |
1039 | 1041 | ), |
1040 | 1042 | ); |
1041 | 1043 |
|
| 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 | + |
1042 | 1179 | // The gate entry is shared, so the stack that REGISTERS a grant is only the |
1043 | 1180 | // first arrival, not its owner. Running the grant on that caller's fiber |
1044 | 1181 | // would hand it that caller's interruption — a disconnected MCP client, an |
|
0 commit comments