diff --git a/docs/api-reference/custom-provider.mdx b/docs/api-reference/custom-provider.mdx index 7ec04075c..657ad99dd 100644 --- a/docs/api-reference/custom-provider.mdx +++ b/docs/api-reference/custom-provider.mdx @@ -68,6 +68,7 @@ type OAuthConfig = { baseUrl?: string; oauthMetadata: OAuthMetadata; verify: { issuer: string; audience?: string; jwksUri?: string }; + createVerifier?: (config: OAuthConfig) => OAuthTokenVerifier; scopesSupported?: string[]; requiredScopes?: string[]; }; @@ -78,11 +79,14 @@ type OAuthConfig = { | `baseUrl` | Echoes the `baseUrl` option. | | `oauthMetadata` | AS metadata served at `/.well-known/oauth-authorization-server`. | | `verify` | JWKS token-verification config. | +| `createVerifier` | Creates the token verifier; defaults to the built-in JWKS verifier configured by `verify`. | | `scopesSupported` | Scopes advertised in protected-resource metadata. | | `requiredScopes` | Server-wide required-scope floor. | Build this object by hand only to wire an IdP whose metadata `customProvider` can't discover: supply `verify.issuer` and `verify.jwksUri` yourself and the [`oauth`](/api-reference/mcp-server#constructor) option mounts the same endpoints. +Set `createVerifier` to supply your own [verifier](/api-reference/verifier) — an object with a `verifyAccessToken(token)` method, the same contract [`requireBearerAuth`](/api-reference/require-bearer-auth) accepts — for verification the JWKS path can't express, such as introspection of opaque tokens or an identity provider's own SDK. When omitted, Skybridge's built-in JWKS verifier, configured by `verify`, is used. + Set up sign-in with a hosted provider diff --git a/packages/core/src/server/auth/index.ts b/packages/core/src/server/auth/index.ts index e4ac2fae3..8fdfc1915 100644 --- a/packages/core/src/server/auth/index.ts +++ b/packages/core/src/server/auth/index.ts @@ -1,3 +1,4 @@ +import type { OAuthTokenVerifier } from "@modelcontextprotocol/sdk/server/auth/provider.js"; import type { OAuthMetadata } from "@modelcontextprotocol/sdk/shared/auth.js"; import type { JwksVerifyConfig } from "./verify.js"; @@ -12,6 +13,11 @@ export type OAuthConfig = { /** AS metadata served at `/.well-known/oauth-authorization-server`. */ oauthMetadata: OAuthMetadata; verify: JwksVerifyConfig; + /** + * Creates the token verifier for this config. When omitted, Skybridge's + * built-in JWKS verifier, configured by `verify`, is used. + */ + createVerifier?: (config: OAuthConfig) => OAuthTokenVerifier; /** Scopes advertised in protected-resource metadata. */ scopesSupported?: string[]; /** Server-wide required-scope floor. */ diff --git a/packages/core/src/server/auth/setup.test.ts b/packages/core/src/server/auth/setup.test.ts index 0c072654b..377499b2b 100644 --- a/packages/core/src/server/auth/setup.test.ts +++ b/packages/core/src/server/auth/setup.test.ts @@ -5,7 +5,9 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/ import type { RequestHandler } from "express"; import * as jose from "jose"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { InvalidTokenError } from "../auth.js"; import { McpServer } from "../server.js"; +import type { OAuthConfig } from "./index.js"; vi.mock("@skybridge/devtools", () => ({ devtoolsStaticServer: () => @@ -61,7 +63,13 @@ function signToken(key: CryptoKey, scope = "openid email") { async function bootServer( jwksUri: string, - { baseUrl = "https://app.example.test" }: { baseUrl?: string | null } = {}, + { + baseUrl = "https://app.example.test", + createVerifier, + }: { + baseUrl?: string | null; + createVerifier?: OAuthConfig["createVerifier"]; + } = {}, ) { const { createApp } = await import("../express.js"); const server = new McpServer( @@ -70,6 +78,7 @@ async function bootServer( { oauth: { ...(baseUrl === null ? {} : { baseUrl }), + ...(createVerifier === undefined ? {} : { createVerifier }), oauthMetadata: { issuer: ISSUER, authorization_endpoint: `${ISSUER}/authorize`, @@ -156,6 +165,90 @@ describe("setupOAuth wiring", () => { }); }); +describe("custom createVerifier in the oauth config", () => { + const customVerifier = { + verifyAccessToken: async (token: string) => { + if (token !== "good-opaque-token") { + throw new InvalidTokenError("Token rejected by custom verifier"); + } + return { + token, + clientId: "custom-client", + scopes: ["openid"], + expiresAt: Math.floor(Date.now() / 1000) + 3600, + }; + }, + }; + const createVerifier: OAuthConfig["createVerifier"] = () => customVerifier; + + it("threads authInfo from the custom verifier into the tool handler", async () => { + const { jwksUri } = await startJwks(); + const base = await bootServer(jwksUri, { createVerifier }); + + const client = new Client({ name: "test-client", version: "0.0.0" }); + const transport = new StreamableHTTPClientTransport( + new URL(`${base}/mcp`), + { + requestInit: { + headers: { Authorization: "Bearer good-opaque-token" }, + }, + }, + ); + await client.connect(transport); + + const result = (await client.callTool({ + name: "whoami", + arguments: {}, + })) as unknown as { content: { type: string; text: string }[] }; + expect(result.content[0]?.text).toBe("custom-client"); + + await client.close(); + }); + + it("answers 401 + WWW-Authenticate when the custom verifier rejects", async () => { + const { jwksUri } = await startJwks(); + const base = await bootServer(jwksUri, { createVerifier }); + + const res = await fetch(`${base}/mcp`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer forged-token", + }, + body: JSON.stringify({ jsonrpc: "2.0", method: "initialize", id: 1 }), + }); + expect(res.status).toBe(401); + const header = res.headers.get("www-authenticate"); + expect(header).toMatch(/error="invalid_token"/); + expect(header).toMatch(/resource_metadata=/); + }); + + it("still serves protected-resource metadata with a custom verifier", async () => { + const { jwksUri } = await startJwks(); + const base = await bootServer(jwksUri, { createVerifier }); + + const res = await fetch(`${base}/.well-known/oauth-protected-resource`); + expect(res.status).toBe(200); + const body = (await res.json()) as { authorization_servers: string[] }; + expect(body.authorization_servers).toContain(ISSUER); + }); + + it("calls createVerifier once at boot with the resolved config", async () => { + const { jwksUri } = await startJwks(); + const seen: OAuthConfig[] = []; + await bootServer(jwksUri, { + createVerifier: (config) => { + seen.push(config); + return customVerifier; + }, + }); + + expect(seen).toHaveLength(1); + expect(seen[0]?.verify.issuer).toBe(ISSUER); + expect(seen[0]?.oauthMetadata.issuer).toBe(ISSUER); + }); +}); + describe("baseUrl inferred from headers", () => { it("derives the resource origin from x-forwarded-host", async () => { const { jwksUri } = await startJwks(); @@ -601,6 +694,37 @@ describe("oauth config validation", () => { response_types_supported: ["code"], }; + it("throws when verify has no issuer", () => { + expect( + () => + new McpServer({ name: "t", version: "0" }, undefined, { + oauth: { + baseUrl: "https://app.example.test", + oauthMetadata: validMetadata, + verify: {} as OAuthConfig["verify"], + }, + }), + ).toThrow(/issuer/); + }); + + it("still requires verify.issuer when createVerifier is supplied", () => { + expect( + () => + new McpServer({ name: "t", version: "0" }, undefined, { + oauth: { + baseUrl: "https://app.example.test", + oauthMetadata: validMetadata, + verify: {} as OAuthConfig["verify"], + createVerifier: () => ({ + verifyAccessToken: async () => { + throw new Error("unreachable"); + }, + }), + }, + }), + ).toThrow(/issuer/); + }); + it("throws on a non-absolute baseUrl", () => { expect( () => diff --git a/packages/core/src/server/auth/setup.ts b/packages/core/src/server/auth/setup.ts index c4950cc30..a918cb961 100644 --- a/packages/core/src/server/auth/setup.ts +++ b/packages/core/src/server/auth/setup.ts @@ -37,7 +37,9 @@ export function setupOAuth( const acceptsAnonymous = () => [...schemesByTool.values()].some(securitySchemesAllowAnonymous); - const verifier = createJwksVerifier(config.verify); + const verifier = ( + config.createVerifier ?? ((c: OAuthConfig) => createJwksVerifier(c.verify)) + )(config); const bearer = (options: BearerAuthMiddlewareOptions): RequestHandler => { const required = requireBearerAuth(options); const optional = optionalBearerAuth(options);