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
8 changes: 6 additions & 2 deletions docs/api-reference/custom-provider.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
};
Expand All @@ -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.

<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
19 changes: 18 additions & 1 deletion packages/core/src/server/auth/index.ts
Original file line number Diff line number Diff line change
@@ -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 = {
/**
Expand All @@ -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. */
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/server/auth/providers/auth0.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -18,7 +18,7 @@ export async function auth0Provider(
CustomProviderOptions,
"issuer" | "audience" | "baseUrl" | "serverUrl"
>,
): Promise<OAuthConfig> {
): Promise<JwksOAuthConfig> {
const { domain, audience, ...rest } = opts;
const config = await customProvider({
issuer: toIssuerUrl(domain),
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/server/auth/providers/authplane.ts
Original file line number Diff line number Diff line change
@@ -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}. */
Expand Down Expand Up @@ -80,7 +80,7 @@ function parseIdentifier(value: string, option: string): URL {
*/
export function authplaneProvider(
opts: AuthplaneProviderOptions,
): Promise<OAuthConfig> {
): Promise<JwksOAuthConfig> {
const { issuer, resource, audience, ...rest } = opts;

parseIdentifier(issuer, "issuer");
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/server/auth/providers/clerk.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -13,7 +13,7 @@ import { toIssuerUrl } from "./shared.js";
*/
export function clerkProvider(
opts: { domain: string } & Omit<CustomProviderOptions, "issuer" | "audience">,
): Promise<OAuthConfig> {
): Promise<JwksOAuthConfig> {
const { domain, ...rest } = opts;
return customProvider({ issuer: toIssuerUrl(domain), ...rest });
}
4 changes: 2 additions & 2 deletions packages/core/src/server/auth/providers/custom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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<OAuthConfig> {
): Promise<JwksOAuthConfig> {
const discovered = await discoverAuthorizationServer(opts.issuer);

// JWKS verification needs a signing-key URL; without it the server can't verify
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/server/auth/providers/descope.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { OAuthConfig } from "../index.js";
import type { JwksOAuthConfig } from "../index.js";
import { type CustomProviderOptions, customProvider } from "./custom.js";

/**
Expand Down Expand Up @@ -37,7 +37,7 @@ function projectIdFromUrl(url: string): string {
*/
export function descopeProvider(
opts: { url: string } & Omit<CustomProviderOptions, "issuer">,
): Promise<OAuthConfig> {
): Promise<JwksOAuthConfig> {
const { url, audience, ...rest } = opts;
const asUrl = toAuthorizationServerUrl(url);
const projectId = projectIdFromUrl(asUrl);
Expand Down
4 changes: 2 additions & 2 deletions packages/core/src/server/auth/providers/stytch.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -13,7 +13,7 @@ export function stytchProvider(
CustomProviderOptions,
"issuer" | "audience"
>,
): Promise<OAuthConfig> {
): Promise<JwksOAuthConfig> {
const { domain, ...rest } = opts;
return customProvider({ issuer: toIssuerUrl(domain), ...rest });
}
4 changes: 2 additions & 2 deletions packages/core/src/server/auth/providers/workos.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -12,7 +12,7 @@ export function workosProvider(
CustomProviderOptions,
"issuer" | "audience"
>,
): Promise<OAuthConfig> {
): Promise<JwksOAuthConfig> {
const { domain, ...rest } = opts;
return customProvider({ issuer: toIssuerUrl(domain), ...rest });
}
90 changes: 88 additions & 2 deletions 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,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(
Expand All @@ -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"],
},
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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(
() =>
Expand Down
12 changes: 8 additions & 4 deletions packages/core/src/server/auth/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -31,13 +31,17 @@ export function setupOAuth(
config: OAuthConfig,
schemesByTool: Map<string, SecurityScheme[] | undefined>,
): 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);
Expand Down
33 changes: 32 additions & 1 deletion packages/core/src/server/auth/verify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
});
});
15 changes: 15 additions & 0 deletions packages/core/src/server/auth/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<OAuthTokenVerifier>).verifyAccessToken ===
"function"
);
}

/** Builds an `OAuthTokenVerifier` validating JWTs against a remote JWKS. Internal, not exported. */
export function createJwksVerifier(
config: JwksVerifyConfig,
Expand Down