diff --git a/docs/api-reference/custom-provider.mdx b/docs/api-reference/custom-provider.mdx index 7ec04075c..674d53ea5 100644 --- a/docs/api-reference/custom-provider.mdx +++ b/docs/api-reference/custom-provider.mdx @@ -67,7 +67,9 @@ A `Promise` (discovery is a boot-time network call) for the `OAuthConfig` you pa type OAuthConfig = { baseUrl?: string; oauthMetadata: OAuthMetadata; - verify: { issuer: string; audience?: string; jwksUri?: string }; + verify: + | { issuer: string; audience?: string; jwksUri?: string } + | OAuthTokenVerifier; scopesSupported?: string[]; requiredScopes?: string[]; }; @@ -77,12 +79,14 @@ type OAuthConfig = { | --- | --- | | `baseUrl` | Echoes the `baseUrl` option. | | `oauthMetadata` | AS metadata served at `/.well-known/oauth-authorization-server`. | -| `verify` | JWKS token-verification config. | +| `verify` | JWKS token-verification config, or a [verifier](/api-reference/verifier) of your own. | | `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. +`verify` also accepts a caller-supplied [verifier](/api-reference/verifier) — an object with a `verifyAccessToken(token)` method — instead of the JWKS config. Use it when verification is more than a JWKS check: opaque tokens verified by introspection, revocation lookups, custom claim mapping, or an identity provider's own SDK. The verifier resolves with an [`AuthInfo`](/api-reference/verifier#authinfo) or throws `InvalidTokenError` / `InsufficientScopeError`, exactly as with [`requireBearerAuth`](/api-reference/require-bearer-auth); the metadata endpoints, per-tool schemes, and challenges keep working unchanged. + 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..a106aa027 100644 --- a/packages/core/src/server/auth/index.ts +++ b/packages/core/src/server/auth/index.ts @@ -1,6 +1,14 @@ +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"; +/** + * An {@link OAuthConfig} whose `verify` is JWKS-config-shaped. This is what + * the bundled providers return: the framework performs the JWT verification, + * and callers can still read and tweak `verify.issuer` / `verify.audience`. + */ +export type JwksOAuthConfig = OAuthConfig & { verify: JwksVerifyConfig }; + /** Resource-server OAuth config for `SkybridgeServerOptions.oauth`. */ export type OAuthConfig = { /** @@ -11,7 +19,16 @@ export type OAuthConfig = { baseUrl?: string; /** AS metadata served at `/.well-known/oauth-authorization-server`. */ oauthMetadata: OAuthMetadata; - verify: JwksVerifyConfig; + /** + * How `/mcp` bearer tokens are verified: either a `JwksVerifyConfig` + * describing a JWKS endpoint (JWT verification handled by the framework), + * or a caller-supplied `OAuthTokenVerifier` for anything the JWKS path + * cannot express — token introspection, revocation checks, custom claim + * mapping, or an identity provider's own SDK. A custom verifier resolves + * with `AuthInfo` or throws `InvalidTokenError`/`InsufficientScopeError` + * from `@modelcontextprotocol/sdk`, exactly as with `requireBearerAuth`. + */ + verify: JwksVerifyConfig | OAuthTokenVerifier; /** Scopes advertised in protected-resource metadata. */ scopesSupported?: string[]; /** Server-wide required-scope floor. */ diff --git a/packages/core/src/server/auth/providers/auth0.ts b/packages/core/src/server/auth/providers/auth0.ts index 04e48945b..5e0a5b76c 100644 --- a/packages/core/src/server/auth/providers/auth0.ts +++ b/packages/core/src/server/auth/providers/auth0.ts @@ -1,4 +1,4 @@ -import type { OAuthConfig } from "../index.js"; +import type { JwksOAuthConfig } from "../index.js"; import { type CustomProviderOptions, customProvider } from "./custom.js"; import { toIssuerUrl } from "./shared.js"; @@ -18,7 +18,7 @@ export async function auth0Provider( CustomProviderOptions, "issuer" | "audience" | "baseUrl" | "serverUrl" >, -): Promise { +): Promise { const { domain, audience, ...rest } = opts; const config = await customProvider({ issuer: toIssuerUrl(domain), diff --git a/packages/core/src/server/auth/providers/authplane.ts b/packages/core/src/server/auth/providers/authplane.ts index 1c45fcbda..17b228123 100644 --- a/packages/core/src/server/auth/providers/authplane.ts +++ b/packages/core/src/server/auth/providers/authplane.ts @@ -1,4 +1,4 @@ -import type { OAuthConfig } from "../index.js"; +import type { JwksOAuthConfig } from "../index.js"; import { type CustomProviderOptions, customProvider } from "./custom.js"; /** Options accepted by {@link authplaneProvider}. */ @@ -80,7 +80,7 @@ function parseIdentifier(value: string, option: string): URL { */ export function authplaneProvider( opts: AuthplaneProviderOptions, -): Promise { +): Promise { const { issuer, resource, audience, ...rest } = opts; parseIdentifier(issuer, "issuer"); diff --git a/packages/core/src/server/auth/providers/clerk.ts b/packages/core/src/server/auth/providers/clerk.ts index 623df4f6c..a45312ed1 100644 --- a/packages/core/src/server/auth/providers/clerk.ts +++ b/packages/core/src/server/auth/providers/clerk.ts @@ -1,4 +1,4 @@ -import type { OAuthConfig } from "../index.js"; +import type { JwksOAuthConfig } from "../index.js"; import { type CustomProviderOptions, customProvider } from "./custom.js"; import { toIssuerUrl } from "./shared.js"; @@ -13,7 +13,7 @@ import { toIssuerUrl } from "./shared.js"; */ export function clerkProvider( opts: { domain: string } & Omit, -): Promise { +): Promise { const { domain, ...rest } = opts; return customProvider({ issuer: toIssuerUrl(domain), ...rest }); } diff --git a/packages/core/src/server/auth/providers/custom.ts b/packages/core/src/server/auth/providers/custom.ts index 5813b8796..cd20b19b4 100644 --- a/packages/core/src/server/auth/providers/custom.ts +++ b/packages/core/src/server/auth/providers/custom.ts @@ -3,7 +3,7 @@ import { type DiscoveredMetadata, discoverAuthorizationServer, } from "../discovery.js"; -import type { OAuthConfig } from "../index.js"; +import type { JwksOAuthConfig } from "../index.js"; /** Options accepted by {@link customProvider} and the branded providers. */ export type CustomProviderOptions = { @@ -37,7 +37,7 @@ export type CustomProviderOptions = { /** Builds a complete {@link OAuthConfig} from an IdP's OAuth discovery document. */ export async function customProvider( opts: CustomProviderOptions, -): Promise { +): Promise { const discovered = await discoverAuthorizationServer(opts.issuer); // JWKS verification needs a signing-key URL; without it the server can't verify diff --git a/packages/core/src/server/auth/providers/descope.ts b/packages/core/src/server/auth/providers/descope.ts index 2d8bd7bee..9195559c0 100644 --- a/packages/core/src/server/auth/providers/descope.ts +++ b/packages/core/src/server/auth/providers/descope.ts @@ -1,4 +1,4 @@ -import type { OAuthConfig } from "../index.js"; +import type { JwksOAuthConfig } from "../index.js"; import { type CustomProviderOptions, customProvider } from "./custom.js"; /** @@ -37,7 +37,7 @@ function projectIdFromUrl(url: string): string { */ export function descopeProvider( opts: { url: string } & Omit, -): Promise { +): Promise { const { url, audience, ...rest } = opts; const asUrl = toAuthorizationServerUrl(url); const projectId = projectIdFromUrl(asUrl); diff --git a/packages/core/src/server/auth/providers/stytch.ts b/packages/core/src/server/auth/providers/stytch.ts index 93a5725f3..d38531434 100644 --- a/packages/core/src/server/auth/providers/stytch.ts +++ b/packages/core/src/server/auth/providers/stytch.ts @@ -1,4 +1,4 @@ -import type { OAuthConfig } from "../index.js"; +import type { JwksOAuthConfig } from "../index.js"; import { type CustomProviderOptions, customProvider } from "./custom.js"; import { toIssuerUrl } from "./shared.js"; @@ -13,7 +13,7 @@ export function stytchProvider( CustomProviderOptions, "issuer" | "audience" >, -): Promise { +): Promise { const { domain, ...rest } = opts; return customProvider({ issuer: toIssuerUrl(domain), ...rest }); } diff --git a/packages/core/src/server/auth/providers/workos.ts b/packages/core/src/server/auth/providers/workos.ts index b07c38bd3..7c7f1ebed 100644 --- a/packages/core/src/server/auth/providers/workos.ts +++ b/packages/core/src/server/auth/providers/workos.ts @@ -1,4 +1,4 @@ -import type { OAuthConfig } from "../index.js"; +import type { JwksOAuthConfig } from "../index.js"; import { type CustomProviderOptions, customProvider } from "./custom.js"; import { toIssuerUrl } from "./shared.js"; @@ -12,7 +12,7 @@ export function workosProvider( CustomProviderOptions, "issuer" | "audience" >, -): Promise { +): Promise { const { domain, ...rest } = opts; return customProvider({ issuer: toIssuerUrl(domain), ...rest }); } diff --git a/packages/core/src/server/auth/setup.test.ts b/packages/core/src/server/auth/setup.test.ts index 0c072654b..07cf6c961 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,10 @@ 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", + verify, + }: { baseUrl?: string | null; verify?: OAuthConfig["verify"] } = {}, ) { const { createApp } = await import("../express.js"); const server = new McpServer( @@ -76,7 +81,7 @@ async function bootServer( token_endpoint: `${ISSUER}/token`, response_types_supported: ["code"], }, - verify: { issuer: ISSUER, audience: AUDIENCE, jwksUri }, + verify: verify ?? { issuer: ISSUER, audience: AUDIENCE, jwksUri }, scopesSupported: ["openid", "email"], requiredScopes: ["openid"], }, @@ -156,6 +161,74 @@ describe("setupOAuth wiring", () => { }); }); +describe("custom token verifier in oauth.verify", () => { + const customVerifier: OAuthConfig["verify"] = { + 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, + }; + }, + }; + + it("threads authInfo from the custom verifier into the tool handler", async () => { + const { jwksUri } = await startJwks(); + const base = await bootServer(jwksUri, { verify: customVerifier }); + + 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, { verify: customVerifier }); + + 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, { verify: customVerifier }); + + 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); + }); +}); + describe("baseUrl inferred from headers", () => { it("derives the resource origin from x-forwarded-host", async () => { const { jwksUri } = await startJwks(); @@ -601,6 +674,19 @@ describe("oauth config validation", () => { response_types_supported: ["code"], }; + it("throws when a config-shaped 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("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..63dc1beb4 100644 --- a/packages/core/src/server/auth/setup.ts +++ b/packages/core/src/server/auth/setup.ts @@ -19,7 +19,7 @@ import { securitySchemesAllowAnonymous, wwwAuthenticateHeader, } from "./security-schemes.js"; -import { createJwksVerifier } from "./verify.js"; +import { createJwksVerifier, isTokenVerifier } from "./verify.js"; export type ResourceMetadataUrlResolver = ( getHeader: (key: string) => string | undefined, @@ -31,13 +31,17 @@ export function setupOAuth( config: OAuthConfig, schemesByTool: Map, ): ResourceMetadataUrlResolver { - if (!config.verify?.issuer) { - throw new Error("oauth.verify requires an `issuer`"); + if (!isTokenVerifier(config.verify) && !config.verify?.issuer) { + throw new Error( + "oauth.verify requires an `issuer` (JWKS config) or a `verifyAccessToken` implementation", + ); } const acceptsAnonymous = () => [...schemesByTool.values()].some(securitySchemesAllowAnonymous); - const verifier = createJwksVerifier(config.verify); + const verifier = isTokenVerifier(config.verify) + ? config.verify + : createJwksVerifier(config.verify); const bearer = (options: BearerAuthMiddlewareOptions): RequestHandler => { const required = requireBearerAuth(options); const optional = optionalBearerAuth(options); diff --git a/packages/core/src/server/auth/verify.test.ts b/packages/core/src/server/auth/verify.test.ts index b3326bfd9..f1c6e8868 100644 --- a/packages/core/src/server/auth/verify.test.ts +++ b/packages/core/src/server/auth/verify.test.ts @@ -2,7 +2,7 @@ import http from "node:http"; import * as jose from "jose"; import { afterEach, describe, expect, it } from "vitest"; -import { createJwksVerifier } from "./verify.js"; +import { createJwksVerifier, isTokenVerifier } from "./verify.js"; const ISSUER = "https://issuer.test"; const AUDIENCE = "api://default"; @@ -121,3 +121,34 @@ describe("createJwksVerifier", () => { ]); }); }); + +describe("isTokenVerifier", () => { + it("recognises an object implementing verifyAccessToken", () => { + expect( + isTokenVerifier({ + verifyAccessToken: async () => ({ + token: "t", + clientId: "c", + scopes: [], + }), + }), + ).toBe(true); + }); + + it("treats a JwksVerifyConfig as config, not a verifier", () => { + expect(isTokenVerifier({ issuer: "https://issuer.test" })).toBe(false); + }); + + it("prefers the verifier reading when both shapes are present", () => { + expect( + isTokenVerifier({ + issuer: "https://issuer.test", + verifyAccessToken: async () => ({ + token: "t", + clientId: "c", + scopes: [], + }), + } as never), + ).toBe(true); + }); +}); diff --git a/packages/core/src/server/auth/verify.ts b/packages/core/src/server/auth/verify.ts index d271f9e66..fa06bc21d 100644 --- a/packages/core/src/server/auth/verify.ts +++ b/packages/core/src/server/auth/verify.ts @@ -13,6 +13,21 @@ export type JwksVerifyConfig = { jwksUri?: string; }; +/** + * Distinguishes a caller-supplied `OAuthTokenVerifier` from a + * `JwksVerifyConfig` in `OAuthConfig.verify`. An object carrying a + * `verifyAccessToken` function is taken as a verifier even if it also has + * config-shaped fields. + */ +export function isTokenVerifier( + verify: JwksVerifyConfig | OAuthTokenVerifier, +): verify is OAuthTokenVerifier { + return ( + typeof (verify as Partial).verifyAccessToken === + "function" + ); +} + /** Builds an `OAuthTokenVerifier` validating JWTs against a remote JWKS. Internal, not exported. */ export function createJwksVerifier( config: JwksVerifyConfig,