Unified · Split
| @@ -6,4 +6,4 @@ · 5 unmodified lines | ||
| 6 | 6 | |
| 7 | 7 | Every MCP request and every API-key-authenticated `/api/*` request used to pay a live WorkOS round trip (~100-150ms) to validate the presented key, on every single request. The JWT bearer path beside it already verified locally against a JWKS cached for an hour; API keys had no cache at all. |
| 8 | 8 | |
| 9 | −Successful validations are now cached in a bounded per-isolate map for 60 seconds, keyed by the SHA-256 digest of the key value (never the raw credential). Invalid keys and upstream failures are never cached, so probing bad keys cannot pollute the map and a freshly created key works immediately. The tradeoff: a revoked key remains usable for up to 60 seconds within an isolate that validated it before revocation — far tighter than the one-hour rotation window the JWT path already accepts. | |
| 9 | +Successful validations are now cached in a bounded per-isolate map for 60 seconds, keyed by the SHA-256 digest of the key value (never the raw credential). The MCP handler used to rebuild its whole auth layer (and with it the cache) on every request; it now builds the layer once per isolate, so the cache holds on both the `/api/*` and `/mcp` planes. Invalid keys and upstream failures are never cached, so probing bad keys cannot pollute the map and a freshly created key works immediately. The tradeoff: a revoked key remains usable for up to 60 seconds within an isolate that validated it before revocation — far tighter than the one-hour rotation window the JWT path already accepts. | |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import * as OtelTracer from "@effect/opentelemetry/Tracer"; |
| 2 | −import { Effect, Predicate } from "effect"; | |
| 2 | +import { Effect, ManagedRuntime, Predicate } from "effect"; | |
| 3 | 3 | |
| 4 | 4 | import { |
| 5 | 5 | McpAuthProvider, |
| @@ -128,7 +128,23 @@ const authenticate = (request: Request) => · 122 unmodified lines | ||
| 128 | 128 | const auth = yield* McpAuthProvider; |
| 129 | 129 | const outcome = yield* auth.authenticate(request); |
| 130 | 130 | return { auth, outcome }; |
| 131 | − }).pipe(Effect.provide(cloudMcpAuth)); | |
| 131 | + }); | |
| 132 | + | |
| 133 | +// ONE auth layer per isolate. `authenticate` used to end in | |
| 134 | +// `Effect.provide(cloudMcpAuth)`, which rebuilt the entire auth stack — the | |
| 135 | +// WorkOS client, `ApiKeyService` and with it the api-key validation success | |
| 136 | +// cache — on every MCP request, so that cache never survived a request on this | |
| 137 | +// plane (the /api/* plane resolves the same service from the app's `boot` | |
| 138 | +// layer, built once per isolate). A lazily created ManagedRuntime (the | |
| 139 | +// auth/doc-gate.ts pattern) builds `cloudMcpAuth` on the first request and | |
| 140 | +// memoizes it for the isolate's lifetime; `runTraced` runs every program on it. | |
| 141 | +// Nothing inside the layer is request-scoped: the WorkOS client holds no | |
| 142 | +// sockets (just config), the JWKS cache is already module-scope, and | |
| 143 | +// `McpOrganizationAuthLive` builds its postgres socket FRESH inside every | |
| 144 | +// `authorize` call precisely so the service itself can outlive a request | |
| 145 | +// (Cloudflare Workers' I/O isolation — see makeMcpOrganizationAuthServices). | |
| 146 | +let mcpAuthRuntime: ManagedRuntime.ManagedRuntime<McpAuthProvider, never> | undefined; | |
| 147 | +const getMcpAuthRuntime = () => (mcpAuthRuntime ??= ManagedRuntime.make(cloudMcpAuth)); | |
| 132 | 148 | |
| 133 | 149 | // The pre-Agents envelope ran the MCP auth path inside the Effect app, whose |
| 134 | 150 | // HttpMiddleware provided the OTEL tracer — that is where the `mcp.request` |
| @@ -140,12 +156,20 @@ const authenticate = (request: Request) => · 5 unmodified lines | ||
| 140 | 156 | // forwarded request's traceparent — which also makes `currentPropagationHeaders` |
| 141 | 157 | // (via Effect.currentParentSpan) ferry that same trace into the session DO |
| 142 | 158 | // instead of letting the DO start a fresh root per request. |
| 143 | −const runTraced = <A>(request: Request, program: Effect.Effect<A>): Promise<A> => { | |
| 159 | +// | |
| 160 | +// Runs on the memoized auth runtime above (rather than a bare | |
| 161 | +// `Effect.runPromise`) so `authenticate` resolves the once-built | |
| 162 | +// `McpAuthProvider` from it; the telemetry layer is still provided per run, so | |
| 163 | +// spans keep exporting exactly as before. | |
| 164 | +const runTraced = <A>( | |
| 165 | + request: Request, | |
| 166 | + program: Effect.Effect<A, never, McpAuthProvider>, | |
| 167 | +): Promise<A> => { | |
| 144 | 168 | const parsed = parseTraceparent( |
| 145 | 169 | request.headers.get("traceparent"), |
| 146 | 170 | request.headers.get("tracestate"), |
| 147 | 171 | ); |
| 148 | − return Effect.runPromise( | |
| 172 | + return getMcpAuthRuntime().runPromise( | |
| 149 | 173 | (parsed ? OtelTracer.withSpanContext(program, parsed) : program).pipe( |
| 150 | 174 | Effect.provide(WorkerTelemetryLive), |
| 151 | 175 | ), |
| @@ -19,15 +19,13 @@ · 18 unmodified lines | ||
| 19 | 19 | // Two scenarios because the two bearer surfaces wire the cache differently: |
| 20 | 20 | // |
| 21 | 21 | // - /api/* resolves `ApiKeyService` from the app's boot layer, built once |
| 22 | −// per isolate — the cache holds. Green. | |
| 23 | −// - /mcp is served outside the app envelope by the agent handler, whose | |
| 24 | −// `authenticate` provides the auth layer PER REQUEST | |
| 25 | −// (`Effect.provide(cloudMcpAuth)` in apps/cloud/src/mcp/agent-handler.ts). | |
| 26 | −// Each request builds a fresh `ApiKeyService` — a fresh cache map — so | |
| 27 | −// every MCP request still pays the WorkOS round trip the cache exists to | |
| 28 | −// remove. That scenario is registered as an expected failure until the | |
| 29 | −// MCP wiring shares one built auth layer per isolate; the assertions | |
| 30 | −// state the real contract, and the flag must be removed when it lands. | |
| 22 | +// per isolate. | |
| 23 | +// - /mcp is served outside the app envelope by the agent handler, which | |
| 24 | +// builds `cloudMcpAuth` once per isolate on a memoized ManagedRuntime | |
| 25 | +// (apps/cloud/src/mcp/agent-handler.ts). It used to provide that layer | |
| 26 | +// per request — a fresh `ApiKeyService`, a fresh cache map, a WorkOS | |
| 27 | +// round trip on every MCP request — so this scenario pins the shared | |
| 28 | +// wiring, not just the cache. | |
| 31 | 29 | // |
| 32 | 30 | // Attribution: the emulator redacts secret-named fields (the response's |
| 33 | 31 | // `api_key` object), but the request body's `value` survives, and every key |
| @@ -226,13 +224,7 @@ scenario( · 192 unmodified lines | ||
| 226 | 224 | |
| 227 | 225 | scenario( |
| 228 | 226 | "MCP · back-to-back API-key requests validate the key with WorkOS once, and each key validates independently", |
| 229 | − { | |
| 230 | − expectedFailure: | |
| 231 | − "the MCP agent handler provides its auth layer per request (Effect.provide(cloudMcpAuth) " + | |
| 232 | − "in apps/cloud/src/mcp/agent-handler.ts), so every MCP request builds a fresh " + | |
| 233 | − "ApiKeyService and a fresh validation cache — each request still pays a WorkOS round " + | |
| 234 | − "trip. Remove this flag when the MCP path shares one built auth layer per isolate.", | |
| 235 | − }, | |
| 227 | + {}, | |
| 236 | 228 | Effect.scoped( |
| 237 | 229 | Effect.gen(function* () { |
| 238 | 230 | const { target, keyA, keyB, readValidations } = yield* mintTwoKeys; |