From a132f1378447d7aba6ad7af27238fcb48b457a8a Mon Sep 17 00:00:00 2001 From: Eugene Samotija Date: Wed, 29 Jul 2026 11:18:24 -0400 Subject: [PATCH] upstream: mint OAuth2 tokens for upstreams that want a finished bearer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some third-party MCP servers (CIPP behind App Service Easy Auth) expect an Authorization: Bearer rather than a static key — so a pasted token dies within the hour. New optional spec block auth: { kind: "oauth2-client-credentials", tokenUrl, clientId, clientSecret: "kv:…", scope, header?, prefix? } makes the gateway do the client-credentials exchange itself at connect time: the secret is resolved from the store/env like any injected value, the token is cached with the connection, and connect() rebuilds the transport once the token is within 60s of expiry (headers are fixed per transport). Neither the secret nor the token is logged. Also a CIPP preset — admin-only grants by design: its tools arrive annotated read-only even when they surface LAPS passwords and BitLocker keys. Release v0.10.0. Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 4 +- README.md | 3 +- package.json | 2 +- packages/gateway/package.json | 2 +- packages/gateway/src/config.ts | 21 ++ packages/gateway/src/domain/presets.test.ts | 27 ++- packages/gateway/src/domain/presets.ts | 38 ++++ packages/gateway/src/upstream/connection.ts | 79 +++++++- .../gateway/src/upstream/oauth2-auth.test.ts | 181 ++++++++++++++++++ 9 files changed, 345 insertions(+), 12 deletions(-) create mode 100644 packages/gateway/src/upstream/oauth2-auth.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index d1ea4e0..782fcb6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,11 +14,11 @@ Self-hosted MCP manager/gateway: one streamable-HTTP `/mcp` endpoint federating - `config.ts` — flags/env + `mspstack.config.json` (`ConfigError`); parses `MCP_TOKENS_` lists (duplicate labels are a `ConfigError` — labels are /me identities), OIDC (`OIDC_ISSUER`/`ENTRA_TENANT_ID` + required `OIDC_AUDIENCE`), `BAO_*` - `db/` — `node:sqlite` schema (roles/upstreams/grants/tool_overrides/tool_settings/users/group_mappings, seeded viewer/editor/admin) + typed `Repo` - `domain/catalog.ts` — namespacing (`${namespace}_${tool}`, no double-prefix), routing map (no string-splitting), annotation-derived tiers (port of mcp-itglue `tierOf`) -- `domain/presets.ts` — one-click upstream presets: builtin family configs (itglue/cwpsa/planner — full specs incl. BYOK headers, per-user mode, userConnect, personalCredentials) + optional `mspstack.presets.json` (file overrides builtin ids); `{{param}}` templating rendered server-side and validated via `parseUpstreamSpec`; recommended grants by role NAME resolved at install (`GET /api/presets`, `POST /api/presets/:id/install` with `dryRun`). Spec's `personalCredentials` metadata drives the /me guided credential forms (`credentialFields` in `/api/me/access`) +- `domain/presets.ts` — one-click upstream presets: builtin family configs (itglue/cwpsa/planner/cipp — full specs incl. BYOK headers, per-user mode, userConnect, personalCredentials, the `auth` mint block) + optional `mspstack.presets.json` (file overrides builtin ids); `{{param}}` templating rendered server-side and validated via `parseUpstreamSpec`; recommended grants by role NAME resolved at install (`GET /api/presets`, `POST /api/presets/:id/install` with `dryRun`). Spec's `personalCredentials` metadata drives the /me guided credential forms (`credentialFields` in `/api/me/access`) - `domain/policy.ts` — `PolicyService`: toolEnabled ∧ (override(allow) ∨ (tier ≤ maxTier ∧ ¬deny)); maxTier = per-upstream grant ?? role default. Same function gates tools/list AND tools/call. `allowsFor(principal, entry)` = envelope ∧ personal prefs (deny-only rows in `user_prefs`; "enable" deletes the row — narrowing can never widen) - `auth/` — `static-tokens.ts` (timing-safe bearer match), `oidc.ts` (jose JWKS resource-server verifier for inbound *access* tokens), `login.ts` (interactive login: openid-client cookie+PKCE confidential-client flow consuming an *id-token*; signed identity-only session cookie, HMAC + freshness; `safeReturnTo`), `authz-server.ts` (OAuth AS facade: RFC 8414 metadata, RFC 7591 DCR for public clients, single-use hashed 60s codes + PKCE S256, HS256 gateway JWTs keyed by `GATEWAY_JWT_SECRET` (default derived from `SESSION_SECRET`), rotating refresh tokens — 30d sliding, family-revoked on replay, client-bound consume that can't burn a live token — register rate limit; clients managed via `/api/oauth-clients` + Users tab), `prm.ts` (RFC 9728 doc + WWW-Authenticate; lists the gateway itself as AS when login is configured, else the raw IdP), `directory.ts` (app-only Graph search of Entra users/groups via the login app's own creds — powers the admin UI group-mapping typeahead at `/api/directory/search`; null for non-Entra issuers → UI degrades to paste-an-id), `principal.ts` (session binding key). Four inbound auth paths in `createAuthResolver`: static token, gateway-issued JWT (routed by unverified `iss == PUBLIC_URL`, then fully verified), OIDC bearer, and the cookie session — the cookie/JWT carry only identity and the role is re-resolved every request (persisted at callback via `setUserRole`), so a session id never carries privilege. `loginUpsert()` is shared by the bearer + callback paths so they can't drift. `/oauth/authorize` brokers user auth to Entra by piggybacking the interactive login: the pending request rides in the signed transient cookie and `/auth/callback` mints the code. - `secrets/` — `SecretStore` interface (scheme-tagged: `bao` | `kv`), `openbao.ts` (KV v2, AppRole or token, 5-min cache), `keyvault.ts` (Azure Key Vault, `DefaultAzureCredential`, lazy SDK import, same 5-min cache; `put(path, field)` writes `path-field`), `memory.ts` (tests). Refs: `bao:path#field` / `kv:secret-name`; env refs: `${VAR}` — all resolved only at upstream connect time. One store at a time (`BAO_ADDR` xor `KEY_VAULT_URI`) -- `upstream/connection.ts` — one pooled SDK `Client` per upstream; header/env injection; backoff reconnect (1s→60s) + `onRecovered`; retry-once on dropped transport AND on server-side session expiry (upstream 404 "unknown session" → transparent re-initialize + retry, per MCP spec) +- `upstream/connection.ts` — one pooled SDK `Client` per upstream; header/env injection; backoff reconnect (1s→60s) + `onRecovered`; retry-once on dropped transport AND on server-side session expiry (upstream 404 "unknown session" → transparent re-initialize + retry, per MCP spec). Optional spec `auth` block (`oauth2-client-credentials`): the gateway mints the upstream's bearer itself (secret via `${VAR}`/`bao:`/`kv:` ref), caches it, and rebuilds the connection when it nears expiry (60s skew) — for third-party servers that want a finished token, e.g. CIPP behind Easy Auth. Neither secret nor token is ever logged - `upstream/manager.ts` also pools **per-principal links** for `sessionMode:"per-user"` upstreams: spec clone with the caller's credential REFS layered over headers/env (still resolved via the secret store at connect — anti-passthrough intact); catalog discovery stays on the shared link; personal pool flushed on upstream upsert/remove. `requirePersonalCredentials` refuses the shared fallback - `upstream/manager.ts` — policy-free catalog owner; hot `upsertUpstream`/`removeUpstream`; `summaries()` for the UI - `mcp/gateway-server.ts` — low-level SDK `Server` per session, closes over the Principal; unknown and forbidden tools get the same error (no existence oracle) diff --git a/README.md b/README.md index 89b020a..eceb412 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,8 @@ Point Claude (Code, Desktop, or any MCP client) at a single URL; the gateway con - **Zero-config client connect (DCR)** — the gateway hosts its own OAuth authorization server facade (RFC 8414 metadata + RFC 7591 dynamic client registration + rotating refresh tokens), brokering user sign-in to your IdP; `claude mcp add ` connects with no pre-provisioned client id — URL only. Registered clients are managed (and revocable) from the admin UI - **Roles & policy** — viewer/editor/admin (plus custom roles) gate tools by annotation-derived tiers (read/write/destructive), with per-upstream grants and per-tool allow/deny overrides; enforcement is re-checked at call time - **Secrets stay server-side** — upstream API keys live in OpenBao (`bao:path#field` refs), Azure Key Vault (`kv:` refs), or env vars, injected at connect time; the inbound client token is never passed through to upstreams -- **Install from the UI** — one-click **presets** for the MSPStack family (IT Glue, ConnectWise PSA, Planner) that fill BYOK headers, per-user session mode, Connect wiring, and apply recommended role grants (extend with your own via `mspstack.presets.json`); or add any MCP server by URL, npm package (npx), or Docker image; search the official MCP registry; preflight-test before saving; crashed stdio servers restart with backoff +- **Minted upstream tokens** — for servers that expect a short-lived bearer instead of a static key (CIPP and friends behind Entra/Easy Auth), an upstream's `auth` block holds only a client id plus a secret reference: the gateway runs the client-credentials exchange itself, caches the token, and refreshes it before expiry +- **Install from the UI** — one-click **presets** for the MSPStack family (IT Glue, ConnectWise PSA, Planner) and CIPP that fill BYOK headers, per-user session mode, Connect wiring, and apply recommended role grants (extend with your own via `mspstack.presets.json`); or add any MCP server by URL, npm package (npx), or Docker image; search the official MCP registry; preflight-test before saving; crashed stdio servers restart with backoff - **Guided user setup** — upstreams declare their personal-credential fields, so `/me` renders labeled forms (not raw header names), plus ready-to-copy connect snippets: Claude Code CLI (user-scope by default) and JSON config for Desktop/Cursor/VS Code - **Admin UI** at `/admin` — status, server management, tool toggles, role matrix, users & group mappings (with live Entra group search when the login app holds the directory-read Graph roles), OAuth client management, secret writes diff --git a/package.json b/package.json index 4a6a804..625f821 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "mcp-gateway-monorepo", "private": true, - "version": "0.9.1", + "version": "0.10.0", "description": "MSPStack Gateway — self-hosted MCP manager: one endpoint federating many MCP servers with OAuth, roles, secret storage, and tool toggles", "type": "module", "workspaces": [ diff --git a/packages/gateway/package.json b/packages/gateway/package.json index f0de784..f67aded 100644 --- a/packages/gateway/package.json +++ b/packages/gateway/package.json @@ -1,6 +1,6 @@ { "name": "@mspstack/mcp-gateway", - "version": "0.9.1", + "version": "0.10.0", "description": "Self-hosted MCP gateway: one streamable-HTTP endpoint federating many MCP servers with namespaced tools, per-tool toggles, roles, and secure upstream credential injection", "type": "module", "main": "dist/index.js", diff --git a/packages/gateway/src/config.ts b/packages/gateway/src/config.ts index 2b4822f..d5471a8 100644 --- a/packages/gateway/src/config.ts +++ b/packages/gateway/src/config.ts @@ -62,6 +62,27 @@ const upstreamBase = { sessionMode: z.enum(["shared", "per-user"]).default("shared"), /** per-user only: refuse shared-credential fallback for callers without personal creds. */ requirePersonalCredentials: z.boolean().default(false), + /** + * Upstreams that want a freshly minted OAuth2 access token rather than a + * static credential (third-party MCP servers behind Entra/Easy Auth, e.g. + * CIPP). The gateway performs the client-credentials exchange itself at + * connect time, caches the token, and re-mints it when it nears expiry — + * the static parts (client id + a secret REF) are all that is stored. + */ + auth: z + .object({ + kind: z.literal("oauth2-client-credentials"), + /** Token endpoint, e.g. https://login.microsoftonline.com//oauth2/v2.0/token */ + tokenUrl: z.string().min(1), + clientId: z.string().min(1), + /** `${VAR}` / `bao:` / `kv:` ref — resolved at connect, never stored resolved. */ + clientSecret: z.string().min(1), + scope: z.string().min(1), + /** Header the token is injected into (default Authorization: Bearer …). */ + header: z.string().min(1).default("Authorization"), + prefix: z.string().default("Bearer "), + }) + .optional(), /** * Declared personal-credential fields for per-user upstreams — pure * metadata consumed by /me to render a labeled guided setup form instead diff --git a/packages/gateway/src/domain/presets.test.ts b/packages/gateway/src/domain/presets.test.ts index 12277a0..7fc6b67 100644 --- a/packages/gateway/src/domain/presets.test.ts +++ b/packages/gateway/src/domain/presets.test.ts @@ -10,10 +10,33 @@ const planner = BUILTIN_PRESETS.find((p) => p.id === "planner")!; describe("builtin presets", () => { it("cover the family and validate against their own schema", () => { - expect(BUILTIN_PRESETS.map((p) => p.id).sort()).toEqual(["cwpsa", "itglue", "planner"]); - for (const p of BUILTIN_PRESETS) { + expect(BUILTIN_PRESETS.map((p) => p.id).sort()).toEqual(["cipp", "cwpsa", "itglue", "planner"]); + for (const p of BUILTIN_PRESETS.filter((p) => p.id !== "cipp")) { expect(p.grants).toEqual({ viewer: "read", editor: "write" }); } + // CIPP is admin-only by default (its read-tier tools surface secrets). + expect(BUILTIN_PRESETS.find((p) => p.id === "cipp")!.grants).toEqual({ + viewer: "none", + editor: "none", + }); + }); + + it("cipp renders an oauth2 client-credentials auth block with a secret ref", () => { + const spec = renderPreset(BUILTIN_PRESETS.find((p) => p.id === "cipp")!, { + url: "https://cipp.example.net/api/ExecMcp", + tenantId: "tenant-1", + clientId: "client-1", + secretRef: "kv:cipp-mcp-secret", + }); + expect(spec.auth).toEqual({ + kind: "oauth2-client-credentials", + tokenUrl: "https://login.microsoftonline.com/tenant-1/oauth2/v2.0/token", + clientId: "client-1", + clientSecret: "kv:cipp-mcp-secret", // a REF, resolved at connect + scope: "api://client-1/.default", + header: "Authorization", + prefix: "Bearer ", + }); }); it("summaries omit the spec template", () => { diff --git a/packages/gateway/src/domain/presets.ts b/packages/gateway/src/domain/presets.ts index 685f21b..a56908a 100644 --- a/packages/gateway/src/domain/presets.ts +++ b/packages/gateway/src/domain/presets.ts @@ -121,6 +121,44 @@ export const BUILTIN_PRESETS: Preset[] = [ }, grants: { viewer: "read", editor: "write" }, }), + presetSchema.parse({ + id: "cipp", + title: "CIPP (CyberDrain Improved Partner Portal)", + description: + "M365 multi-tenant management. The gateway mints its own access token from the CIPP API client credentials, so nothing expires. Ships restrictive grants: admin only — CIPP exposes hundreds of read tools including LAPS passwords and BitLocker keys.", + params: [ + { + key: "url", + label: "MCP endpoint", + placeholder: "https://.azurewebsites.net/api/ExecMcp", + }, + { key: "tenantId", label: "Entra tenant id" }, + { key: "clientId", label: "CIPP API client id (MCP access enabled)" }, + { + key: "secretRef", + label: "Client secret REFERENCE (not the value)", + placeholder: "kv:cipp-mcp-secret — write the value on the Secrets tab first", + }, + ], + spec: { + id: "cipp", + namespace: "cipp", + transport: "http", + url: "{{url}}", + headers: {}, + auth: { + kind: "oauth2-client-credentials", + tokenUrl: "https://login.microsoftonline.com/{{tenantId}}/oauth2/v2.0/token", + clientId: "{{clientId}}", + clientSecret: "{{secretRef}}", + scope: "api://{{clientId}}/.default", + }, + }, + // Deliberately restrictive: CIPP's tools arrive annotated read-only even + // when they surface secrets, so viewer/editor get nothing until an admin + // widens it deliberately (admins keep their destructive default). + grants: { viewer: "none", editor: "none" }, + }), presetSchema.parse({ id: "planner", title: "Microsoft Planner (mcp-planner)", diff --git a/packages/gateway/src/upstream/connection.ts b/packages/gateway/src/upstream/connection.ts index 094197a..bd64b08 100644 --- a/packages/gateway/src/upstream/connection.ts +++ b/packages/gateway/src/upstream/connection.ts @@ -42,6 +42,8 @@ import { SERVER_NAME, SERVER_VERSION } from "../version.js"; const BACKOFF_INITIAL_MS = 1_000; const BACKOFF_MAX_MS = 60_000; +/** Re-mint an OAuth2 upstream token this long before it actually expires. */ +const TOKEN_SKEW_MS = 60_000; /** * The upstream forgot our streamable-HTTP session while the local transport @@ -69,6 +71,8 @@ export class UpstreamConnection { private closed = false; private backoffMs = BACKOFF_INITIAL_MS; private reconnectTimer: NodeJS.Timeout | null = null; + /** When the current connection's minted OAuth2 token goes stale (0 = n/a). */ + private tokenExpiresAt = 0; private status: UpstreamStatus = { connected: false, lastError: null, reconnectAttempts: 0 }; /** Fires when the upstream announces its tool list changed. */ @@ -94,6 +98,12 @@ export class UpstreamConnection { /** Connect (or join an in-flight connect). Serialized so reconnects never race. */ async connect(): Promise { + // A minted OAuth2 token is fixed for the transport's lifetime, so a stale + // one means the whole connection must be rebuilt with a fresh token. + if (this.client && this.tokenExpiresAt && Date.now() >= this.tokenExpiresAt) { + console.error(`[upstream:${this.spec.id}] access token expiring — reconnecting with a fresh one`); + await this.dropClient(); + } if (this.client) return; if (this.closed) throw new Error(`upstream "${this.spec.id}" is closed`); if (!this.connecting) { @@ -104,6 +114,63 @@ export class UpstreamConnection { return this.connecting; } + /** + * Client-credentials exchange for upstreams that want a minted token rather + * than a static credential. The secret itself comes from the store/env like + * any other injected value; only the resulting header is added, and neither + * the secret nor the token is ever logged. + */ + private async withMintedToken(headers: Record): Promise> { + const auth = this.spec.auth; + this.tokenExpiresAt = 0; + if (!auth) return headers; + + const clientSecret = await resolveInjectionValue( + auth.clientSecret, + this.env, + this.secretStore, + `upstream "${this.spec.id}".auth.clientSecret` + ); + const response = await fetch(auth.tokenUrl, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "client_credentials", + client_id: auth.clientId, + client_secret: clientSecret, + scope: auth.scope, + }), + signal: AbortSignal.timeout(30_000), + }); + if (!response.ok) { + let detail = ""; + try { + const body = (await response.json()) as { error?: string; error_description?: string }; + // error_description can be long but carries no secret — trim it. + detail = ` ${body.error ?? ""}${body.error_description ? `: ${body.error_description.slice(0, 200)}` : ""}`; + } catch { + // non-JSON error body + } + throw new Error(`token request failed (${response.status})${detail}`); + } + const token = (await response.json()) as { access_token?: string; expires_in?: number }; + if (!token.access_token) throw new Error("token response carried no access_token"); + this.tokenExpiresAt = Date.now() + (token.expires_in ?? 3600) * 1000 - TOKEN_SKEW_MS; + console.error( + `[upstream:${this.spec.id}] minted access token via client credentials (expires in ${token.expires_in ?? 3600}s)` + ); + return { ...headers, [auth.header]: `${auth.prefix}${token.access_token}` }; + } + + /** Tear down the live client without triggering the reconnect supervisor. */ + private async dropClient(): Promise { + const client = this.client; + this.client = null; + if (!client) return; + client.onclose = undefined; + await client.close().catch(() => undefined); + } + private async doConnect(): Promise { const context = `upstream "${this.spec.id}"`; try { @@ -117,11 +184,13 @@ export class UpstreamConnection { this.secretStore, `${context}.url` ); - const headers = await resolveInjectionRecord( - this.spec.headers, - this.env, - this.secretStore, - `${context}.headers` + const headers = await this.withMintedToken( + await resolveInjectionRecord( + this.spec.headers, + this.env, + this.secretStore, + `${context}.headers` + ) ); transport = new StreamableHTTPClientTransport(new URL(url), { requestInit: { headers }, diff --git a/packages/gateway/src/upstream/oauth2-auth.test.ts b/packages/gateway/src/upstream/oauth2-auth.test.ts new file mode 100644 index 0000000..f9fed32 --- /dev/null +++ b/packages/gateway/src/upstream/oauth2-auth.test.ts @@ -0,0 +1,181 @@ +/** + * UpstreamConnection with an `auth` block: the gateway mints its own OAuth2 + * access token (client credentials) for upstreams that expect a finished + * bearer rather than a static credential — third-party MCP servers behind + * Entra/Easy Auth, e.g. CIPP. Covers the mint, the secret-store lookup for + * the client secret, and the re-mint when the token nears expiry. + */ + +import { createServer, type IncomingMessage, type Server as HttpServer, type ServerResponse } from "node:http"; +import { randomUUID } from "node:crypto"; +import type { AddressInfo } from "node:net"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; +import { UpstreamConnection } from "./connection.js"; +import { MemorySecretStore } from "../secrets/memory.js"; +import type { UpstreamSpec } from "../config.js"; + +/** + * One HTTP server playing both roles: the Entra-ish token endpoint + * (POST /token) and a bearer-protected MCP upstream (POST /mcp). + */ +class FakeTokenAndUpstream { + private readonly sessions = new Map(); + private http!: HttpServer; + /** Tokens handed out, newest last. */ + readonly issued: string[] = []; + /** Bodies posted to the token endpoint (to assert the grant shape). */ + readonly tokenRequests: URLSearchParams[] = []; + /** expires_in for the NEXT token — lets a test make one go stale instantly. */ + nextExpiresIn = 3600; + rejectedRequests = 0; + + async start(): Promise { + this.http = createServer((req, res) => { + this.handle(req, res).catch((err) => res.writeHead(500).end(String(err))); + }); + await new Promise((resolve) => this.http.listen(0, resolve)); + return `http://localhost:${(this.http.address() as AddressInfo).port}`; + } + + async stop(): Promise { + await new Promise((resolve) => this.http.close(() => resolve())); + } + + private async handle(req: IncomingMessage, res: ServerResponse): Promise { + const chunks: Buffer[] = []; + for await (const chunk of req) chunks.push(chunk as Buffer); + const raw = Buffer.concat(chunks).toString("utf8"); + + if (req.url?.startsWith("/token")) { + this.tokenRequests.push(new URLSearchParams(raw)); + const token = `at-${this.issued.length + 1}`; + this.issued.push(token); + res.writeHead(200, { "Content-Type": "application/json" }).end( + JSON.stringify({ access_token: token, expires_in: this.nextExpiresIn, token_type: "Bearer" }) + ); + return; + } + + // MCP endpoint: only the CURRENT token is accepted (expired ones 401). + const auth = req.headers.authorization; + if (auth !== `Bearer ${this.issued.at(-1)}`) { + this.rejectedRequests += 1; + res.writeHead(401).end("unauthorized"); + return; + } + + const body: unknown = raw ? JSON.parse(raw) : undefined; + const sessionId = req.headers["mcp-session-id"] as string | undefined; + const existing = sessionId ? this.sessions.get(sessionId) : undefined; + if (existing) { + await existing.handleRequest(req, res, body); + return; + } + if (req.method === "POST" && isInitializeRequest(body)) { + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (id) => this.sessions.set(id, transport), + }); + const mcp = new McpServer({ name: "fake-cipp", version: "0.0.0" }); + mcp.tool("ping", async () => ({ content: [{ type: "text" as const, text: "pong" }] })); + await mcp.connect(transport); + await transport.handleRequest(req, res, body); + return; + } + res.writeHead(404, { "Content-Type": "application/json" }).end( + JSON.stringify({ error: "Unknown or expired MCP session. Re-initialize." }) + ); + } +} + +const spec = (base: string): UpstreamSpec => + ({ + id: "cipp", + namespace: "cipp", + transport: "http", + url: `${base}/mcp`, + headers: { "x-static": "kept" }, + enabled: true, + auth: { + kind: "oauth2-client-credentials", + tokenUrl: `${base}/token`, + clientId: "client-1", + clientSecret: "bao:cipp#secret", // resolved through the secret store + scope: "api://client-1/.default", + header: "Authorization", + prefix: "Bearer ", + }, + }) as UpstreamSpec; + +describe("UpstreamConnection — minted OAuth2 upstream tokens", () => { + const upstream = new FakeTokenAndUpstream(); + let base: string; + let store: MemorySecretStore; + + beforeAll(async () => { + base = await upstream.start(); + store = new MemorySecretStore(); + await store.put("cipp", "secret", "s3cr3t"); + }); + + afterAll(async () => { + await upstream.stop(); + }); + + it("mints a token from the client-credentials grant and calls with it", async () => { + const connection = new UpstreamConnection(spec(base), store); + try { + await connection.connect(); + expect(await connection.listTools()).toHaveLength(1); + expect((await connection.callTool("ping", {})).content).toEqual([{ type: "text", text: "pong" }]); + + const body = upstream.tokenRequests.at(-1)!; + expect(body.get("grant_type")).toBe("client_credentials"); + expect(body.get("client_id")).toBe("client-1"); + expect(body.get("scope")).toBe("api://client-1/.default"); + // the secret came from the store, not from the spec literal + expect(body.get("client_secret")).toBe("s3cr3t"); + expect(upstream.rejectedRequests).toBe(0); + } finally { + await connection.close(); + } + }); + + it("re-mints when the token is near expiry, so calls never 401", async () => { + upstream.nextExpiresIn = 30; // < the 60s skew ⇒ stale immediately + const connection = new UpstreamConnection(spec(base), store); + try { + await connection.connect(); + const firstToken = upstream.issued.at(-1); + const mintsAfterConnect = upstream.tokenRequests.length; + + // Next call notices the stale token, rebuilds the connection with a + // fresh one, and still succeeds (the fake rejects superseded tokens). + await connection.connect(); + expect(upstream.tokenRequests.length).toBe(mintsAfterConnect + 1); + expect(upstream.issued.at(-1)).not.toBe(firstToken); + expect((await connection.callTool("ping", {})).content).toEqual([{ type: "text", text: "pong" }]); + } finally { + upstream.nextExpiresIn = 3600; + await connection.close(); + } + }); + + it("surfaces token-endpoint failures as connect errors (no silent unauthenticated connect)", async () => { + const broken = { + ...spec(base), + auth: { ...(spec(base) as { auth: Record }).auth, tokenUrl: `${base}/nowhere` }, + } as UpstreamSpec; + const connection = new UpstreamConnection(broken, store); + try { + // /nowhere is not the token route → 401 from the MCP branch → mint fails. + await expect(connection.connect()).rejects.toThrow(/token request failed/); + expect(connection.connected).toBe(false); + } finally { + await connection.close(); + } + }); +});