310 lines · 13.4 KB · TypeScript
RawDownload
// ---------------------------------------------------------------------------// Cloud McpAuthProvider adapter — the cloud analog of selfHostMcpAuthProviderLayer.//// Folds the entire cloud edge auth/authz surface (WorkOS JWT verify + API-key// bearer + per-request org-liveness check + the two OAuth discovery docs) into// ONE `McpAuthProvider` Layer behind the shared host-mcp envelope.//// `authenticate(request)` runs on EVERY /mcp request and resolves a typed// AuthOutcome://   - missing bearer       -> Unauthorized (challenge: Bearer resource_metadata=…)//   - invalid token/api key -> Unauthorized (challenge: Bearer error="invalid_token" …)//   - transient JWKS OR membership-lookup infra -> Unavailable (caught here;//       envelope renders a retryable 503 -32001). A WorkOS blip during the live//       org check is a TRANSIENT failure, not evidence the org is gone, so it//       must NOT reach the Forbidden/destroy path below.//   - no org / revoked org  -> Forbidden ("No organization in session …", -32001).//       This requires a POSITIVE determination (the lookup SUCCEEDED and the org//       is absent), never a failed lookup. Because authenticate reads the//       mcp-session-id header to do the live org check, the envelope's//       dispose-on-Forbidden-with-sessionId path reproduces the old inline//       clearExistingSession.//   - verified + org allowed -> Authenticated(principal)//// The rich client-fingerprint annotations (cloud-specific, no envelope seam)// are stamped onto the `mcp.request` span from here so telemetry parity is// preserved.//// The OAuth endpoints (/authorize, /token, /register) are NOT cloud's — they// live at WorkOS/AuthKit (external); only the two discovery docs are mounted.// --------------------------------------------------------------------------- import { Effect, Layer, Predicate, Result } from "effect"; import {  authenticated,  forbidden,  unauthorized,  unavailable,  McpAuthProvider,  type AuthOutcome,  type McpDiscoveryRoute,  type Principal,} from "@executor-js/host-mcp"; import { ApiKeyService } from "../auth/api-keys";import { isDefinitiveWorkOSDenial } from "../auth/errors";import { CoreSharedServices } from "../auth/workos";import {  bearerChallengeFor,  mcpOrganizationFromRequest,  protectedResourceMetadataUrlFor,  PROTECTED_RESOURCE_METADATA_PATH,  toolkitSlugFromRequest,  McpAuth,  McpAuthLive,  McpOrganizationAuth,  McpOrganizationAuthLive,  type McpAuthResult,  type VerifiedToken,} from "./auth";import { annotateMcpRequest } from "./telemetry";import {  authorizationServerMetadataResponse,  protectedResourceMetadataResponse,} from "./oauth-metadata"; const AUTHORIZATION_SERVER_METADATA_PATH = "/.well-known/oauth-authorization-server";const TOOLKIT_PROTECTED_RESOURCE_METADATA_PATH = `${PROTECTED_RESOURCE_METADATA_PATH}/toolkits/:toolkitSlug`; const NO_ORGANIZATION_MESSAGE = "No organization in session — log in via the web app first"; // A transient WorkOS failure (429 / 5xx / timeout / network) during the live// membership lookup must NOT masquerade as "org revoked" — but the failure// channel alone is not enough to tell them apart: WorkOS also answers with// DEFINITIVE 4xx denials (401 revoked/invalid API key, 403, 404 deleted org)// that the SDK throws as typed exceptions. So the classification is://   - lookup SUCCEEDS with `null`            -> genuine absence -> Forbidden//   - lookup FAILS with WorkOS 401/403/404   -> definitive denial -> Forbidden//       (fail CLOSED: WorkOS answered and said no; retrying cannot help)//   - lookup FAILS any other way (429/5xx/timeout/network/no status)//       -> transient -> retryable 503, session preserved// The status rides on `WorkOSError.status` (threaded from the SDK exception at// the service boundary in auth/workos.ts); `isDefinitiveWorkOSDenial` is the// single predicate. This mirrors the JWKS/api-key `reason: "system"`// classification already used on the verify path.const ORGANIZATION_AUTHORIZE_UNAVAILABLE =  "Organization authorization temporarily unavailable - please retry"; /** * Enrich a cloud {@link VerifiedToken} (which carries only accountId + * organizationId) into the full {@link Principal} the seam validates. The * envelope only uses `accountId` + `organizationId` for ownership; cloud * resolves org name/email inside the DO, so the cosmetic identity fields carry * empty placeholders. `organizationId` is guaranteed non-null here because the * Forbidden branch already rejected the no-org case before Authenticated. */const principalFromToken = (token: VerifiedToken, organizationId: string): Principal => ({  accountId: token.accountId,  organizationId,  organizationName: "",  email: "",  name: null,  avatarUrl: null,  roles: [],}); export const cloudMcpAuthProviderLayer: Layer.Layer<  McpAuthProvider,  never,  McpAuth | McpOrganizationAuth> = Layer.effect(  McpAuthProvider,  Effect.gen(function* () {    const auth = yield* McpAuth;    const orgAuth = yield* McpOrganizationAuth;     const discoveryRoutes: ReadonlyArray<McpDiscoveryRoute> = [      {        path: PROTECTED_RESOURCE_METADATA_PATH,        // The bare path is the only one mounted; `prepareMcpOrgScope` rewrites an        // org-scoped discovery doc onto it and pins the org in the header we read.        handler: (request) =>          Effect.succeed(            protectedResourceMetadataResponse(              mcpOrganizationFromRequest(request),              toolkitSlugFromRequest(request),            ),          ),      },      {        path: TOOLKIT_PROTECTED_RESOURCE_METADATA_PATH,        handler: (request) =>          Effect.succeed(            protectedResourceMetadataResponse(              mcpOrganizationFromRequest(request),              toolkitSlugFromRequest(request),            ),          ),      },      {        path: AUTHORIZATION_SERVER_METADATA_PATH,        handler: () => authorizationServerMetadataResponse,      },    ];     const resourceMetadataUrl = (request: Request): string =>      protectedResourceMetadataUrlFor(        mcpOrganizationFromRequest(request),        toolkitSlugFromRequest(request),      );     /**     * Resolve a verified bearer to a final AuthOutcome by running the live org     * check. Mirrors the old `authorizeMcpOrganization`: no org -> Forbidden;     * revoked live org -> Forbidden (the envelope disposes the session when a     * session-id is present). Telemetry-annotates before returning so even 401s     * and 403s carry the client fingerprint.     */    const finishAuthorized = (request: Request, token: VerifiedToken): Effect.Effect<AuthOutcome> =>      Effect.gen(function* () {        // OLD `mcpApp` annotated with parseBody = (POST && isAuthorized) BEFORE        // org-authz, so a verified-but-no/revoked-org POST still captured        // mcp.rpc.method/id. The body is read via `request.clone().text()`        // (annotateMcpRequest -> readJsonRpcEnvelope), so it never consumes the        // original stream a downstream dispatch reads — safe on every path,        // including the Forbidden short-circuit. Keep parseBody keyed on POST,        // not on the org outcome, to preserve that telemetry.        const parseBody = request.method === "POST";         // URL is the source of truth for the active org when pinned — the org's        // slug (`/acme/mcp`, what the install card prints) or a legacy org id        // (`/org_xxx/mcp`), carried in the header by `prepareMcpOrgScope`; the        // bare `/mcp` falls back to the token's `org_id`. Either way        // `orgAuth.authorize` resolves the selector and re-checks live WorkOS        // membership below, so the URL is a selector, not a trust boundary.        const organizationSelector = mcpOrganizationFromRequest(request) ?? token.organizationId;        if (!organizationSelector) {          yield* annotateMcpRequest(request, { token, parseBody });          return forbidden(NO_ORGANIZATION_MESSAGE, -32001);        }         // Capture success-vs-failure explicitly instead of collapsing both into        // `null`, then classify the failure (see the classification table on        // ORGANIZATION_AUTHORIZE_UNAVAILABLE above): a definitive WorkOS 4xx        // denial fails CLOSED as Forbidden, anything else is a transient error        // that must become a retryable 503 with the session left intact.        const authorizeResult = yield* orgAuth          .authorize(token.accountId, organizationSelector)          .pipe(            Effect.result,            Effect.withSpan("mcp.auth.authorize_organization", {              attributes: {                "mcp.auth.organization_selector": organizationSelector,              },            }),          );         yield* annotateMcpRequest(request, { token, parseBody });         if (Result.isFailure(authorizeResult)) {          if (isDefinitiveWorkOSDenial(authorizeResult.failure)) {            // WorkOS ANSWERED and said no (revoked key, forbidden, deleted            // org). Deterministic denial — same as a successful lookup with no            // membership, so the Forbidden/condemn path applies.            yield* Effect.annotateCurrentSpan({              "mcp.auth.outcome": "denied",              "mcp.auth.organization_authorize_error": String(authorizeResult.failure).slice(                0,                500,              ),            });            return forbidden(NO_ORGANIZATION_MESSAGE, -32001);          }          yield* Effect.annotateCurrentSpan({            "mcp.auth.outcome": "system_error",            "mcp.auth.system_error.reason": "organization_authorize",            "mcp.auth.organization_authorize_error": String(authorizeResult.failure).slice(0, 500),          });          return unavailable(ORGANIZATION_AUTHORIZE_UNAVAILABLE);        }         // Positive determination: the lookup succeeded. `null` here means the        // caller genuinely holds no active membership (revoked / never a member)        // — a real Forbidden, which the handler may act on by condemning the        // session.        const organizationId = authorizeResult.success;        if (!organizationId) return forbidden(NO_ORGANIZATION_MESSAGE, -32001);        return authenticated(principalFromToken(token, organizationId));      });     const toOutcome = (request: Request, result: McpAuthResult): Effect.Effect<AuthOutcome> => {      if (Predicate.isTagged(result, "Authorized")) {        return finishAuthorized(request, result.token);      }      return annotateMcpRequest(request, {        token: null,        parseBody: false,      }).pipe(        Effect.as(          unauthorized(            bearerChallengeFor(              result,              mcpOrganizationFromRequest(request),              toolkitSlugFromRequest(request),            ),          ),        ),      );    };     /**     * Never fails: a transient JWKS-infra failure (the McpJwtVerificationError     * the old `mcpApp` caught and turned into a 503) is caught HERE and mapped     * to Unavailable so the envelope renders the retryable 503 -32001.     */    const authenticate = (request: Request): Effect.Effect<AuthOutcome> =>      auth.verifyBearer(request).pipe(        Effect.result,        Effect.flatMap((result) =>          Result.isFailure(result)            ? annotateMcpRequest(request, {                token: null,                parseBody: false,              }).pipe(                Effect.flatMap(() =>                  Effect.annotateCurrentSpan({                    "mcp.auth.outcome": "system_error",                    "mcp.auth.system_error.reason": result.failure.reason,                    "mcp.auth.system_error.message": String(result.failure.cause).slice(0, 500),                  }),                ),                Effect.as(unavailable("Authentication temporarily unavailable - please retry")),              )            : toOutcome(request, result.success),        ),        Effect.withSpan("mcp.request"),      );     return {      discoveryRoutes,      resourceMetadataUrl,      authenticate,    };  }),); // ---------------------------------------------------------------------------// The cloud MCP auth seam fed to `ExecutorApp.make`'s `mcp.auth` slot.//// `make`'s MCP seam contract is generic over the auth seam's residual// (`Layer<McpAuthProvider, never, RMcpAuth>`). Cloud's MCP auth is a SEPARATE// credential plane (WorkOS JWT + API-key bearer, no cookie session), so it does// NOT read the neutral `IdentityProvider` fallback the way self-host does; it// provides its own `McpAuth` + `McpOrganizationAuth` seams INTERNALLY (the// production WorkOS JWT verify over `ApiKeyService.WorkOS` + live org-liveness),// so `RMcpAuth = never` — no phantom requirement, no cast. (Self-host's seam// genuinely requires `IdentityProvider`, so its `RMcpAuth = IdentityProvider`.)// ---------------------------------------------------------------------------export const cloudMcpAuth: Layer.Layer<McpAuthProvider> = cloudMcpAuthProviderLayer.pipe(  Layer.provide(    Layer.mergeAll(      McpAuthLive.pipe(Layer.provide(ApiKeyService.WorkOS.pipe(Layer.provide(CoreSharedServices)))),      McpOrganizationAuthLive,    ),  ),  // A boot-time WorkOS misconfiguration (the `WorkOSClient.Default` config error)  // is unrecoverable; die rather than leak it into the seam's channel.  // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: a boot-time WorkOS misconfiguration is unrecoverable  Layer.orDie,);