Unified · Split
| @@ -6,4 +6,4 @@ · 5 unmodified lines | |||
| 6 | 6 | ||
| 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. | 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 | import * as OtelTracer from "@effect/opentelemetry/Tracer"; | 1 | import * as OtelTracer from "@effect/opentelemetry/Tracer"; |
| 2 | import { Effect, Predicate } from "effect"; | 2 | import { Effect, ManagedRuntime, Predicate } from "effect"; |
| 3 | 3 | ||
| 4 | import { | 4 | import { |
| 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). | ||
| 146 | let mcpAuthRuntime: ManagedRuntime.ManagedRuntime<McpAuthProvider, never> | undefined; | ||
| 147 | const getMcpAuthRuntime = () => (mcpAuthRuntime ??= ManagedRuntime.make(cloudMcpAuth)); | ||
| 132 | 148 | ||
| 133 | // The pre-Agents envelope ran the MCP auth path inside the Effect app, whose | 149 | // 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 DO | 157 | // (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. |
| 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 | 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 | ), |
| @@ -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 once | 21 | // - /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, whose | 23 | // - /mcp is served outside the app envelope by the agent handler, which |
| 24 | // `authenticate` provides the auth layer PER REQUEST | 24 | // 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 — so | 26 | // per request — a fresh `ApiKeyService`, a fresh cache map, a WorkOS |
| 27 | // every MCP request still pays the WorkOS round trip the cache exists to | 27 | // round trip on every MCP request — so this scenario pins the shared |
| 28 | // remove. That scenario is registered as an expected failure until the | 28 | // 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's | 30 | // Attribution: the emulator redacts secret-named fields (the response's |
| 33 | // `api_key` object), but the request body's `value` survives, and every key | 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 | scenario( | 225 | scenario( |
| 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; |