diff --git a/frontend/src/app/main/data/workspace/mcp.cljs b/frontend/src/app/main/data/workspace/mcp.cljs index 2884803a68a..29d544a11fe 100644 --- a/frontend/src/app/main/data/workspace/mcp.cljs +++ b/frontend/src/app/main/data/workspace/mcp.cljs @@ -33,9 +33,14 @@ :description "This plugin enables interaction with the Penpot MCP server" :allow-background true :permissions + ;; user:read added in the Moneta fork: the plugins runtime gates + ;; penpot.currentUser / penpot.activeUsers on it, and MCP clients + ;; routinely ask about users. Keep in sync with + ;; mcp/packages/plugin/public/manifest.json. #{"library:read" "library:write" "comment:read" "comment:write" - "content:write" "content:read"}}) + "content:write" "content:read" + "user:read"}}) (defonce interval-sub (atom nil)) diff --git a/mcp/docs/moneta-cognito-auth.md b/mcp/docs/moneta-cognito-auth.md new file mode 100644 index 00000000000..5401ee0d6e2 --- /dev/null +++ b/mcp/docs/moneta-cognito-auth.md @@ -0,0 +1,85 @@ +# Moneta fork — Cognito OAuth gate + +This document covers the Pressingly/Moneta fork additions that put the Penpot +MCP server behind AWS Cognito / mPass SSO, mirroring `surfsense-mcp-server` +and `plane-mcp-server`. Everything lives in +`packages/server/src/moneta/`; upstream files carry three one-line hooks +(`index.ts`, `PenpotMcpServer.ts`, `PluginBridge.ts`). + +## How the two legs pair + +``` +MCP client (Claude/Cursor) Penpot user's browser + │ │ + ▼ Bearer (Cognito access token) ▼ _oauth2_proxy cookie +https://design-mcp./mcp wss://design./mcp/ws + │ Traefik: strip-auth-headers only │ Traefik: mpass-auth ForwardAuth + ▼ ▼ + X-Auth-Request-Email injected + Cognito gate (this fork) penpot-frontend nginx → :4402 + │ identity = id_token email │ identity = header email + └────────────► pair by equality ◄──────────┘ + (PluginBridge clientsByToken) +``` + +- **MCP leg.** `/mcp` is *not* behind mPass. The server is its own OAuth 2.0 + authorization server (RFC 8414/9728 discovery, RFC 7591 DCR shim, + `/authorize` + `/token` proxied to Cognito with a two-hop redirect through + `{MCP_BASE_URL}/auth/callback`), so MCP clients auto-OAuth — no manual + token paste. Inbound Bearers are Cognito **access tokens**, JWKS-verified + per request. +- **Pairing identity.** A Cognito access token has no `email` and an opaque + UUID `username` for federated users, so identity comes from the **id_token** + captured at code/refresh exchange (userInfo endpoint as self-healing + fallback), precedence `email → cognito:username → access-token username` — + the same order oauth2-proxy uses for `X-Auth-Request-Email` / + `X-Auth-Request-User`. No identity → fail closed (401). +- **Plugin leg.** Unchanged for the user: the Penpot plugin connects to + `wss://design./mcp/ws` (the URL penpot's `cfg/mcp-ws-uri` already + produces), which traverses mPass. The bridge prefers the injected + `X-Auth-Request-Email` header over the upstream `?userToken=` JWE. +- The resolved identity is exposed to the untouched upstream handlers as + `req.query.userToken`, so session bookkeeping and `PluginBridge` pairing + work exactly as upstream wrote them. Each `mcp-session-id` is additionally + pinned to the identity that created it. + +In Cognito mode multi-user mode is forced on (`index.ts` hook), so the +single-user "any connected plugin" fallback can never cross users. The +upstream `/mcp/stream?userToken=` path effectively retires in this mode +(requests without a Bearer get 401); MCP clients use +`https://design-mcp./mcp` instead of the URL shown in Penpot's UI. + +## Environment + +| Variable | Purpose | +|---|---| +| `COGNITO_USER_POOL_ID` | Enables Cognito mode (its presence is the switch). | +| `COGNITO_AWS_REGION` / `AWS_REGION` | Cognito region. | +| `OIDC_CLIENT_ID` | Pre-registered Cognito app client (shared with the other MCPs). | +| `OIDC_CLIENT_SECRET` | Optional — omit for public/PKCE clients. | +| `MCP_BASE_URL` | Public URL of this server, e.g. `https://design-mcp.`. | +| `MCP_ALLOWED_ORIGINS` | CSV CORS allow-list, or `*` (dev/Inspector). | +| `MCP_ALLOWED_CLIENT_REDIRECT_URIS` | CSV allow-list for DCR redirect URIs; unset = allow all. | +| `MCP_OAUTH_STORAGE_URL` | `redis://valkey:6379/13` — encrypted OAuth state (AES-256-GCM, key HKDF-derived from `OIDC_CLIENT_SECRET` or `MCP_JWT_SIGNING_KEY`). Unset = in-memory. | +| `MCP_JWT_SIGNING_KEY` | Storage-encryption key material when there is no client secret. | +| `MCP_ENV` | `production` logs a warning when storage is in-memory. | + +Valkey DB allocation: 11 = surfsense-mcp, 12 = plane-mcp, **13 = penpot-mcp**. + +## One-time IdP step + +Add `https://design-mcp./auth/callback` to the Cognito app client's +allowed callback URLs (same app client as the sibling MCPs — no new client). + +## Devstack + +Service `penpot-mcp` in `foss-server-bundle/docker-compose.dev.yml` (profiles +`penpot-mcp` / `mcp`): Traefik router `design-mcp.` → +`penpot-mcp:4401` with `strip-auth-headers` only, healthcheck on `/healthz`. +Rebuild with `make dev.build.penpot.mcp`. Remember `PENPOT_MCP_URI` / +`PENPOT_MCP_URI_WS` in `.env` so penpot-frontend's nginx proxies `/mcp/ws` to +this server (the plugin leg). + +Local smoke test without Cognito reachability: start with a fake pool id and +probe `GET /healthz`, `GET /.well-known/oauth-authorization-server`, +`POST /register`, and confirm `/mcp` answers 401 with a `WWW-Authenticate` +challenge. diff --git a/mcp/packages/plugin/public/manifest.json b/mcp/packages/plugin/public/manifest.json index aa97095b301..ba1b43b6fb6 100644 --- a/mcp/packages/plugin/public/manifest.json +++ b/mcp/packages/plugin/public/manifest.json @@ -4,5 +4,5 @@ "icon": "icon.jpg", "version": 2, "description": "This plugin enables interaction with the Penpot MCP server", - "permissions": ["content:read", "content:write", "library:read", "library:write", "comment:read", "comment:write"] + "permissions": ["content:read", "content:write", "library:read", "library:write", "comment:read", "comment:write", "user:read"] } diff --git a/mcp/packages/server/package.json b/mcp/packages/server/package.json index 68d33859ac1..7875944a036 100644 --- a/mcp/packages/server/package.json +++ b/mcp/packages/server/package.json @@ -5,7 +5,7 @@ "type": "module", "main": "dist/index.js", "scripts": { - "build:server": "esbuild src/index.ts --bundle --platform=node --target=node18 --format=esm --outfile=dist/index.js --external:@modelcontextprotocol/* --external:ws --external:express --external:class-transformer --external:class-validator --external:reflect-metadata --external:pino --external:pino-pretty --external:pino-loki --external:js-yaml --external:sharp", + "build:server": "esbuild src/index.ts --bundle --platform=node --target=node18 --format=esm --outfile=dist/index.js --external:@modelcontextprotocol/* --external:ws --external:express --external:class-transformer --external:class-validator --external:reflect-metadata --external:pino --external:pino-pretty --external:pino-loki --external:js-yaml --external:sharp --external:ioredis --external:jose", "build": "pnpm run build:server && node scripts/copy-resources.js", "build:types": "tsc --emitDeclarationOnly --outDir dist", "start": "node dist/index.js", @@ -28,6 +28,8 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.3", "express": "^5.1.0", + "ioredis": "^5.4.1", + "jose": "^5.9.6", "js-yaml": "^4.1.1", "penpot-mcp": "file:..", "pino": "^9.10.0", diff --git a/mcp/packages/server/src/PenpotMcpServer.ts b/mcp/packages/server/src/PenpotMcpServer.ts index 6bd3ca33e55..db57671fd33 100644 --- a/mcp/packages/server/src/PenpotMcpServer.ts +++ b/mcp/packages/server/src/PenpotMcpServer.ts @@ -13,6 +13,7 @@ import { ExportShapeTool } from "./tools/ExportShapeTool"; import { ImportImageTool } from "./tools/ImportImageTool"; import { ReplServer } from "./ReplServer"; import { ApiDocs } from "./ApiDocs"; +import { installMonetaAuth, monetaAuthEnabled } from "./moneta"; /** * Session context for request-scoped data. @@ -321,6 +322,11 @@ export class PenpotMcpServer { this.app = express(); this.app.use(express.json()); + // Moneta fork: Cognito OAuth gate (no-op unless COGNITO_USER_POOL_ID is set) + if (monetaAuthEnabled()) { + await installMonetaAuth(this.app, this.logger); + } + this.setupHttpEndpoints(); return new Promise((resolve) => { diff --git a/mcp/packages/server/src/PluginBridge.ts b/mcp/packages/server/src/PluginBridge.ts index 35d39aa728e..936e876d5b1 100644 --- a/mcp/packages/server/src/PluginBridge.ts +++ b/mcp/packages/server/src/PluginBridge.ts @@ -4,6 +4,7 @@ import { PluginTask } from "./PluginTask"; import { PluginTaskResponse, PluginTaskResult } from "@penpot/mcp-common"; import { createLogger } from "./logger"; import type { PenpotMcpServer } from "./PenpotMcpServer"; +import { monetaIdentityFromUpgrade } from "./moneta"; const KEEP_ALIVE_TIME = 30000; // 30 seconds @@ -43,8 +44,9 @@ export class PluginBridge { private setupWebSocketHandlers(): void { this.wsServer.on("connection", (ws: WebSocket, request: http.IncomingMessage) => { // extract userToken from query parameters + // (Moneta fork: the mPass identity headers take precedence in Cognito mode) const url = new URL(request.url!, `ws://${request.headers.host}`); - const userToken = url.searchParams.get("userToken"); + const userToken = monetaIdentityFromUpgrade(request) ?? url.searchParams.get("userToken"); // require userToken if running in multi-user mode if (this.mcpServer.isMultiUserMode() && !userToken) { diff --git a/mcp/packages/server/src/index.ts b/mcp/packages/server/src/index.ts index 84ef23f204d..e769a059587 100644 --- a/mcp/packages/server/src/index.ts +++ b/mcp/packages/server/src/index.ts @@ -2,6 +2,7 @@ import { PenpotMcpServer } from "./PenpotMcpServer"; import { createLogger, logActiveTransports } from "./logger"; +import { monetaAuthEnabled } from "./moneta"; /** * Entry point for Penpot MCP Server @@ -37,7 +38,9 @@ async function main(): Promise { } } - const server = new PenpotMcpServer(multiUser); + // Moneta fork: Cognito auth implies multiple users — never route tools + // through the single-user "any connected plugin" fallback. + const server = new PenpotMcpServer(multiUser || monetaAuthEnabled()); await server.start(); // keep the process alive diff --git a/mcp/packages/server/src/moneta/bridge.ts b/mcp/packages/server/src/moneta/bridge.ts new file mode 100644 index 00000000000..407c1049872 --- /dev/null +++ b/mcp/packages/server/src/moneta/bridge.ts @@ -0,0 +1,70 @@ +/** + * Moneta fork — plugin-side identity for the WebSocket bridge. + * + * In the devstack the plugin connects from the user's browser to + * wss://design./mcp/ws, which traverses Traefik's mpass-auth + * ForwardAuth: oauth2-proxy validates the shared SSO cookie and Traefik + * injects X-Auth-Request-Email / X-Auth-Request-User into the upgrade request + * before nginx proxies it to this server. Reading those headers gives the + * plugin connection the same identity string that the Cognito gate derives + * for MCP sessions, so the two sides pair by simple equality. + * + * Precedence mirrors cognito.pickPairingIdentity exactly: a *usable* email + * first (see cognito.usablePairingEmail — in the Moneta pool this header + * carries the bare askii user id, the join key with the MCP leg's native + * twin identity), then X-Auth-Request-User, which oauth2-proxy fills from + * the `cognito:username` claim (OAUTH2_PROXY_USER_ID_CLAIM). + * + * Trust: external requests can never carry forged headers — Traefik's + * strip-auth-headers middleware removes inbound X-Auth-Request-* on every + * router before mpass-auth re-adds them. Inside the docker network the + * boundary is the network itself, the same model the sibling MCP servers use + * for their identity-header paths. + * + * Returns null when Cognito mode is off or no header is present, in which + * case the caller falls back to the upstream ?userToken= pairing untouched. + */ + +import type * as http from "node:http"; +import { createLogger } from "../logger"; +import { usablePairingEmail } from "./cognito"; +import { monetaAuthEnabled } from "./config"; + +const logger = createLogger("moneta.bridge"); + +function headerValue(request: http.IncomingMessage, name: string): string | null { + const raw = request.headers[name]; + const value = Array.isArray(raw) ? raw[0] : raw; + const trimmed = value?.trim(); + return trimmed ? trimmed : null; +} + +/** Short non-reversible marker for log correlation — never the full identifier. */ +function fingerprint(value: string | null): string { + return value ? `${value.slice(0, 8)}…` : ""; +} + +export function monetaIdentityFromUpgrade(request: http.IncomingMessage): string | null { + if (!monetaAuthEnabled()) { + return null; + } + const emailHeader = headerValue(request, "x-auth-request-email"); + const userHeader = headerValue(request, "x-auth-request-user"); + const identity = usablePairingEmail(emailHeader) ?? userHeader; + // Fingerprints only at info — full identifiers would leak PII into + // production logs. Raw header values are available at debug for pairing + // diagnosis (the dev overlay runs with PENPOT_MCP_LOG_LEVEL=debug). + logger.info( + "Plugin connection identity: %s (email header=%s, user header=%s)", + identity ? fingerprint(identity) : "", + fingerprint(emailHeader), + fingerprint(userHeader) + ); + logger.debug( + "Plugin connection identity (full): %s (email header=%s, user header=%s)", + identity ?? "", + emailHeader ?? "", + userHeader ?? "" + ); + return identity; +} diff --git a/mcp/packages/server/src/moneta/cognito.ts b/mcp/packages/server/src/moneta/cognito.ts new file mode 100644 index 00000000000..737f03863b3 --- /dev/null +++ b/mcp/packages/server/src/moneta/cognito.ts @@ -0,0 +1,274 @@ +/** + * Moneta fork — AWS Cognito client: OIDC discovery, JWKS validation of access + * tokens, code/refresh exchanges, and identity extraction. + * + * Identity precedence (why the id_token matters) + * ---------------------------------------------- + * A Cognito *access* token carries no `email` claim and, for users federated + * from an external IdP, a `username` that is an opaque Cognito UUID. The + * mPass browser flow (oauth2-proxy) keys on the id_token's `email` / + * `cognito:username` claims, so to pair an MCP session with the same human as + * the Penpot web login we must derive identity from the **id_token** captured + * at token-exchange time (or from the userInfo endpoint, which serves the + * same attributes). This mirrors surfsense-mcp's SurfSenseCognitoProvider and + * plane-mcp's PlaneCognitoProvider. + * + * The id_token is decoded WITHOUT signature verification, which is safe here: + * it arrives directly from Cognito's token endpoint over this server's own + * TLS call — never from the MCP client — and is only used to label sessions, + * while the inbound access token is independently JWKS-verified per request. + */ + +import { createRemoteJWKSet, decodeJwt, jwtVerify, type JWTPayload } from "jose"; +import type { Logger } from "pino"; +import type { MonetaAuthConfig } from "./config"; +import { UPSTREAM_SCOPE } from "./config"; + +export interface CognitoTokenResponse { + access_token: string; + token_type?: string; + expires_in?: number; + refresh_token?: string; + id_token?: string; + scope?: string; +} + +export interface CognitoIdentity { + email?: string; + /** The id_token's cognito:username — the human handle mPass keys on. */ + username?: string; +} + +interface DiscoveryDocument { + issuer: string; + authorization_endpoint: string; + token_endpoint: string; + jwks_uri: string; + userinfo_endpoint?: string; + revocation_endpoint?: string; +} + +const EMAIL_CLAIM = "email"; +const COGNITO_USERNAME_CLAIM = "cognito:username"; + +/** Extracts pairing identity claims from a Cognito id_token (unverified — see module docstring). */ +export function identityFromIdToken(idToken: string, logger?: Logger): CognitoIdentity { + try { + const claims = decodeJwt(idToken); + const identity: CognitoIdentity = {}; + if (typeof claims[EMAIL_CLAIM] === "string" && claims[EMAIL_CLAIM]) { + identity.email = claims[EMAIL_CLAIM] as string; + } + if (typeof claims[COGNITO_USERNAME_CLAIM] === "string" && claims[COGNITO_USERNAME_CLAIM]) { + identity.username = claims[COGNITO_USERNAME_CLAIM] as string; + } + return identity; + } catch (error) { + logger?.warn(error, "Failed to decode Cognito id_token"); + return {}; + } +} + +/** + * Email-claim values usable as a pairing key. Two Moneta-pool realities shape + * this rule: + * + * - Humans exist twice in the pool: a *federated* identity (used by the mPass + * browser login via Moneta's own login bridge; `cognito:username` is an + * opaque UUID, email attribute carries the bare askii user id, e.g. + * "1020010000020127") and a *native* twin (the only login the Cognito + * hosted UI offers — no IdPs are enabled on it; `cognito:username` IS the + * askii id, email is a mapping placeholder). The bare askii id in the email + * claim/header is therefore the join key between the browser (plugin) leg + * and the MCP leg — it must be accepted even though it has no "@". + * - Native accounts carry the literal placeholder "cognito:default_val" in + * the email claim — identical for every user, so pairing on it would funnel + * all users onto one key and cross their plugin sessions. Anything in the + * "cognito:"-namespace is mapping junk, never a real identifier. + * + * The same rule runs on the plugin-bridge side for X-Auth-Request-Email. + */ +export function usablePairingEmail(value: string | undefined | null): string | null { + if (!value || value.startsWith("cognito:")) { + return null; + } + return value; +} + +/** + * Resolves the single pairing string for a user, in the same precedence order + * the plugin-bridge side uses for mPass identity headers (X-Auth-Request-Email + * first, then X-Auth-Request-User): usable email → cognito:username → + * access-token username. With the Moneta pool's twin identities this makes + * both legs converge on the askii user id: the bridge's federated session + * resolves it from the email header, the MCP leg's native login resolves it + * from `cognito:username`. Returns null when nothing usable is present — + * callers fail closed rather than pair against an opaque UUID `sub`. + */ +export function pickPairingIdentity(identity: CognitoIdentity, accessTokenUsername?: string): string | null { + const email = usablePairingEmail(identity.email); + if (email) { + return email; + } + if (identity.username) { + return identity.username; + } + if (accessTokenUsername) { + return accessTokenUsername; + } + return null; +} + +export class CognitoClient { + private discoveryPromise: Promise | undefined; + private jwks: ReturnType | undefined; + + constructor( + private readonly config: MonetaAuthConfig, + private readonly logger: Logger + ) {} + + /** Fetches and caches the pool's OIDC discovery document; a failed fetch is retried on the next call. */ + discovery(): Promise { + if (!this.discoveryPromise) { + this.discoveryPromise = this.fetchDiscovery().catch((error) => { + this.discoveryPromise = undefined; + throw error; + }); + } + return this.discoveryPromise; + } + + private async fetchDiscovery(): Promise { + const url = `${this.config.issuer}/.well-known/openid-configuration`; + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Cognito discovery failed: ${response.status} ${response.statusText} (${url})`); + } + const doc = (await response.json()) as DiscoveryDocument; + this.logger.info("Cognito discovery loaded (issuer=%s)", doc.issuer); + return doc; + } + + /** + * Validates an inbound Bearer as a Cognito **access** token: JWKS + * signature, issuer, token_use, and that it was issued to our app client. + * Throws on any failure; callers translate into an OAuth 401. + */ + async verifyAccessToken(token: string): Promise { + const discovery = await this.discovery(); + if (!this.jwks) { + this.jwks = createRemoteJWKSet(new URL(discovery.jwks_uri)); + } + const { payload } = await jwtVerify(token, this.jwks, { issuer: this.config.issuer }); + if (payload.token_use !== "access") { + throw new Error(`Expected a Cognito access token, got token_use=${String(payload.token_use)}`); + } + if (payload.client_id !== this.config.clientId) { + throw new Error("Access token was issued to a different client"); + } + return payload; + } + + private async tokenRequest(params: Record): Promise { + const discovery = await this.discovery(); + const body = new URLSearchParams({ ...params, client_id: this.config.clientId }); + const headers: Record = { "Content-Type": "application/x-www-form-urlencoded" }; + if (this.config.clientSecret) { + const basic = Buffer.from(`${this.config.clientId}:${this.config.clientSecret}`).toString("base64"); + headers["Authorization"] = `Basic ${basic}`; + } + const response = await fetch(discovery.token_endpoint, { method: "POST", headers, body }); + if (!response.ok) { + const detail = await response.text().catch(() => ""); + throw new Error(`Cognito token endpoint returned ${response.status}: ${detail.slice(0, 300)}`); + } + return (await response.json()) as CognitoTokenResponse; + } + + /** Exchanges the upstream authorization code received on /auth/callback. */ + exchangeCode(code: string, codeVerifier: string): Promise { + return this.tokenRequest({ + grant_type: "authorization_code", + code, + redirect_uri: this.config.callbackUrl, + code_verifier: codeVerifier, + }); + } + + /** Proxies a refresh_token grant. Cognito returns a fresh access + id token (no new refresh token). */ + refresh(refreshToken: string): Promise { + return this.tokenRequest({ grant_type: "refresh_token", refresh_token: refreshToken }); + } + + /** + * Fetches identity attributes for a live access token. Fallback used when + * the identity map has no entry for a (still valid) token — e.g. after a + * restart with in-memory storage — so sessions self-heal without a forced + * re-auth. Returns null on any failure. + */ + async userInfo(accessToken: string): Promise { + const discovery = await this.discovery(); + if (!discovery.userinfo_endpoint) { + return null; + } + try { + const response = await fetch(discovery.userinfo_endpoint, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (!response.ok) { + this.logger.warn("Cognito userInfo returned %d", response.status); + return null; + } + const attributes = (await response.json()) as Record; + const identity: CognitoIdentity = {}; + if (typeof attributes[EMAIL_CLAIM] === "string" && attributes[EMAIL_CLAIM]) { + identity.email = attributes[EMAIL_CLAIM] as string; + } + // userInfo exposes cognito:username as plain `username`. + if (typeof attributes["username"] === "string" && attributes["username"]) { + identity.username = attributes["username"] as string; + } + return identity; + } catch (error) { + this.logger.warn(error, "Cognito userInfo request failed"); + return null; + } + } + + /** Best-effort revocation of a refresh token at Cognito; failures are logged, never thrown. */ + async revoke(token: string): Promise { + try { + const discovery = await this.discovery(); + if (!discovery.revocation_endpoint) { + return; + } + const headers: Record = { "Content-Type": "application/x-www-form-urlencoded" }; + if (this.config.clientSecret) { + const basic = Buffer.from(`${this.config.clientId}:${this.config.clientSecret}`).toString("base64"); + headers["Authorization"] = `Basic ${basic}`; + } + await fetch(discovery.revocation_endpoint, { + method: "POST", + headers, + body: new URLSearchParams({ token, client_id: this.config.clientId }), + }); + } catch (error) { + this.logger.warn(error, "Cognito token revocation failed (ignored)"); + } + } + + /** Builds the upstream authorize redirect with our own callback, state and PKCE pair. */ + async authorizeUrl(state: string, codeChallenge: string): Promise { + const discovery = await this.discovery(); + const url = new URL(discovery.authorization_endpoint); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", this.config.clientId); + url.searchParams.set("redirect_uri", this.config.callbackUrl); + url.searchParams.set("scope", UPSTREAM_SCOPE); + url.searchParams.set("state", state); + url.searchParams.set("code_challenge", codeChallenge); + url.searchParams.set("code_challenge_method", "S256"); + return url.href; + } +} diff --git a/mcp/packages/server/src/moneta/config.ts b/mcp/packages/server/src/moneta/config.ts new file mode 100644 index 00000000000..6cda2889b63 --- /dev/null +++ b/mcp/packages/server/src/moneta/config.ts @@ -0,0 +1,116 @@ +/** + * Moneta fork — environment configuration for the Cognito OAuth gate. + * + * The presence of COGNITO_USER_POOL_ID switches the server into Cognito mode + * (mirroring plane-mcp's `moneta.http.enabled()` convention). All other + * variables follow the names used by surfsense-mcp / plane-mcp so the + * foss-server-bundle compose blocks stay uniform across the three MCP servers: + * + * COGNITO_USER_POOL_ID ap-southeast-1_XXXXX — enables Cognito mode + * COGNITO_AWS_REGION / AWS_REGION Cognito region + * OIDC_CLIENT_ID pre-registered Cognito app client + * OIDC_CLIENT_SECRET optional — public/PKCE clients have none + * MCP_BASE_URL public URL of this server (https://design-mcp.) + * MCP_ALLOWED_ORIGINS CSV CORS allow-list, or '*' + * MCP_ALLOWED_CLIENT_REDIRECT_URIS CSV allow-list for DCR redirect URIs (unset = allow all) + * MCP_OAUTH_STORAGE_URL redis://valkey:6379/13 — persistent OAuth state (unset = in-memory) + * MCP_JWT_SIGNING_KEY storage-encryption key fallback when no client secret + * MCP_ENV 'production' warns when storage is left in-memory + */ + +export const CALLBACK_PATH = "/auth/callback"; + +/** + * Scopes requested from Cognito on the upstream authorize redirect. Fixed + * rather than forwarded from the MCP client: Cognito hard-rejects any scope + * not enabled on the app client (error=invalid_request/invalid_scope before + * the login page), and the Moneta app client allows exactly `openid` + + * `email` — which is also all the identity relay needs (the id_token carries + * `cognito:username` with `openid` alone and `email` with the email scope). + */ +export const UPSTREAM_SCOPE = "openid email"; + +export interface MonetaAuthConfig { + userPoolId: string; + region: string; + clientId: string; + clientSecret: string | undefined; + /** Cognito issuer URL: https://cognito-idp..amazonaws.com/ */ + issuer: string; + /** Public base URL of this MCP server, no trailing slash. */ + baseUrl: string; + /** Absolute URL of the upstream OAuth callback ({baseUrl}/auth/callback). */ + callbackUrl: string; + /** CORS allow-list; ["*"] means any origin. */ + allowedOrigins: string[]; + /** DCR redirect-URI allow-list; null means allow all. */ + allowedClientRedirectUris: string[] | null; + storageUrl: string | undefined; + /** Key material for at-rest encryption of OAuth state in Valkey. */ + encryptionSecret: string | undefined; + isProduction: boolean; +} + +/** + * Whether the Cognito OAuth gate is enabled. Keyed on COGNITO_USER_POOL_ID + * alone so upstream behaviour is completely untouched when it is unset. + */ +export function monetaAuthEnabled(): boolean { + return Boolean(process.env.COGNITO_USER_POOL_ID); +} + +function csv(value: string | undefined): string[] { + return (value ?? "") + .split(",") + .map((part) => part.trim()) + .filter((part) => part.length > 0); +} + +/** + * Reads and validates the Cognito configuration from the environment. + * Throws with a list of all missing variables so a misconfigured container + * fails fast at startup rather than on the first OAuth request. + */ +export function loadMonetaAuthConfig(): MonetaAuthConfig { + const userPoolId = process.env.COGNITO_USER_POOL_ID ?? ""; + const region = process.env.COGNITO_AWS_REGION ?? process.env.AWS_REGION ?? ""; + const clientId = process.env.OIDC_CLIENT_ID ?? ""; + const clientSecret = process.env.OIDC_CLIENT_SECRET || undefined; + const baseUrl = (process.env.MCP_BASE_URL ?? "").replace(/\/+$/, ""); + + const missing: string[] = []; + if (!userPoolId) missing.push("COGNITO_USER_POOL_ID"); + if (!region) missing.push("COGNITO_AWS_REGION (or AWS_REGION)"); + if (!clientId) missing.push("OIDC_CLIENT_ID"); + if (!baseUrl) missing.push("MCP_BASE_URL"); + if (missing.length > 0) { + throw new Error(`Cognito auth is enabled but configuration is incomplete; missing: ${missing.join(", ")}`); + } + + const storageUrl = process.env.MCP_OAUTH_STORAGE_URL || undefined; + const encryptionSecret = clientSecret ?? (process.env.MCP_JWT_SIGNING_KEY || undefined); + if (storageUrl && !encryptionSecret) { + throw new Error( + "MCP_OAUTH_STORAGE_URL is set but neither OIDC_CLIENT_SECRET nor MCP_JWT_SIGNING_KEY is available " + + "to derive the storage encryption key." + ); + } + + const origins = csv(process.env.MCP_ALLOWED_ORIGINS); + const redirectUris = csv(process.env.MCP_ALLOWED_CLIENT_REDIRECT_URIS); + + return { + userPoolId, + region, + clientId, + clientSecret, + issuer: `https://cognito-idp.${region}.amazonaws.com/${userPoolId}`, + baseUrl, + callbackUrl: `${baseUrl}${CALLBACK_PATH}`, + allowedOrigins: origins.length > 0 ? origins : [baseUrl], + allowedClientRedirectUris: redirectUris.length > 0 ? redirectUris : null, + storageUrl, + encryptionSecret, + isProduction: (process.env.MCP_ENV ?? "production") === "production", + }; +} diff --git a/mcp/packages/server/src/moneta/index.ts b/mcp/packages/server/src/moneta/index.ts new file mode 100644 index 00000000000..265d359bc68 --- /dev/null +++ b/mcp/packages/server/src/moneta/index.ts @@ -0,0 +1,23 @@ +/** + * Moneta fork additions for the Penpot MCP server. + * + * All Pressingly/Moneta-specific code lives in this package so the upstream + * modules stay as close to the Penpot original as possible. Hooks into + * upstream (kept intentionally tiny): + * + * - `index.ts` — forces multi-user mode when Cognito auth is enabled. + * - `PenpotMcpServer.ts` — `start()` calls `installMonetaAuth(app, logger)` + * before registering the /mcp /sse /messages routes. + * - `PluginBridge.ts` — prefers `monetaIdentityFromUpgrade(request)` over + * the ?userToken= query parameter when pairing + * plugin connections. + * + * The flow itself mirrors surfsense-mcp / plane-mcp: the /mcp endpoint is NOT + * behind mPass (Traefik keeps strip-auth-headers only) and this server is its + * own OAuth 2.0 authorization server proxying AWS Cognito, so MCP clients + * discover and complete the OAuth dance automatically. + */ + +export { monetaAuthEnabled } from "./config"; +export { installMonetaAuth } from "./install"; +export { monetaIdentityFromUpgrade } from "./bridge"; diff --git a/mcp/packages/server/src/moneta/install.ts b/mcp/packages/server/src/moneta/install.ts new file mode 100644 index 00000000000..1be6eea41bd --- /dev/null +++ b/mcp/packages/server/src/moneta/install.ts @@ -0,0 +1,184 @@ +/** + * Moneta fork — installs the Cognito OAuth gate onto the server's Express app. + * + * Called from PenpotMcpServer.start() (one guarded line) BEFORE the upstream + * /mcp /sse /messages routes are registered, so everything here is plain + * middleware ordering — no upstream handler is modified: + * + * 1. /healthz — unauthenticated, for the compose healthcheck. + * 2. CORS — MCP_ALLOWED_ORIGINS; answers preflight before auth. + * 3. mcpAuthRouter — /.well-known discovery, /register (DCR shim), + * /authorize, /token, /revoke (SDK-provided). + * 4. /auth/callback — the upstream Cognito redirect target. + * 5. Bearer guard — JWKS-validates the Cognito access token on + * /mcp, /sse and /messages. + * 6. Session binding — pins each mcp-session-id to the identity that + * created it (defense-in-depth against session-id reuse + * across users; the ids are unguessable UUIDs). + * 7. userToken rewrite — exposes the resolved identity as + * req.query.userToken, which is exactly what the + * untouched upstream handlers consume for plugin pairing. + */ + +import type { Logger } from "pino"; +import { getOAuthProtectedResourceMetadataUrl, mcpAuthRouter } from "@modelcontextprotocol/sdk/server/auth/router.js"; +import { requireBearerAuth } from "@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js"; +import { CognitoClient } from "./cognito"; +import { loadMonetaAuthConfig, UPSTREAM_SCOPE } from "./config"; +import { CognitoProxyProvider } from "./provider"; +import { buildOAuthStorage } from "./storage"; + +/** Express types are kept loose ("any") to match the host class's `app: any`. */ +type ExpressApp = any; + +const PROTECTED_PATHS = ["/mcp", "/sse", "/messages"]; +const SESSION_BINDING_TTL_MS = 24 * 3600 * 1000; + +function corsMiddleware(allowedOrigins: string[]) { + const allowAll = allowedOrigins.includes("*"); + return (req: any, res: any, next: any) => { + const origin = req.headers.origin as string | undefined; + if (origin && (allowAll || allowedOrigins.includes(origin))) { + res.setHeader("Access-Control-Allow-Origin", allowAll ? "*" : origin); + res.setHeader("Vary", "Origin"); + // Mcp-Session-Id must be readable by browser-based clients (MCP Inspector). + res.setHeader("Access-Control-Expose-Headers", "Mcp-Session-Id, WWW-Authenticate"); + } + if (req.method === "OPTIONS") { + res.setHeader( + "Access-Control-Allow-Methods", + (req.headers["access-control-request-method"] as string | undefined) ?? "GET, POST, DELETE, OPTIONS" + ); + res.setHeader( + "Access-Control-Allow-Headers", + (req.headers["access-control-request-headers"] as string | undefined) ?? + "Authorization, Content-Type, Mcp-Session-Id, Mcp-Protocol-Version, Last-Event-Id" + ); + res.setHeader("Access-Control-Max-Age", "86400"); + res.status(204).end(); + return; + } + next(); + }; +} + +/** + * Pins each MCP session id to the identity that first used it. The upstream + * streamable handler keys plugin pairing on the session's stored userToken, so + * without this a caller presenting a *different* user's valid Cognito token + * could attach to an existing session id and execute in the original user's + * Penpot plugin. + */ +function sessionBindingMiddleware(logger: Logger) { + const bindings = new Map(); + const sweep = setInterval(() => { + const now = Date.now(); + for (const [key, value] of bindings) { + if (value.expiresAt <= now) { + bindings.delete(key); + } + } + }, 60_000); + sweep.unref(); + + return (req: any, res: any, next: any) => { + const sessionId = + (req.headers["mcp-session-id"] as string | undefined) ?? + (typeof req.query.sessionId === "string" ? (req.query.sessionId as string) : undefined); + const identity = req.auth?.extra?.identity as string | undefined; + if (!sessionId || !identity) { + next(); + return; + } + const existing = bindings.get(sessionId); + if (existing && existing.expiresAt > Date.now() && existing.identity !== identity) { + logger.warn("Rejected session reuse across identities (session=%s)", sessionId.slice(0, 8)); + res.status(403).json({ error: "forbidden", error_description: "Session belongs to a different user" }); + return; + } + bindings.set(sessionId, { identity, expiresAt: Date.now() + SESSION_BINDING_TTL_MS }); + next(); + }; +} + +/** + * Replaces req.query.userToken with the Cognito-derived identity. The upstream + * /mcp and /sse handlers read exactly that key when creating a session, and the + * plugin bridge pairs connections by the same string — so this one property is + * the entire integration surface with upstream. Express 5 exposes req.query as + * a getter, hence defineProperty instead of assignment. + */ +function identityAsUserTokenMiddleware() { + return (req: any, _res: any, next: any) => { + const identity = req.auth?.extra?.identity as string | undefined; + if (identity) { + Object.defineProperty(req, "query", { + value: { ...req.query, userToken: identity }, + writable: true, + configurable: true, + }); + } + next(); + }; +} + +/** + * Installs the Cognito OAuth gate. Must be called before the upstream routes + * are registered (middleware order is the only sequencing Express honours). + */ +export async function installMonetaAuth(app: ExpressApp, logger: Logger): Promise { + const config = loadMonetaAuthConfig(); + if (config.allowedClientRedirectUris === null && config.isProduction) { + // Matches the FastMCP siblings' default, but in production an open + // allow-list lets any DCR client register any callback — the Cognito + // login + PKCE still gate token issuance, yet a phished user could be + // walked through authorizing a malicious client. Warn, don't fail. + logger.warn( + "MCP_ALLOWED_CLIENT_REDIRECT_URIS is unset — dynamic client registration accepts any " + + "redirect_uri. Set an allow-list for production deployments." + ); + } + const storage = await buildOAuthStorage(config, logger); + const cognito = new CognitoClient(config, logger); + const provider = new CognitoProxyProvider(config, cognito, storage, logger); + + app.get("/healthz", (_req: any, res: any) => res.status(200).send("ok")); + + app.use(corsMiddleware(config.allowedOrigins)); + + const baseUrl = new URL(config.baseUrl); + const resourceServerUrl = new URL(`${config.baseUrl}/mcp`); + // Quiet express-rate-limit's trust-proxy validation: behind Traefik every + // request shares the proxy's source IP, which the library flags. Limits stay on. + const rateLimit = { validate: false } as const; + app.use( + mcpAuthRouter({ + provider, + issuerUrl: baseUrl, + baseUrl, + resourceServerUrl, + resourceName: "Penpot MCP", + scopesSupported: UPSTREAM_SCOPE.split(" "), + authorizationOptions: { rateLimit }, + tokenOptions: { rateLimit }, + clientRegistrationOptions: { rateLimit }, + }) + ); + + app.get("/auth/callback", (req: any, res: any) => { + provider.handleCallback(req, res).catch((error: unknown) => { + logger.error(error, "Unhandled error in /auth/callback"); + if (!res.headersSent) { + res.status(500).send("Authorization callback failed"); + } + }); + }); + + const guard = requireBearerAuth({ + verifier: provider, + resourceMetadataUrl: getOAuthProtectedResourceMetadataUrl(resourceServerUrl), + }); + app.use(PROTECTED_PATHS, guard, sessionBindingMiddleware(logger), identityAsUserTokenMiddleware()); + + logger.info("Cognito OAuth gate installed (issuer=%s, callback=%s)", config.issuer, config.callbackUrl); +} diff --git a/mcp/packages/server/src/moneta/provider.ts b/mcp/packages/server/src/moneta/provider.ts new file mode 100644 index 00000000000..5f32aec0f0f --- /dev/null +++ b/mcp/packages/server/src/moneta/provider.ts @@ -0,0 +1,407 @@ +/** + * Moneta fork — OAuth 2.0 authorization-server facade over AWS Cognito. + * + * Implements the MCP TypeScript SDK's `OAuthServerProvider` so that + * `mcpAuthRouter` can publish RFC 8414 / 9728 discovery metadata, a DCR + * `/register` shim, and `/authorize` + `/token` endpoints — making this + * server self-describing for MCP clients (Claude, Cursor, MCP Inspector), + * which then run the whole OAuth dance automatically. This is the TypeScript + * counterpart of FastMCP's `AWSCognitoProvider` used by surfsense-mcp and + * plane-mcp. + * + * Why a two-hop redirect (not a passthrough proxy) + * ------------------------------------------------ + * Cognito has no dynamic client registration and only accepts pre-registered + * redirect URIs, while MCP clients register arbitrary callback URLs via DCR. + * So /authorize stores the client's request as a transaction and redirects to + * Cognito with OUR fixed callback ({MCP_BASE_URL}/auth/callback — the one URL + * registered on the Cognito app client); /auth/callback exchanges Cognito's + * code server-side, captures the id_token identity, mints OUR single-use + * authorization code, and redirects to the MCP client's own callback. The + * client then redeems our code at /token and receives the Cognito tokens. + * + * The inbound Bearer on /mcp is the Cognito access token, JWKS-verified on + * every request. Pairing identity (email) comes from the identity map written + * at exchange/refresh time, with Cognito's userInfo endpoint as a self-healing + * fallback; if no identity can be resolved the request fails closed with a 401 + * rather than letting an opaque-UUID identity through (it would pair with the + * wrong — or no — Penpot plugin session). + */ + +import { createHash, randomBytes, randomUUID } from "node:crypto"; +import type { Request, Response } from "express"; +import type { Logger } from "pino"; +import type { OAuthRegisteredClientsStore } from "@modelcontextprotocol/sdk/server/auth/clients.js"; +import type { AuthorizationParams, OAuthServerProvider } from "@modelcontextprotocol/sdk/server/auth/provider.js"; +import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; +import type { OAuthClientInformationFull, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js"; +import { + InvalidClientError, + InvalidGrantError, + InvalidRequestError, + InvalidTokenError, +} from "@modelcontextprotocol/sdk/server/auth/errors.js"; +import { CognitoClient, identityFromIdToken, pickPairingIdentity, type CognitoIdentity } from "./cognito"; +import type { MonetaAuthConfig } from "./config"; +import type { KeyValueStore } from "./storage"; + +const COLLECTION_CLIENTS = "clients"; +const COLLECTION_TXNS = "txns"; +const COLLECTION_CODES = "codes"; +const COLLECTION_IDENTITY = "identity"; + +const CLIENT_TTL_SECONDS = 90 * 24 * 3600; +const TXN_TTL_SECONDS = 600; +const CODE_TTL_SECONDS = 120; +/** Used when Cognito's token response carries no expires_in (it always should). */ +const DEFAULT_TOKEN_TTL_SECONDS = 3600; + +/** In-flight /authorize transaction, persisted between the redirect to Cognito and our callback. */ +interface AuthorizationTxn { + clientId: string; + redirectUri: string; + /** The MCP client's PKCE challenge, validated by the SDK at /token. */ + codeChallenge: string; + state?: string; + /** Our own PKCE verifier for the upstream Cognito leg. */ + upstreamVerifier: string; +} + +/** Payload behind one of our single-use authorization codes. */ +interface CodeRecord { + clientId: string; + redirectUri: string; + codeChallenge: string; + tokens: OAuthTokens; +} + +function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function base64url(buffer: Buffer): string { + return buffer.toString("base64url"); +} + +/** + * Matches an allow-list entry: exact, or prefix when the entry ends with '*'. + * Wildcard matching is URL-aware: the candidate must share the entry's exact + * origin (scheme + host + port) before the prefix test, so a too-broad entry + * like "https://example.com*" can never match a host-extension such as + * "https://example.com.evil/cb". Unparseable values never match. + */ +function redirectUriAllowed(uri: string, allowList: string[] | null): boolean { + if (allowList === null) { + return true; + } + return allowList.some((entry) => { + if (!entry.endsWith("*")) { + return uri === entry; + } + const prefix = entry.slice(0, -1); + let entryOrigin: string; + let uriOrigin: string; + try { + entryOrigin = new URL(prefix).origin; + uriOrigin = new URL(uri).origin; + } catch { + return false; + } + return uriOrigin === entryOrigin && uri.startsWith(prefix); + }); +} + +/** + * DCR client store. The SDK's /register handler generates client_id/secret; + * we only validate redirect URIs against MCP_ALLOWED_CLIENT_REDIRECT_URIS + * (unset = allow all, matching the FastMCP servers' default — the real gate + * is the Cognito login plus PKCE, not the registration). + */ +export class MonetaClientsStore implements OAuthRegisteredClientsStore { + constructor( + private readonly storage: KeyValueStore, + private readonly allowedRedirectUris: string[] | null, + private readonly logger: Logger + ) {} + + async getClient(clientId: string): Promise { + const stored = await this.storage.get(COLLECTION_CLIENTS, clientId); + return stored ? (JSON.parse(stored) as OAuthClientInformationFull) : undefined; + } + + async registerClient( + client: Omit & + Partial> + ): Promise { + for (const uri of client.redirect_uris ?? []) { + if (!redirectUriAllowed(uri, this.allowedRedirectUris)) { + throw new InvalidRequestError(`redirect_uri is not allowed by this server: ${uri}`); + } + } + const full = client as OAuthClientInformationFull; + await this.storage.set(COLLECTION_CLIENTS, full.client_id, JSON.stringify(full), CLIENT_TTL_SECONDS); + this.logger.info( + "Registered MCP client %s (%s)", + full.client_id, + full.client_name ?? (full.redirect_uris ?? []).join(",") + ); + return full; + } +} + +export class CognitoProxyProvider implements OAuthServerProvider { + public readonly clientsStore: MonetaClientsStore; + /** We validate the MCP client's PKCE locally; the upstream leg has its own verifier. */ + public readonly skipLocalPkceValidation = false; + + constructor( + private readonly config: MonetaAuthConfig, + private readonly cognito: CognitoClient, + private readonly storage: KeyValueStore, + private readonly logger: Logger + ) { + this.clientsStore = new MonetaClientsStore(storage, config.allowedClientRedirectUris, logger); + } + + /** + * First hop: park the client's authorize request as a transaction and + * redirect the browser to Cognito with our fixed callback. The RFC 8707 + * `resource` parameter is intentionally dropped — Cognito does not support + * it, same as the FastMCP-based siblings. + */ + async authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise { + const txnId = randomUUID(); + const upstreamVerifier = base64url(randomBytes(48)); + const upstreamChallenge = base64url(createHash("sha256").update(upstreamVerifier).digest()); + const txn: AuthorizationTxn = { + clientId: client.client_id, + redirectUri: params.redirectUri, + codeChallenge: params.codeChallenge, + state: params.state, + upstreamVerifier, + }; + await this.storage.set(COLLECTION_TXNS, txnId, JSON.stringify(txn), TXN_TTL_SECONDS); + const url = await this.cognito.authorizeUrl(txnId, upstreamChallenge); + this.logger.info("authorize: client=%s txn=%s -> Cognito", client.client_id, txnId.slice(0, 8)); + res.redirect(url); + } + + /** + * Second hop: Cognito redirected back to {MCP_BASE_URL}/auth/callback. + * Exchange the upstream code, capture the id_token identity, mint our own + * single-use code and send the browser on to the MCP client's callback. + */ + async handleCallback(req: Request, res: Response): Promise { + const state = typeof req.query.state === "string" ? req.query.state : undefined; + const code = typeof req.query.code === "string" ? req.query.code : undefined; + const upstreamError = typeof req.query.error === "string" ? req.query.error : undefined; + const upstreamErrorDescription = + typeof req.query.error_description === "string" ? req.query.error_description : undefined; + + const storedTxn = state ? await this.storage.get(COLLECTION_TXNS, state) : null; + if (!storedTxn) { + this.logger.warn("auth callback with unknown or expired state"); + res.status(400).send("Invalid or expired authorization request. Please retry from your MCP client."); + return; + } + await this.storage.delete(COLLECTION_TXNS, state as string); + const txn = JSON.parse(storedTxn) as AuthorizationTxn; + + const redirectError = (error: string, description: string): void => { + const target = new URL(txn.redirectUri); + target.searchParams.set("error", error); + target.searchParams.set("error_description", description); + if (txn.state !== undefined) { + target.searchParams.set("state", txn.state); + } + res.redirect(target.href); + }; + + if (upstreamError || !code) { + // Cognito puts the useful detail (e.g. "invalid_scope") in + // error_description — preserve it for the log and the MCP client. + this.logger.warn( + "Cognito returned an authorize error: %s (%s)", + upstreamError ?? "missing code", + upstreamErrorDescription ?? "no error_description" + ); + redirectError(upstreamError ?? "access_denied", upstreamErrorDescription ?? "Cognito authorization failed"); + return; + } + + let tokens; + try { + tokens = await this.cognito.exchangeCode(code, txn.upstreamVerifier); + } catch (error) { + this.logger.error(error, "Cognito code exchange failed"); + redirectError("server_error", "Token exchange with Cognito failed"); + return; + } + + await this.rememberIdentity(tokens.access_token, tokens.id_token, tokens.expires_in); + + const ourCode = base64url(randomBytes(32)); + const record: CodeRecord = { + clientId: txn.clientId, + redirectUri: txn.redirectUri, + codeChallenge: txn.codeChallenge, + tokens: { + access_token: tokens.access_token, + token_type: tokens.token_type ?? "Bearer", + expires_in: tokens.expires_in, + refresh_token: tokens.refresh_token, + id_token: tokens.id_token, + scope: tokens.scope, + }, + }; + await this.storage.set(COLLECTION_CODES, ourCode, JSON.stringify(record), CODE_TTL_SECONDS); + + const target = new URL(txn.redirectUri); + target.searchParams.set("code", ourCode); + if (txn.state !== undefined) { + target.searchParams.set("state", txn.state); + } + this.logger.info("auth callback: minted code for client=%s", txn.clientId); + res.redirect(target.href); + } + + async challengeForAuthorizationCode( + client: OAuthClientInformationFull, + authorizationCode: string + ): Promise { + const stored = await this.storage.get(COLLECTION_CODES, authorizationCode); + if (!stored) { + throw new InvalidGrantError("Unknown or expired authorization code"); + } + const record = JSON.parse(stored) as CodeRecord; + if (record.clientId !== client.client_id) { + throw new InvalidClientError("Authorization code was issued to a different client"); + } + return record.codeChallenge; + } + + async exchangeAuthorizationCode( + client: OAuthClientInformationFull, + authorizationCode: string, + _codeVerifier?: string, + redirectUri?: string + ): Promise { + const stored = await this.storage.get(COLLECTION_CODES, authorizationCode); + if (!stored) { + throw new InvalidGrantError("Unknown or expired authorization code"); + } + // Single use: drop before returning tokens so a replayed code always fails. + await this.storage.delete(COLLECTION_CODES, authorizationCode); + const record = JSON.parse(stored) as CodeRecord; + if (record.clientId !== client.client_id) { + throw new InvalidClientError("Authorization code was issued to a different client"); + } + if (redirectUri && redirectUri !== record.redirectUri) { + throw new InvalidGrantError("redirect_uri does not match the authorization request"); + } + this.logger.info("token: code exchanged for client=%s", client.client_id); + return record.tokens; + } + + async exchangeRefreshToken( + client: OAuthClientInformationFull, + refreshToken: string, + _scopes?: string[] + ): Promise { + let response; + try { + response = await this.cognito.refresh(refreshToken); + } catch (error) { + this.logger.warn(error, "Cognito refresh grant failed"); + throw new InvalidGrantError("Refresh token was rejected by Cognito"); + } + // A refresh response carries a fresh id_token — re-capture identity so + // the pairing map stays warm across access-token rotations and restarts. + await this.rememberIdentity(response.access_token, response.id_token, response.expires_in); + this.logger.info("token: refresh grant served for client=%s", client.client_id); + return { + access_token: response.access_token, + token_type: response.token_type ?? "Bearer", + expires_in: response.expires_in, + // Cognito does not rotate refresh tokens; hand the original back. + refresh_token: response.refresh_token ?? refreshToken, + id_token: response.id_token, + scope: response.scope, + }; + } + + async verifyAccessToken(token: string): Promise { + let payload; + try { + payload = await this.cognito.verifyAccessToken(token); + } catch (error) { + // Wrap into the SDK's error type so requireBearerAuth answers 401 + // (anything else becomes an opaque 500). + throw new InvalidTokenError(error instanceof Error ? error.message : "Token verification failed"); + } + + const identity = await this.resolveIdentity(token, payload.exp); + const username = typeof payload.username === "string" ? payload.username : undefined; + const pairingIdentity = pickPairingIdentity(identity ?? {}, username); + if (!pairingIdentity) { + // Fail closed: without email/cognito:username we could only pair by + // the opaque UUID sub, which matches no mPass identity header. + throw new InvalidTokenError( + "Could not resolve a user identity (email/username) for this token — re-authorize" + ); + } + + return { + token, + clientId: typeof payload.client_id === "string" ? payload.client_id : this.config.clientId, + scopes: typeof payload.scope === "string" ? payload.scope.split(" ") : [], + expiresAt: typeof payload.exp === "number" ? payload.exp : undefined, + extra: { + identity: pairingIdentity, + email: identity?.email, + username: identity?.username ?? username, + }, + }; + } + + /** Best-effort passthrough to Cognito's revocation endpoint (refresh tokens only — access tokens just expire). */ + async revokeToken(_client: OAuthClientInformationFull, request: { token: string }): Promise { + await this.cognito.revoke(request.token); + } + + private async rememberIdentity( + accessToken: string, + idToken: string | undefined, + expiresIn: number | undefined + ): Promise { + if (!idToken) { + this.logger.warn("Cognito token response has no id_token — identity will rely on the userInfo fallback"); + return; + } + const identity = identityFromIdToken(idToken, this.logger); + if (!identity.email && !identity.username) { + this.logger.warn("Cognito id_token carries neither email nor cognito:username"); + return; + } + await this.storage.set( + COLLECTION_IDENTITY, + sha256(accessToken), + JSON.stringify(identity), + expiresIn ?? DEFAULT_TOKEN_TTL_SECONDS + ); + } + + private async resolveIdentity(token: string, exp: number | undefined): Promise { + const stored = await this.storage.get(COLLECTION_IDENTITY, sha256(token)); + if (stored) { + return JSON.parse(stored) as CognitoIdentity; + } + const fetched = await this.cognito.userInfo(token); + if (fetched && (fetched.email || fetched.username)) { + const ttl = exp ? Math.max(exp - Math.floor(Date.now() / 1000), 60) : DEFAULT_TOKEN_TTL_SECONDS; + await this.storage.set(COLLECTION_IDENTITY, sha256(token), JSON.stringify(fetched), ttl); + return fetched; + } + return null; + } +} diff --git a/mcp/packages/server/src/moneta/storage.ts b/mcp/packages/server/src/moneta/storage.ts new file mode 100644 index 00000000000..893debfd92d --- /dev/null +++ b/mcp/packages/server/src/moneta/storage.ts @@ -0,0 +1,174 @@ +/** + * Moneta fork — key-value storage for OAuth state. + * + * The Cognito gate keeps four collections of state: DCR client registrations, + * in-flight authorize transactions, our own authorization codes, and the + * access-token → identity map. Two backends: + * + * - In-memory (default). Restarts drop everything, which is survivable: + * identity is re-derived from Cognito's userInfo endpoint, short-lived + * txns/codes just force the user back through /authorize, and MCP clients + * re-register on an invalid_client error. Single-replica only. + * - Valkey/Redis via MCP_OAUTH_STORAGE_URL (redis://valkey:6379/13 in the + * devstack), mirroring surfsense-mcp (DB 11) and plane-mcp (DB 12). Values + * are AES-256-GCM encrypted with a key HKDF-derived from the client secret + * (or MCP_JWT_SIGNING_KEY for public clients) so the RDB/AOF on disk never + * carries plaintext tokens — the same property FastMCP's Fernet wrapper + * gives the Python MCP servers. + * + * Keys are namespaced `penpot-mcp-oauth::::`; '::' matches + * the compound-separator convention of the sibling MCP servers' storage. + */ + +import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from "node:crypto"; +import type { Logger } from "pino"; + +export interface KeyValueStore { + get(collection: string, key: string): Promise; + set(collection: string, key: string, value: string, ttlSeconds: number): Promise; + delete(collection: string, key: string): Promise; +} + +const KEY_PREFIX = "penpot-mcp-oauth"; + +function compoundKey(collection: string, key: string): string { + return `${KEY_PREFIX}::${collection}::${key}`; +} + +/** + * Process-local store with TTL expiry. A periodic sweep (unref'd so it never + * keeps the process alive) bounds memory; expiry is also checked on read. + */ +export class MemoryStore implements KeyValueStore { + private readonly entries = new Map(); + private readonly sweeper: NodeJS.Timeout; + + constructor(sweepIntervalMs: number = 60_000) { + this.sweeper = setInterval(() => this.sweep(), sweepIntervalMs); + this.sweeper.unref(); + } + + private sweep(): void { + const now = Date.now(); + for (const [key, entry] of this.entries) { + if (entry.expiresAt <= now) { + this.entries.delete(key); + } + } + } + + async get(collection: string, key: string): Promise { + const entry = this.entries.get(compoundKey(collection, key)); + if (!entry) { + return null; + } + if (entry.expiresAt <= Date.now()) { + this.entries.delete(compoundKey(collection, key)); + return null; + } + return entry.value; + } + + async set(collection: string, key: string, value: string, ttlSeconds: number): Promise { + this.entries.set(compoundKey(collection, key), { value, expiresAt: Date.now() + ttlSeconds * 1000 }); + } + + async delete(collection: string, key: string): Promise { + this.entries.delete(compoundKey(collection, key)); + } +} + +/** + * AES-256-GCM with a per-value random salt and IV. Layout (base64): + * salt(16) | iv(12) | authTag(16) | ciphertext. The key is HKDF-derived per + * value from the configured secret, so rotating the secret invalidates stored + * state cleanly (decrypt failures read as cache misses). + */ +export class ValueEncryption { + constructor(private readonly secret: string) {} + + encrypt(plaintext: string): string { + const salt = randomBytes(16); + const iv = randomBytes(12); + const key = Buffer.from(hkdfSync("sha256", this.secret, salt, "penpot-mcp-oauth-storage", 32)); + const cipher = createCipheriv("aes-256-gcm", key, iv); + const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + return Buffer.concat([salt, iv, cipher.getAuthTag(), ciphertext]).toString("base64"); + } + + decrypt(encoded: string): string | null { + try { + const raw = Buffer.from(encoded, "base64"); + const salt = raw.subarray(0, 16); + const iv = raw.subarray(16, 28); + const tag = raw.subarray(28, 44); + const ciphertext = raw.subarray(44); + const key = Buffer.from(hkdfSync("sha256", this.secret, salt, "penpot-mcp-oauth-storage", 32)); + const decipher = createDecipheriv("aes-256-gcm", key, iv); + decipher.setAuthTag(tag); + return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8"); + } catch { + return null; + } + } +} + +/** Valkey/Redis-backed store; values encrypted at rest (see ValueEncryption). */ +export class ValkeyStore implements KeyValueStore { + // Type kept loose so ioredis stays a constructor-injected dependency + // (no module-level import side effects when Cognito mode is off). + constructor( + private readonly redis: { + get(key: string): Promise; + set(key: string, value: string, mode: "EX", ttl: number): Promise; + del(key: string): Promise; + }, + private readonly encryption: ValueEncryption + ) {} + + async get(collection: string, key: string): Promise { + const stored = await this.redis.get(compoundKey(collection, key)); + if (stored === null) { + return null; + } + return this.encryption.decrypt(stored); + } + + async set(collection: string, key: string, value: string, ttlSeconds: number): Promise { + await this.redis.set(compoundKey(collection, key), this.encryption.encrypt(value), "EX", Math.ceil(ttlSeconds)); + } + + async delete(collection: string, key: string): Promise { + await this.redis.del(compoundKey(collection, key)); + } +} + +/** + * Builds the configured store: Valkey when MCP_OAUTH_STORAGE_URL is set, + * otherwise in-memory (with a warning in production, mirroring surfsense-mcp's + * warn_if_storage_missing_in_production — not a hard failure, so evaluation + * runs of the image still come up). + */ +export async function buildOAuthStorage( + config: { storageUrl?: string; encryptionSecret?: string; isProduction: boolean }, + logger: Logger +): Promise { + if (!config.storageUrl) { + if (config.isProduction) { + logger.warn( + "MCP_OAUTH_STORAGE_URL is not set — OAuth state is in-memory and every restart will force " + + "MCP clients to re-authorize. Set it to a Valkey/Redis URL for production." + ); + } + return new MemoryStore(); + } + if (!config.encryptionSecret) { + // loadMonetaAuthConfig enforces this; repeated here so the store is safe standalone. + throw new Error("OAuth storage requires an encryption secret (OIDC_CLIENT_SECRET or MCP_JWT_SIGNING_KEY)."); + } + const { default: Redis } = await import("ioredis"); + const redis = new Redis(config.storageUrl, { maxRetriesPerRequest: 2 }); + redis.on("error", (error: Error) => logger.error(error, "OAuth storage (Valkey) connection error")); + logger.info("OAuth state storage: Valkey (%s)", config.storageUrl.replace(/\/\/[^@]*@/, "//***@")); + return new ValkeyStore(redis, new ValueEncryption(config.encryptionSecret)); +} diff --git a/mcp/pnpm-lock.yaml b/mcp/pnpm-lock.yaml index c59ac34c3f7..648b75b39d8 100644 --- a/mcp/pnpm-lock.yaml +++ b/mcp/pnpm-lock.yaml @@ -57,6 +57,12 @@ importers: express: specifier: ^5.1.0 version: 5.2.1 + ioredis: + specifier: ^5.4.1 + version: 5.11.1 + jose: + specifier: ^5.9.6 + version: 5.10.0 js-yaml: specifier: ^4.1.1 version: 4.1.1 @@ -598,6 +604,9 @@ packages: cpu: [x64] os: [win32] + '@ioredis/commands@1.10.0': + resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==} + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -915,6 +924,10 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} + cluster-key-slot@1.1.1: + resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==} + engines: {node: '>=0.10.0'} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -978,6 +991,10 @@ packages: supports-color: optional: true + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -1149,6 +1166,10 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ioredis@5.11.1: + resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==} + engines: {node: '>=12.22.0'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -1163,6 +1184,9 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + jose@5.10.0: + resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + jose@6.1.3: resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} @@ -1328,6 +1352,14 @@ packages: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + reflect-metadata@0.1.14: resolution: {integrity: sha512-ZhYeb6nRaXCfhnndflDK8qI6ZQ/YcWZCISRAWICW9XYqMUwjZM9Z0DveWX/ABN01oxSHwVxKQmxeYZSsm0jh5A==} @@ -1420,6 +1452,9 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -1868,6 +1903,8 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true + '@ioredis/commands@1.10.0': {} + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.5.5': {} @@ -2139,6 +2176,8 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + cluster-key-slot@1.1.1: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -2189,6 +2228,8 @@ snapshots: dependencies: ms: 2.1.3 + denque@2.1.0: {} + depd@2.0.0: {} detect-libc@2.1.2: {} @@ -2408,6 +2449,18 @@ snapshots: inherits@2.0.4: {} + ioredis@5.11.1: + dependencies: + '@ioredis/commands': 1.10.0 + cluster-key-slot: 1.1.1 + debug: 4.4.3 + denque: 2.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + ipaddr.js@1.9.1: {} is-fullwidth-code-point@3.0.0: {} @@ -2416,6 +2469,8 @@ snapshots: isexe@2.0.0: {} + jose@5.10.0: {} + jose@6.1.3: {} joycon@3.1.1: {} @@ -2564,6 +2619,12 @@ snapshots: real-require@0.2.0: {} + redis-errors@1.2.0: {} + + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + reflect-metadata@0.1.14: {} require-directory@2.1.1: {} @@ -2725,6 +2786,8 @@ snapshots: split2@4.2.0: {} + standard-as-callback@2.1.0: {} + statuses@2.0.2: {} string-width@4.2.3: diff --git a/mcp/scripts/build b/mcp/scripts/build index e31b9a5ef2d..5bbe01df3c5 100755 --- a/mcp/scripts/build +++ b/mcp/scripts/build @@ -36,6 +36,18 @@ cp -a packages/server/dist/. ./dist/; cp packages/server/package.json ./dist/; cp pnpm-lock.yaml ./dist/; +# Strip the workspace self-reference ("penpot-mcp": "file:..") from the +# shipped package.json: inside the docker image it resolves to /opt/penpot — +# the install's own parent directory — and pnpm packs that tree while mutating +# its cache under the same path, failing with ERR_PNPM_ENOENT. The runtime +# bundle only needs the esbuild-external dependencies. +node -e ' +const fs = require("fs"); +const pkg = JSON.parse(fs.readFileSync("./dist/package.json", "utf8")); +delete pkg.dependencies["penpot-mcp"]; +fs.writeFileSync("./dist/package.json", JSON.stringify(pkg, null, 4) + "\n"); +'; + touch ./dist/pnpm-workspace.yaml; cat <