Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/api-reference/custom-provider.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
};
Expand All @@ -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.

<CardGroup cols={3}>
<Card title="Connect an Identity Provider" icon="fingerprint" href="/guides/auth-providers">
Set up sign-in with a hosted provider
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/server/auth/index.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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. */
Expand Down
126 changes: 125 additions & 1 deletion packages/core/src/server/auth/setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: () =>
Expand Down Expand Up @@ -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(
Expand All @@ -70,6 +78,7 @@ async function bootServer(
{
oauth: {
...(baseUrl === null ? {} : { baseUrl }),
...(createVerifier === undefined ? {} : { createVerifier }),
oauthMetadata: {
issuer: ISSUER,
authorization_endpoint: `${ISSUER}/authorize`,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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(
() =>
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/server/auth/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down