diff --git a/packages/junior-plugin-api/src/manifest.ts b/packages/junior-plugin-api/src/manifest.ts index 43b7b4c59a..3d351b2733 100644 --- a/packages/junior-plugin-api/src/manifest.ts +++ b/packages/junior-plugin-api/src/manifest.ts @@ -52,9 +52,20 @@ export interface PluginRuntimePostinstallCommand { sudo?: boolean; } +export interface PluginMcpAuthConfig { + /** Issuer string the MCP server trusts for this bot. */ + issuer: string; + /** JWKS key id of the published public key that pairs with the private key. */ + keyId: string; + /** Env var holding the PEM (PKCS#8) private signing key. */ + privateKeyEnv: string; +} + export interface PluginMcpConfig { /** Provider tools exposed directly to the model. */ allowedTools?: string[]; + /** Bot auth: sign short-lived JWT assertions instead of per-actor OAuth. */ + auth?: PluginMcpAuthConfig; headers?: Record; transport: "http"; url: string; diff --git a/packages/junior/src/chat/mcp/jwt-bearer-provider.ts b/packages/junior/src/chat/mcp/jwt-bearer-provider.ts new file mode 100644 index 0000000000..d7225b2fea --- /dev/null +++ b/packages/junior/src/chat/mcp/jwt-bearer-provider.ts @@ -0,0 +1,97 @@ +/** + * Non-interactive MCP auth for bot plugins. + * + * When a manifest declares `mcp.auth`, this provider signs a short-lived RFC 7523 jwt-bearer + * assertion with a plugin-held private key and the SDK exchanges it at the server token endpoint. + * No user, no browser redirect; expired access tokens re-mint automatically on the next 401. + * The absent redirectUrl is what routes the SDK into its non-interactive token flow, the + * `oauth-id-jag+jwt` typ header is what identity-assertion servers require, and the placeholder + * redirect_uri exists only because dynamic client registration rejects an empty list. + */ +import { randomUUID } from "node:crypto"; +import type { + OAuthClientProvider, + OAuthDiscoveryState, +} from "@modelcontextprotocol/sdk/client/auth.js"; +import type { + OAuthClientInformationMixed, + OAuthTokens, +} from "@modelcontextprotocol/sdk/shared/auth.js"; +import { importPKCS8, SignJWT } from "jose"; +import type { PluginMcpConfig } from "@sentry/junior-plugin-api"; + +const JWT_BEARER_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:jwt-bearer"; +const ALGORITHM = "RS256"; + +function nonInteractive(): never { + throw new Error("jwt-bearer MCP auth is non-interactive"); +} + +/** Build the OAuth client provider that signs jwt-bearer assertions for one bot MCP plugin. */ +export function createJwtBearerMcpClientProvider( + subject: string, + mcpUrl: string, + auth: NonNullable, +): OAuthClientProvider { + let discovery: OAuthDiscoveryState | undefined; + let clientInfo: OAuthClientInformationMixed | undefined; + let tokens: OAuthTokens | undefined; + + return { + redirectUrl: undefined, + clientMetadata: { + client_name: subject, + redirect_uris: ["http://localhost"], + token_endpoint_auth_method: "none", + }, + discoveryState: () => discovery, + saveDiscoveryState: (state) => { + discovery = state; + }, + clientInformation: () => clientInfo, + saveClientInformation: (info) => { + clientInfo = info; + }, + tokens: () => tokens, + saveTokens: (next) => { + tokens = next; + }, + redirectToAuthorization: nonInteractive, + saveCodeVerifier: nonInteractive, + codeVerifier: nonInteractive, + prepareTokenRequest: async () => { + if (!discovery) { + throw new Error("jwt-bearer token request requires discovered server metadata"); + } + const privateKeyPem = process.env[auth.privateKeyEnv]; + if (!privateKeyPem) { + throw new Error( + `jwt-bearer MCP auth env var ${auth.privateKeyEnv} is unset`, + ); + } + const assertion = await new SignJWT({ + client_id: clientInfo?.client_id, + resource: mcpUrl, + }) + .setProtectedHeader({ + alg: ALGORITHM, + kid: auth.keyId, + typ: "oauth-id-jag+jwt", + }) + .setIssuer(auth.issuer) + .setSubject(subject) + .setAudience( + discovery.authorizationServerMetadata?.issuer ?? + discovery.authorizationServerUrl, + ) + .setIssuedAt() + .setExpirationTime("5m") + .setJti(randomUUID()) + .sign(await importPKCS8(privateKeyPem, ALGORITHM)); + return new URLSearchParams({ + grant_type: JWT_BEARER_GRANT_TYPE, + assertion, + }); + }, + }; +} diff --git a/packages/junior/src/chat/plugins/inline-manifest-source.ts b/packages/junior/src/chat/plugins/inline-manifest-source.ts index 8ce502c8c2..32a60e1bd1 100644 --- a/packages/junior/src/chat/plugins/inline-manifest-source.ts +++ b/packages/junior/src/chat/plugins/inline-manifest-source.ts @@ -1,4 +1,4 @@ -import type { PluginManifest } from "./types"; +import type { PluginManifest, PluginMcpConfig } from "./types"; type ManifestSource = Record; @@ -54,6 +54,17 @@ function inlineCredentialsSource( return result; } +/** Convert a camelCase `mcp.auth` block to its plugin.yaml source keys. */ +export function mcpAuthSource( + auth: NonNullable, +): ManifestSource { + return { + issuer: auth.issuer, + "key-id": auth.keyId, + "private-key-env": auth.privateKeyEnv, + }; +} + function inlineMcpSource(mcp: PluginManifest["mcp"]): unknown { if (mcp === undefined || !isRecord(mcp)) { return mcp; @@ -63,6 +74,7 @@ function inlineMcpSource(mcp: PluginManifest["mcp"]): unknown { setDefined(result, "transport", mcp.transport); setDefined(result, "url", mcp.url); setDefined(result, "headers", mcp.headers); + setDefined(result, "auth", mcp.auth && mcpAuthSource(mcp.auth)); setDefined(result, "allowed-tools", mcp.allowedTools); setDefined(result, "wrapped-tools", mcp.wrappedTools); return result; diff --git a/packages/junior/src/chat/plugins/manifest.ts b/packages/junior/src/chat/plugins/manifest.ts index e70003fd91..d8a348a981 100644 --- a/packages/junior/src/chat/plugins/manifest.ts +++ b/packages/junior/src/chat/plugins/manifest.ts @@ -15,7 +15,10 @@ import type { PluginSystemRuntimeDependency, PluginSystemRuntimeDependencyFromUrl, } from "./types"; -import { inlineManifestSource } from "./inline-manifest-source"; +import { + inlineManifestSource, + mcpAuthSource, +} from "./inline-manifest-source"; const PLUGIN_NAME_RE = /^[a-z][a-z0-9-]*$/; const SHORT_CONFIG_KEY_RE = /^[a-z0-9]+(\.[a-z0-9-]+)*$/; @@ -227,6 +230,14 @@ const oauthSourceSchema = z }) .passthrough(); +const mcpAuthSourceSchema = z + .object({ + issuer: nonEmptyTrimmedString, + "key-id": nonEmptyTrimmedString, + "private-key-env": envVarString, + }) + .passthrough(); + const mcpSourceSchema = z .object({ transport: nonEmptyTrimmedString @@ -235,6 +246,7 @@ const mcpSourceSchema = z }) .optional(), url: httpsUrlString, + auth: mcpAuthSourceSchema.optional(), headers: stringMapSchema.optional(), "allowed-tools": nonEmptyStringArraySchema("allowed-tools").optional(), "wrapped-tools": nonEmptyStringArraySchema("wrapped-tools").optional(), @@ -360,6 +372,7 @@ function manifestConfigPatch( setDefined(mcp, "transport", config.mcp.transport); setDefined(mcp, "url", config.mcp.url); setDefined(mcp, "headers", config.mcp.headers); + setDefined(mcp, "auth", config.mcp.auth && mcpAuthSource(config.mcp.auth)); setDefined(mcp, "allowed-tools", config.mcp.allowedTools); setDefined(mcp, "wrapped-tools", config.mcp.wrappedTools); result.mcp = mcp; @@ -588,6 +601,7 @@ function assertCommandEnvDoesNotExposeHostSecretRefs( apiHeaders: Record | undefined, credentials: PluginCredentials | undefined, oauth: PluginOAuthConfig | undefined, + mcp: PluginMcpConfig | undefined, pluginName: string, ): void { if (!commandEnv) { @@ -595,6 +609,9 @@ function assertCommandEnvDoesNotExposeHostSecretRefs( } const hostOnlyRefs = new Set(); + if (mcp?.auth) { + hostOnlyRefs.add(mcp.auth.privateKeyEnv); + } for (const value of Object.values(apiHeaders ?? {})) { for (const name of envReferences(value)) { hostOnlyRefs.add(name); @@ -945,9 +962,18 @@ function normalizeMcp( }) : undefined; + const auth = result.data.auth + ? { + issuer: result.data.auth.issuer, + keyId: result.data.auth["key-id"], + privateKeyEnv: result.data.auth["private-key-env"], + } + : undefined; + return { transport: "http", url: result.data.url, + ...(auth ? { auth } : undefined), ...(headers ? { headers } : undefined), ...(result.data["allowed-tools"] ? { allowedTools: result.data["allowed-tools"] } @@ -1150,6 +1176,7 @@ function parseManifestSource( apiHeaders, credentials, manifest.oauth, + mcp, data.name, ); assertCommandEnvHostRefsAreExplicitlyExposed( diff --git a/packages/junior/src/chat/plugins/types.ts b/packages/junior/src/chat/plugins/types.ts index 515034b33b..cf6923ad0d 100644 --- a/packages/junior/src/chat/plugins/types.ts +++ b/packages/junior/src/chat/plugins/types.ts @@ -124,6 +124,7 @@ export interface PluginManifestConfig { transport?: "http"; url?: string; headers?: Record | null; + auth?: PluginMcpConfig["auth"]; allowedTools?: string[] | null; wrappedTools?: string[] | null; } | null; diff --git a/packages/junior/src/chat/services/mcp-auth-orchestration.ts b/packages/junior/src/chat/services/mcp-auth-orchestration.ts index 0da9055097..3df5e19aa7 100644 --- a/packages/junior/src/chat/services/mcp-auth-orchestration.ts +++ b/packages/junior/src/chat/services/mcp-auth-orchestration.ts @@ -8,6 +8,7 @@ */ import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; import type { Destination, Source } from "@sentry/junior-plugin-api"; +import { createJwtBearerMcpClientProvider } from "@/chat/mcp/jwt-bearer-provider"; import { createMcpOAuthClientProvider } from "@/chat/mcp/oauth"; import { deleteMcpAuthSession, @@ -84,6 +85,14 @@ export function createMcpAuthOrchestration( const authProviderFactory = async ( plugin: PluginDefinition, ): Promise => { + const mcp = plugin.manifest.mcp; + if (mcp?.auth) { + return createJwtBearerMcpClientProvider( + plugin.manifest.name, + mcp.url, + mcp.auth, + ); + } if (!input.conversationId || !input.sessionId || !input.actorId) { return undefined; } diff --git a/packages/junior/tests/unit/mcp/jwt-bearer-provider.test.ts b/packages/junior/tests/unit/mcp/jwt-bearer-provider.test.ts new file mode 100644 index 0000000000..4beb193162 --- /dev/null +++ b/packages/junior/tests/unit/mcp/jwt-bearer-provider.test.ts @@ -0,0 +1,60 @@ +import { generateKeyPairSync } from "node:crypto"; +import { decodeProtectedHeader, jwtVerify } from "jose"; +import { afterEach, describe, expect, it } from "vitest"; +import { createJwtBearerMcpClientProvider } from "@/chat/mcp/jwt-bearer-provider"; + +describe("createJwtBearerMcpClientProvider", () => { + afterEach(() => { + delete process.env.TEST_MCP_PRIVATE_KEY; + }); + + it("prepares a jwt-bearer grant with a verifiable signed assertion", async () => { + const { privateKey, publicKey } = generateKeyPairSync("rsa", { + modulusLength: 2048, + }); + process.env.TEST_MCP_PRIVATE_KEY = privateKey + .export({ type: "pkcs8", format: "pem" }) + .toString(); + const provider = createJwtBearerMcpClientProvider( + "gocd-mcp", + "https://mcp.example.test/mcp", + { + issuer: "https://junior.example.test", + keyId: "junior-1", + privateKeyEnv: "TEST_MCP_PRIVATE_KEY", + }, + ); + await provider.saveDiscoveryState?.({ + authorizationServerUrl: "https://mcp.example.test", + authorizationServerMetadata: { + issuer: "https://mcp.example.test/", + authorization_endpoint: "https://mcp.example.test/authorize", + token_endpoint: "https://mcp.example.test/token", + response_types_supported: ["code"], + }, + }); + await provider.saveClientInformation?.({ client_id: "client-123" }); + + const params = await provider.prepareTokenRequest?.(); + + expect(params?.get("grant_type")).toBe( + "urn:ietf:params:oauth:grant-type:jwt-bearer", + ); + const assertion = params?.get("assertion") ?? ""; + expect(decodeProtectedHeader(assertion)).toMatchObject({ + alg: "RS256", + kid: "junior-1", + typ: "oauth-id-jag+jwt", + }); + const { payload } = await jwtVerify(assertion, publicKey, { + issuer: "https://junior.example.test", + audience: "https://mcp.example.test/", + }); + expect(payload).toMatchObject({ + sub: "gocd-mcp", + client_id: "client-123", + resource: "https://mcp.example.test/mcp", + }); + expect(payload.jti).toBeTruthy(); + }); +}); diff --git a/packages/junior/tests/unit/plugins/plugin-inline-manifest.test.ts b/packages/junior/tests/unit/plugins/plugin-inline-manifest.test.ts index 0dd5af7bc3..4f80414b3f 100644 --- a/packages/junior/tests/unit/plugins/plugin-inline-manifest.test.ts +++ b/packages/junior/tests/unit/plugins/plugin-inline-manifest.test.ts @@ -79,7 +79,12 @@ describe("inline plugin manifests", () => { }); }); - it("preserves wrapped MCP tool declarations", () => { + it("preserves wrapped MCP tool and bot auth declarations", () => { + const auth = { + issuer: "https://junior.example.test", + keyId: "junior-1", + privateKeyEnv: "LINEAR_MCP_PRIVATE_KEY", + }; const manifest = parse({ name: "linear", displayName: "Linear", @@ -87,10 +92,12 @@ describe("inline plugin manifests", () => { mcp: { transport: "http", url: "https://mcp.linear.app/mcp", + auth, wrappedTools: ["create_issue"], }, }); expect(manifest.mcp?.wrappedTools).toEqual(["create_issue"]); + expect(manifest.mcp?.auth).toEqual(auth); }); });