From 41949210b5fbc22c9223e28f4f87f9b48cb9b6b9 Mon Sep 17 00:00:00 2001 From: Usama Sadiq Date: Tue, 14 Jul 2026 13:06:49 +0500 Subject: [PATCH 1/3] :sparkles: Bypass Cognito hosted UI when federated IdP is configured Signed-off-by: Usama Sadiq --- mcp/packages/server/src/moneta/cognito.ts | 277 ++++++++++++++++++++++ mcp/packages/server/src/moneta/config.ts | 121 ++++++++++ 2 files changed, 398 insertions(+) create mode 100644 mcp/packages/server/src/moneta/cognito.ts create mode 100644 mcp/packages/server/src/moneta/config.ts diff --git a/mcp/packages/server/src/moneta/cognito.ts b/mcp/packages/server/src/moneta/cognito.ts new file mode 100644 index 00000000000..6d2c3115e66 --- /dev/null +++ b/mcp/packages/server/src/moneta/cognito.ts @@ -0,0 +1,277 @@ +/** + * 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"); + if (this.config.identityProvider) { + url.searchParams.set("identity_provider", this.config.identityProvider); + } + 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..ec7c8dd5540 --- /dev/null +++ b/mcp/packages/server/src/moneta/config.ts @@ -0,0 +1,121 @@ +/** + * 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; + /** Federated IdP name (e.g. "mPass") — when set, /authorize passes identity_provider to bypass Cognito's hosted UI. */ + identityProvider: 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 identityProvider = (process.env.COGNITO_IDENTITY_PROVIDER ?? "").trim() || undefined; + + 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, + identityProvider, + isProduction: (process.env.MCP_ENV ?? "production") === "production", + }; +} From cf6e3256d8e9ece6a8b2f4a2b5b2f772601eede6 Mon Sep 17 00:00:00 2001 From: Usama Sadiq Date: Tue, 14 Jul 2026 19:02:37 +0500 Subject: [PATCH 2/3] Use COGNITO_UPSTREAM_AUTH_URL to redirect via mpass-auth-proxy --- mcp/packages/server/src/moneta/cognito.ts | 3 ++- mcp/packages/server/src/moneta/config.ts | 8 ++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/mcp/packages/server/src/moneta/cognito.ts b/mcp/packages/server/src/moneta/cognito.ts index 6d2c3115e66..daab7badd2b 100644 --- a/mcp/packages/server/src/moneta/cognito.ts +++ b/mcp/packages/server/src/moneta/cognito.ts @@ -261,7 +261,8 @@ export class CognitoClient { /** 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); + const baseEndpoint = this.config.upstreamAuthUrl ?? discovery.authorization_endpoint; + const url = new URL(baseEndpoint); url.searchParams.set("response_type", "code"); url.searchParams.set("client_id", this.config.clientId); url.searchParams.set("redirect_uri", this.config.callbackUrl); diff --git a/mcp/packages/server/src/moneta/config.ts b/mcp/packages/server/src/moneta/config.ts index ec7c8dd5540..4f6b9c98482 100644 --- a/mcp/packages/server/src/moneta/config.ts +++ b/mcp/packages/server/src/moneta/config.ts @@ -48,8 +48,8 @@ export interface MonetaAuthConfig { storageUrl: string | undefined; /** Key material for at-rest encryption of OAuth state in Valkey. */ encryptionSecret: string | undefined; - /** Federated IdP name (e.g. "mPass") — when set, /authorize passes identity_provider to bypass Cognito's hosted UI. */ - identityProvider: string | undefined; + /** Override Cognito's authorization_endpoint with a custom auth proxy URL (e.g. mpass-auth-proxy). */ + upstreamAuthUrl: string | undefined; isProduction: boolean; } @@ -98,7 +98,7 @@ export function loadMonetaAuthConfig(): MonetaAuthConfig { ); } - const identityProvider = (process.env.COGNITO_IDENTITY_PROVIDER ?? "").trim() || undefined; + const upstreamAuthUrl = (process.env.COGNITO_UPSTREAM_AUTH_URL ?? "").trim() || undefined; const origins = csv(process.env.MCP_ALLOWED_ORIGINS); const redirectUris = csv(process.env.MCP_ALLOWED_CLIENT_REDIRECT_URIS); @@ -115,7 +115,7 @@ export function loadMonetaAuthConfig(): MonetaAuthConfig { allowedClientRedirectUris: redirectUris.length > 0 ? redirectUris : null, storageUrl, encryptionSecret, - identityProvider, + upstreamAuthUrl, isProduction: (process.env.MCP_ENV ?? "production") === "production", }; } From 528a1bb2444b6198059ea75b4bdc76515104de39 Mon Sep 17 00:00:00 2001 From: Usama Sadiq Date: Tue, 14 Jul 2026 20:46:57 +0500 Subject: [PATCH 3/3] :sparkles: Add COGNITO_UPSTREAM_TOKEN_URL override for mpass-auth-proxy token exchange Signed-off-by: Usama Sadiq --- mcp/packages/server/src/moneta/cognito.ts | 3 ++- mcp/packages/server/src/moneta/config.ts | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/mcp/packages/server/src/moneta/cognito.ts b/mcp/packages/server/src/moneta/cognito.ts index daab7badd2b..20136121b36 100644 --- a/mcp/packages/server/src/moneta/cognito.ts +++ b/mcp/packages/server/src/moneta/cognito.ts @@ -172,13 +172,14 @@ export class CognitoClient { private async tokenRequest(params: Record): Promise { const discovery = await this.discovery(); + const tokenEndpoint = this.config.upstreamTokenUrl ?? discovery.token_endpoint; 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 }); + const response = await fetch(tokenEndpoint, { 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)}`); diff --git a/mcp/packages/server/src/moneta/config.ts b/mcp/packages/server/src/moneta/config.ts index 4f6b9c98482..63bf742cb91 100644 --- a/mcp/packages/server/src/moneta/config.ts +++ b/mcp/packages/server/src/moneta/config.ts @@ -50,6 +50,8 @@ export interface MonetaAuthConfig { encryptionSecret: string | undefined; /** Override Cognito's authorization_endpoint with a custom auth proxy URL (e.g. mpass-auth-proxy). */ upstreamAuthUrl: string | undefined; + /** Override Cognito's token_endpoint with a custom auth proxy URL (e.g. mpass-auth-proxy). */ + upstreamTokenUrl: string | undefined; isProduction: boolean; } @@ -99,6 +101,7 @@ export function loadMonetaAuthConfig(): MonetaAuthConfig { } const upstreamAuthUrl = (process.env.COGNITO_UPSTREAM_AUTH_URL ?? "").trim() || undefined; + const upstreamTokenUrl = (process.env.COGNITO_UPSTREAM_TOKEN_URL ?? "").trim() || undefined; const origins = csv(process.env.MCP_ALLOWED_ORIGINS); const redirectUris = csv(process.env.MCP_ALLOWED_CLIENT_REDIRECT_URIS); @@ -116,6 +119,7 @@ export function loadMonetaAuthConfig(): MonetaAuthConfig { storageUrl, encryptionSecret, upstreamAuthUrl, + upstreamTokenUrl, isProduction: (process.env.MCP_ENV ?? "production") === "production", }; }