History

Build the MCP auth layer once per isolate

Rhys Sullivan committed 2 months ago

Commit 01e90181 parent 0520ee13 files · +37 −21Browse files

Unified · Split

3 files
  1. .changeset/apikey-validate-cache.md +1 −1
  2. apps/cloud/src/mcp/agent-handler.ts +28 −4
  3. e2e/cloud/api-key-validation-cache.test.ts +8 −16
.changeset/apikey-validate-cache.md+1−1
@@ -6,4 +6,4 @@ · 5 unmodified lines
6 6
7Every 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.7Every 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
9Successful 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.9Successful 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.
apps/cloud/src/mcp/agent-handler.ts+28−4
@@ -1,5 +1,5 @@
1import * as OtelTracer from "@effect/opentelemetry/Tracer";1import * as OtelTracer from "@effect/opentelemetry/Tracer";
2import { Effect, Predicate } from "effect";2import { Effect, ManagedRuntime, Predicate } from "effect";
3 3
4import {4import {
5 McpAuthProvider,5 McpAuthProvider,
@@ -128,7 +128,23 @@ const authenticate = (request: Request) => · 122 unmodified lines
128 const auth = yield* McpAuthProvider;128 const auth = yield* McpAuthProvider;
129 const outcome = yield* auth.authenticate(request);129 const outcome = yield* auth.authenticate(request);
130 return { auth, outcome };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).
146let mcpAuthRuntime: ManagedRuntime.ManagedRuntime<McpAuthProvider, never> | undefined;
147const getMcpAuthRuntime = () => (mcpAuthRuntime ??= ManagedRuntime.make(cloudMcpAuth));
132 148
133// The pre-Agents envelope ran the MCP auth path inside the Effect app, whose149// The pre-Agents envelope ran the MCP auth path inside the Effect app, whose
134// HttpMiddleware provided the OTEL tracer — that is where the `mcp.request`150// HttpMiddleware provided the OTEL tracer — that is where the `mcp.request`
@@ -140,12 +156,20 @@ const authenticate = (request: Request) => · 5 unmodified lines
140// forwarded request's traceparent — which also makes `currentPropagationHeaders`156// forwarded request's traceparent — which also makes `currentPropagationHeaders`
141// (via Effect.currentParentSpan) ferry that same trace into the session DO157// (via Effect.currentParentSpan) ferry that same trace into the session DO
142// instead of letting the DO start a fresh root per request.158// instead of letting the DO start a fresh root per request.
143const 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.
164const runTraced = <A>(
165 request: Request,
166 program: Effect.Effect<A, never, McpAuthProvider>,
167): Promise<A> => {
144 const parsed = parseTraceparent(168 const parsed = parseTraceparent(
145 request.headers.get("traceparent"),169 request.headers.get("traceparent"),
146 request.headers.get("tracestate"),170 request.headers.get("tracestate"),
147 );171 );
148 return Effect.runPromise(172 return getMcpAuthRuntime().runPromise(
149 (parsed ? OtelTracer.withSpanContext(program, parsed) : program).pipe(173 (parsed ? OtelTracer.withSpanContext(program, parsed) : program).pipe(
150 Effect.provide(WorkerTelemetryLive),174 Effect.provide(WorkerTelemetryLive),
151 ),175 ),
e2e/cloud/api-key-validation-cache.test.ts+8−16
@@ -19,15 +19,13 @@ · 18 unmodified lines
19// Two scenarios because the two bearer surfaces wire the cache differently:19// Two scenarios because the two bearer surfaces wire the cache differently:
20//20//
21// - /api/* resolves `ApiKeyService` from the app's boot layer, built once21// - /api/* resolves `ApiKeyService` from the app's boot layer, built once
22// per isolate — the cache holds. Green.22// per isolate.
23// - /mcp is served outside the app envelope by the agent handler, whose23// - /mcp is served outside the app envelope by the agent handler, which
24// `authenticate` provides the auth layer PER REQUEST24// builds `cloudMcpAuth` once per isolate on a memoized ManagedRuntime
25// (`Effect.provide(cloudMcpAuth)` in apps/cloud/src/mcp/agent-handler.ts).25// (apps/cloud/src/mcp/agent-handler.ts). It used to provide that layer
26// Each request builds a fresh `ApiKeyService` — a fresh cache map — so26// per request — a fresh `ApiKeyService`, a fresh cache map, a WorkOS
27// every MCP request still pays the WorkOS round trip the cache exists to27// round trip on every MCP request — so this scenario pins the shared
28// remove. That scenario is registered as an expected failure until the28// wiring, not just the cache.
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.
31//29//
32// Attribution: the emulator redacts secret-named fields (the response's30// Attribution: the emulator redacts secret-named fields (the response's
33// `api_key` object), but the request body's `value` survives, and every key31// `api_key` object), but the request body's `value` survives, and every key
@@ -226,13 +224,7 @@ scenario( · 192 unmodified lines
226 224
227scenario(225scenario(
228 "MCP · back-to-back API-key requests validate the key with WorkOS once, and each key validates independently",226 "MCP · back-to-back API-key requests validate the key with WorkOS once, and each key validates independently",
229 {227 {},
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 },
236 Effect.scoped(228 Effect.scoped(
237 Effect.gen(function* () {229 Effect.gen(function* () {
238 const { target, keyA, keyB, readValidations } = yield* mintTwoKeys;230 const { target, keyA, keyB, readValidations } = yield* mintTwoKeys;