From e53582ce55a0ddbca62f52efeb3459523816f222 Mon Sep 17 00:00:00 2001 From: Gustavo Valverde Date: Thu, 11 Jun 2026 21:34:38 -0700 Subject: [PATCH] feat(mcp)!: ship MCP as its own package built on the OAuth provider (#9992) --- .changeset/mcp-oauth-provider-migration.md | 11 + docs/content/docs/plugins/mcp.mdx | 426 ++---- packages/better-auth/package.json | 16 - packages/better-auth/src/plugins/index.ts | 1 - .../better-auth/src/plugins/mcp/authorize.ts | 276 ---- .../src/plugins/mcp/client/mcp-client.test.ts | 431 ------ packages/better-auth/src/plugins/mcp/index.ts | 1183 --------------- .../better-auth/src/plugins/mcp/mcp.test.ts | 1316 ----------------- packages/better-auth/tsdown.config.ts | 2 - packages/mcp/README.md | 21 + packages/mcp/package.json | 92 ++ .../mcp => mcp/src}/client/adapters.ts | 93 +- packages/mcp/src/client/index.test.ts | 134 ++ .../plugins/mcp => mcp/src}/client/index.ts | 131 +- packages/mcp/src/handler.ts | 60 + packages/mcp/src/index.ts | 3 + .../{oauth-provider => mcp}/src/mcp.test.ts | 22 +- packages/mcp/src/plugin.test.ts | 609 ++++++++ packages/mcp/src/plugin.ts | 170 +++ packages/mcp/src/require-mcp-auth.test.ts | 113 ++ packages/mcp/src/require-mcp-auth.ts | 132 ++ packages/mcp/tsconfig.json | 18 + packages/mcp/tsdown.config.ts | 13 + packages/mcp/vitest.config.ts | 9 + packages/oauth-provider/package.json | 1 - .../oauth-provider/src/client-resource.ts | 4 +- packages/oauth-provider/src/index.ts | 12 +- packages/oauth-provider/src/mcp.ts | 103 -- packages/oauth-provider/src/oauth.ts | 16 +- .../oauth-provider/src/resource-challenge.ts | 57 + pnpm-lock.yaml | 37 +- 31 files changed, 1756 insertions(+), 3756 deletions(-) create mode 100644 .changeset/mcp-oauth-provider-migration.md delete mode 100644 packages/better-auth/src/plugins/mcp/authorize.ts delete mode 100644 packages/better-auth/src/plugins/mcp/client/mcp-client.test.ts delete mode 100644 packages/better-auth/src/plugins/mcp/index.ts delete mode 100644 packages/better-auth/src/plugins/mcp/mcp.test.ts create mode 100644 packages/mcp/README.md create mode 100644 packages/mcp/package.json rename packages/{better-auth/src/plugins/mcp => mcp/src}/client/adapters.ts (62%) create mode 100644 packages/mcp/src/client/index.test.ts rename packages/{better-auth/src/plugins/mcp => mcp/src}/client/index.ts (68%) create mode 100644 packages/mcp/src/handler.ts create mode 100644 packages/mcp/src/index.ts rename packages/{oauth-provider => mcp}/src/mcp.test.ts (95%) create mode 100644 packages/mcp/src/plugin.test.ts create mode 100644 packages/mcp/src/plugin.ts create mode 100644 packages/mcp/src/require-mcp-auth.test.ts create mode 100644 packages/mcp/src/require-mcp-auth.ts create mode 100644 packages/mcp/tsconfig.json create mode 100644 packages/mcp/tsdown.config.ts create mode 100644 packages/mcp/vitest.config.ts delete mode 100644 packages/oauth-provider/src/mcp.ts create mode 100644 packages/oauth-provider/src/resource-challenge.ts diff --git a/.changeset/mcp-oauth-provider-migration.md b/.changeset/mcp-oauth-provider-migration.md new file mode 100644 index 0000000000..97eb3412a7 --- /dev/null +++ b/.changeset/mcp-oauth-provider-migration.md @@ -0,0 +1,11 @@ +--- +"@better-auth/mcp": minor +"better-auth": minor +"@better-auth/oauth-provider": minor +--- + +The MCP plugin moves out of `better-auth` into its own package, `@better-auth/mcp`, built on `@better-auth/oauth-provider`. Import the server plugin and its helpers from `@better-auth/mcp`, and the remote client and adapters from `@better-auth/mcp/client` and `@better-auth/mcp/client/adapters` (previously imported from `better-auth/plugins` and `better-auth/plugins/mcp/client`). The OAuth endpoints move from `/mcp/*` to `/oauth2/*`, with discovery at `/.well-known/oauth-authorization-server` and protected resource metadata at `/.well-known/oauth-protected-resource`. Discovery-based MCP clients pick up the new locations on their own. + +The route helper is renamed `requireMcpAuth` (was `withMcpAuth`), and the remote client is `createMcpResourceClient` (was `createMcpAuthClient`). `requireMcpAuth` verifies the bearer token against the published JWKS and passes the verified JWT claims to your handler. + +To migrate, install `@better-auth/mcp`, add the `jwt()` plugin (now required for token signing), and move options that were nested under `oidcConfig` to flat options on `mcp({ ... })`. The database models change: `oauthApplication` becomes `oauthClient`, with new `oauthRefreshToken` and `oauthClientAssertion` tables. Regenerate or migrate your schema with `npx auth migrate` or `npx auth generate`. diff --git a/docs/content/docs/plugins/mcp.mdx b/docs/content/docs/plugins/mcp.mdx index a9ba523e64..d52a59a3f4 100644 --- a/docs/content/docs/plugins/mcp.mdx +++ b/docs/content/docs/plugins/mcp.mdx @@ -1,18 +1,16 @@ --- title: MCP -description: MCP provider plugin for Better Auth +description: Turn your Better Auth server into an OAuth provider for MCP clients --- `OAuth` `MCP` - - This plugin will soon be deprecated in favor of the [OAuth Provider Plugin](/docs/plugins/oauth-provider). - +The **MCP** plugin lets your app act as an OAuth authorization server for [Model Context Protocol](https://modelcontextprotocol.io) clients. It is built on the [OAuth 2.1 Provider](/docs/plugins/oauth-provider), so MCP clients discover your endpoints, register dynamically, and obtain access tokens through standard OAuth discovery. -The **MCP** plugin lets your app act as an OAuth provider for MCP clients. It handles authentication and makes it easy to issue and manage access tokens for MCP applications. +`mcp()` configures the OAuth provider with MCP-appropriate defaults (dynamic registration for self-registering clients, PKCE for public clients) and serves the [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) protected resource metadata. - - This plugin is based on OIDC Provider plugin. It'll be moved to the OAuth Provider Plugin in the future. + + `mcp()` ships in the `@better-auth/mcp` package. Install it with `npm install @better-auth/mcp`. ## Installation @@ -21,30 +19,33 @@ The **MCP** plugin lets your app act as an OAuth provider for MCP clients. It ha ### Add the Plugin - Add the MCP plugin to your auth configuration and specify the login page path. + Add the MCP plugin to your auth configuration alongside the [JWT plugin](/docs/plugins/jwt). The JWT plugin is required: it provides the stable signing key used for ID tokens and access tokens, and exposes the `/jwks` endpoint that resource servers use to verify them. ```ts title="auth.ts" import { betterAuth } from "better-auth"; - import { mcp } from "better-auth/plugins"; // [!code highlight] + import { jwt } from "better-auth/plugins"; // [!code highlight] + import { mcp } from "@better-auth/mcp"; // [!code highlight] export const auth = betterAuth({ plugins: [ + jwt(), // [!code highlight] mcp({ // [!code highlight] - loginPage: "/sign-in" // path to your login page // [!code highlight] + loginPage: "/sign-in", // path to your login page // [!code highlight] + consentPage: "/consent" // path to your consent page // [!code highlight] }) // [!code highlight] ] }); ``` - This doesn't have a client plugin, so you don't need to make any changes to your authClient. + `mcp()` is the OAuth provider. Do not also register a separate `oauthProvider()` plugin in the same app. ### Generate Schema - Run the migration or generate the schema to add the necessary fields and tables to the database. + Run the migration or generate the schema to add the necessary tables to the database. @@ -60,46 +61,51 @@ The **MCP** plugin lets your app act as an OAuth provider for MCP clients. It ha - The MCP plugin uses the same schema as the OIDC Provider plugin. See the [OIDC Provider Schema](/docs/plugins/oidc-provider#schema) section for details. + The MCP plugin uses the same schema as the OAuth Provider plugin (`oauthClient`, `oauthAccessToken`, `oauthRefreshToken`, `oauthConsent`, `oauthClientAssertion`). See the [OAuth Provider Schema](/docs/plugins/oauth-provider#schema) section for details. +## Endpoints + +`mcp()` serves the standard OAuth 2.1 endpoints under `/oauth2/*`: + +| Endpoint | Path | +| -------------------- | ------------------- | +| Authorization | `/oauth2/authorize` | +| Token | `/oauth2/token` | +| Dynamic registration | `/oauth2/register` | +| UserInfo | `/oauth2/userinfo` | + +Discovery follows OAuth 2.0 Authorization Server Metadata ([RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414)) and Protected Resource Metadata ([RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728)). The well-known URLs are derived from the issuer, so a server with a base path serves them at the issuer-inserted location rather than the bare root. Spec-compliant MCP clients locate the authorization, token, and registration endpoints through discovery, so they need no hardcoded paths. + ## Usage -### OAuth Discovery Metadata +### Protected Resource Metadata -Better Auth already handles the `/api/auth/.well-known/oauth-authorization-server` route automatically but some client may fail to parse the `WWW-Authenticate` header and default to `/.well-known/oauth-authorization-server` (this can happen, for example, if your CORS configuration doesn't expose the `WWW-Authenticate`). For this reason it's better to add a route to expose OAuth metadata for MCP clients: +The [RFC 9728](https://datatracker.ietf.org/doc/html/rfc9728) `/.well-known/oauth-protected-resource` document is served automatically at the well-known root (and the resource-path-inserted alias). It tells MCP clients which authorization server protects the resource, which scopes it supports, and which `resource` identifier their access tokens must be bound to. -```ts title=".well-known/oauth-authorization-server/route.ts" -import { oAuthDiscoveryMetadata } from "better-auth/plugins"; -import { auth } from "../../../lib/auth"; +The `resource` defaults to the server's base URL. Set it explicitly when your resource server has a distinct identifier: -export const GET = oAuthDiscoveryMetadata(auth); -``` - -### OAuth Protected Resource Metadata - -Better Auth already handles the `/api/auth/.well-known/oauth-protected-resource` route automatically but some client may fail to parse the `WWW-Authenticate` header and default to `/.well-known/oauth-protected-resource` (this can happen, for example, if your CORS configuration doesn't expose the `WWW-Authenticate`). For this reason it's better to add a route to expose OAuth metadata for MCP clients: - -```ts title="/.well-known/oauth-protected-resource/route.ts" -import { oAuthProtectedResourceMetadata } from "better-auth/plugins"; -import { auth } from "@/lib/auth"; - -export const GET = oAuthProtectedResourceMetadata(auth); +```ts title="auth.ts" +mcp({ + loginPage: "/sign-in", + consentPage: "/consent", + resource: "https://api.example.com/mcp" +}) ``` ### MCP Session Handling -You can use the helper function `withMcpAuth` to get the session and handle unauthenticated calls automatically. +Use `requireMcpAuth` to protect an MCP route. It reads the `Authorization` header, verifies the bearer access token against the authorization server's JWKS (signature, issuer, audience, and expiry), and forwards the verified JWT claims to your handler. Unauthenticated requests receive a JSON-RPC `401` with the RFC 9728 `WWW-Authenticate` header, so MCP clients can start the authorization flow. ```ts title="api/[transport]/route.ts" import { auth } from "@/lib/auth"; import { createMcpHandler } from "@vercel/mcp-adapter"; -import { withMcpAuth } from "better-auth/plugins"; +import { requireMcpAuth } from "@better-auth/mcp"; // [!code highlight] import { z } from "zod"; -const handler = withMcpAuth(auth, (req, session) => { - // session contains the access token record with scopes and user ID +const handler = requireMcpAuth(auth, (req, claims) => { // [!code highlight] + // claims holds the verified JWT payload: sub, scope, client_id, ... // [!code highlight] return createMcpHandler( (server) => { server.tool( @@ -134,327 +140,103 @@ const handler = withMcpAuth(auth, (req, session) => { export { handler as GET, handler as POST, handler as DELETE }; ``` -You can also use `auth.api.getMcpSession` to get the session using the access token sent from the MCP client: +The second argument is the verified JWT payload, not a database record. `requireMcpAuth` never exposes a refresh token; the access token is checked locally against the JWKS without a database round trip. -```ts title="api/[transport]/route.ts" -import { auth } from "@/lib/auth"; -import { createMcpHandler } from "@vercel/mcp-adapter"; -import { z } from "zod"; +By default `requireMcpAuth` reads the server's resolved base URL from the auth context and uses it as both the expected token issuer and audience, with the JWKS at that base URL. This matches what the provider issues, so no configuration is needed for the common case. Override any value when the resource differs or `jwt.issuer` is custom: -const handler = async (req: Request) => { - // session contains the access token record with scopes and user ID - const session = await auth.api.getMcpSession({ - headers: req.headers - }) - if(!session){ - //this is important and you must return 401 - return new Response(null, { - status: 401 - }) - } - return createMcpHandler( - (server) => { - server.tool( - "echo", - "Echo a message", - { message: z.string() }, - async ({ message }) => { - return { - content: [{ type: "text", text: `Tool echo: ${message}` }], - }; - }, - ); - }, - { - capabilities: { - tools: { - echo: { - description: "Echo a message", - }, - }, - }, - }, - { - redisUrl: process.env.REDIS_URL, - basePath: "/api", - verboseLogs: true, - maxDuration: 60, - }, - )(req); -} - -export { handler as GET, handler as POST, handler as DELETE }; +```ts +requireMcpAuth(auth, handler, { + resource: "https://api.example.com/mcp", // token audience + issuer: "https://auth.example.com", // override when jwt.issuer is custom + jwksUrl: "https://auth.example.com/api/auth/jwks", + scope: "openid profile" // advertised in the 401 challenge +}) ``` ## Configuration -The MCP plugin accepts the following configuration options: +`mcp()` extends the [OAuth Provider](/docs/plugins/oauth-provider#configuration) options. All OAuth provider options are passed flat (there is no nested `oidcConfig`). The MCP-specific option is `resource`. export const mcpPluginOptionsType = { loginPage: { - description: "Path to the login page where users will be redirected for authentication", + description: "Path to the login page where users are redirected for authentication.", + type: "string", + required: true, + }, + consentPage: { + description: "Path to the consent page where users grant the requested scopes.", type: "string", required: true, }, resource: { - description: "The resource that should be returned by the protected resource metadata endpoint", + description: "The protected resource identifier (RFC 8707 / RFC 9728) that access tokens are bound to. Advertised in the protected resource metadata and used as the token audience.", type: "string", required: false, }, - oidcConfig: { - description: "Optional OIDC configuration options", - type: "object", - required: false, - }, } -### OIDC Configuration +The most commonly tuned OAuth provider options are below. See the [OAuth Provider Configuration](/docs/plugins/oauth-provider#configuration) for the full list. -The plugin supports additional OIDC configuration options through the `oidcConfig` parameter: - -export const mcpOidcConfigOptionsType = { - codeExpiresIn: { - description: "Expiration time for authorization codes in seconds", - type: "number", - default: 600, - }, - accessTokenExpiresIn: { - description: "Expiration time for access tokens in seconds", - type: "number", - default: 3600, - }, - refreshTokenExpiresIn: { - description: "Expiration time for refresh tokens in seconds", - type: "number", - default: 604800, - }, - defaultScope: { - description: "Default scope for OAuth requests", - type: "string", - default: "openid", - }, +export const mcpOauthOptionsType = { scopes: { - description: "Additional scopes to support", + description: "Scopes advertised by the authorization server.", type: "string[]", default: '["openid", "profile", "email", "offline_access"]', }, + validAudiences: { + description: "Additional valid audiences when the resource server has more than one identifier.", + type: "string[]", + }, + accessTokenExpiresIn: { + description: "Lifetime of access tokens in seconds.", + type: "number", + default: 3600, + }, + idTokenExpiresIn: { + description: "Lifetime of ID tokens in seconds.", + type: "number", + default: 36000, + }, + refreshTokenExpiresIn: { + description: "Lifetime of refresh tokens in seconds.", + type: "number", + default: 2592000, + }, + codeExpiresIn: { + description: "Lifetime of authorization codes in seconds.", + type: "number", + default: 600, + }, } - + -## Remote MCP Client +## Remote MCP Server -The examples above use `withMcpAuth` which requires the Better Auth instance to be in the **same process** as the MCP server. If your MCP server runs as a separate service (different repo, different runtime, different language), you can use the **MCP Client** — a lightweight, framework-agnostic HTTP client that validates Bearer tokens against a remote Better Auth server. +`requireMcpAuth` verifies tokens against your Better Auth server's JWKS, so the MCP route can run anywhere as long as it can reach that JWKS URL. When the resource server runs separately from the authorization server, or uses a dynamic `baseURL`, use `mcpHandler` with explicit verification options instead of `requireMcpAuth`. - - No additional packages needed — the MCP Client is included in `better-auth`. - +```ts title="mcp-server.ts" +import { mcpHandler } from "@better-auth/mcp"; // [!code highlight] -### Setup - - - - ### Create the client - - Point it at your Better Auth server's URL (the same `baseURL` + `basePath` from your auth config): - - ```ts title="mcp-server.ts" - import { createMcpAuthClient } from "better-auth/plugins/mcp/client" // [!code highlight] - - const mcpAuth = createMcpAuthClient({ // [!code highlight] - authURL: "http://localhost:3000/api/auth" // [!code highlight] - }) // [!code highlight] - ``` - - - - ### Protect your MCP routes - - Use the `handler` wrapper for Web Standard `Request`/`Response` (works with Deno, Bun, Cloudflare Workers, etc.): - - ```ts title="mcp-server.ts" - const handler = mcpAuth.handler(async (req, session) => { // [!code highlight] - // session.userId, session.scopes, session.clientId +const handler = mcpHandler( // [!code highlight] + { + verifyOptions: { + issuer: "https://auth.example.com", + audience: "https://api.example.com/mcp", + }, + jwksUrl: "https://auth.example.com/api/auth/jwks", + }, + async (req, claims) => { // [!code highlight] + // claims holds the verified JWT payload // [!code highlight] return new Response(JSON.stringify({ jsonrpc: "2.0", - result: { userId: session.userId }, + result: { sub: claims.sub }, id: 1 })) - }) // [!code highlight] + } +) +``` - Deno.serve(handler) - ``` - - - - ### Mount discovery endpoints - - MCP clients need to discover your OAuth server. Mount these at the root of your MCP server: - - ```ts title="mcp-server.ts" - const discovery = mcpAuth.discoveryHandler() // [!code highlight] - const protectedResource = mcpAuth.protectedResourceHandler("http://localhost:4000") // [!code highlight] - - // GET /.well-known/oauth-authorization-server → proxied from Better Auth - // GET /.well-known/oauth-protected-resource → points MCP clients to Better Auth - ``` - - These proxy and cache the metadata from your Better Auth server, so MCP clients can discover the authorization, token, and registration endpoints automatically. - - - -### Framework Adapters - - - - ```ts title="mcp-server.ts" - import { Hono } from "hono" - import { mcpAuthHono } from "better-auth/plugins/mcp/client/adapters" // [!code highlight] - - const app = new Hono() - const { middleware, discoveryRoutes } = mcpAuthHono({ // [!code highlight] - authURL: "http://localhost:3000/api/auth" // [!code highlight] - }) // [!code highlight] - - // Mount OAuth discovery endpoints (required by MCP spec) - discoveryRoutes(app, "http://localhost:4000") // [!code highlight] - - // Protect MCP routes - app.use("/mcp/*", middleware) // [!code highlight] - - app.post("/mcp", (c) => { - const session = c.get("mcpSession") // [!code highlight] - // session.userId, session.scopes, etc. - }) - ``` - - - - ```ts title="mcp-server.ts" - import express from "express" - import { createMcpAuthClient } from "better-auth/plugins/mcp/client" // [!code highlight] - - const app = express() - const mcpAuth = createMcpAuthClient({ // [!code highlight] - authURL: "http://localhost:3000/api/auth" // [!code highlight] - }) // [!code highlight] - - app.use("/mcp", mcpAuth.middleware()) // [!code highlight] - - app.post("/mcp", (req, res) => { - const session = req.mcpSession // [!code highlight] - // session.userId, session.scopes, etc. - }) - ``` - - - - ```ts title="mcp-server.ts" - import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" - import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js" - import { mcpAuthOfficial } from "better-auth/plugins/mcp/client/adapters" // [!code highlight] - - const auth = mcpAuthOfficial({ // [!code highlight] - authURL: "http://localhost:3000/api/auth" // [!code highlight] - }) // [!code highlight] - - const mcpServer = new McpServer({ name: "my-server", version: "1.0.0" }) - const app = express() // your HTTP framework - - app.post("/mcp", auth.handler(async (req, session) => { // [!code highlight] - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => crypto.randomUUID() - }) - await mcpServer.connect(transport) - return transport.handleRequest(req) - })) - ``` - - - - Drop-in replacement for `oauthWorkOSProvider`, `oauthSupabaseProvider`, etc.: - - ```ts title="mcp-server.ts" - import { MCPServer } from "mcp-use/server" - import { mcpAuthMcpUse } from "better-auth/plugins/mcp/client/adapters" // [!code highlight] - - const server = new MCPServer({ - name: "my-server", - version: "1.0.0", - oauth: mcpAuthMcpUse({ // [!code highlight] - authURL: "http://localhost:3000/api/auth" // [!code highlight] - }) // [!code highlight] - }) - ``` - - - -### Options - -export const mcpClientOptionsType = { - authURL: { - description: "Full URL to Better Auth endpoints (baseURL + basePath)", - type: "string", - required: true, - }, - resource: { - description: "Resource identifier for the protected resource metadata. Defaults to the origin of the server URL.", - type: "string", - required: false, - }, - allowedOrigin: { - description: "Allowed CORS origin. Defaults to the authURL origin. Set to '*' to allow all origins (not recommended for production).", - type: "string", - required: false, - }, - fetch: { - description: "Custom fetch implementation. Defaults to global fetch.", - type: "typeof fetch", - required: false, - }, -} - - - -### Session Object - -The session object returned by `verifyToken` and passed to handlers contains: - -export const mcpSessionObjectType = { - accessToken: { - description: "The opaque access token", - type: "string", - }, - refreshToken: { - description: "The refresh token", - type: "string", - }, - accessTokenExpiresAt: { - description: "When the access token expires", - type: "string", - }, - refreshTokenExpiresAt: { - description: "When the refresh token expires", - type: "string", - }, - clientId: { - description: "The OAuth client ID that requested the token", - type: "string", - }, - userId: { - description: "The authenticated user's ID", - type: "string", - }, - scopes: { - description: "Space-separated list of granted scopes", - type: "string", - }, -} - - - -## Schema - -The MCP plugin uses the same schema as the OIDC Provider plugin. See the [OIDC Provider Schema](/docs/plugins/oidc-provider#schema) section for details. +`mcpHandler` returns the same RFC 9728 `WWW-Authenticate` response for unauthenticated requests. For resources whose identifier is not a URL (for example a URN), pass `resourceMetadataMappings` to map each resource to its metadata URL. diff --git a/packages/better-auth/package.json b/packages/better-auth/package.json index 54be1f3652..f2953f52cd 100644 --- a/packages/better-auth/package.json +++ b/packages/better-auth/package.json @@ -310,16 +310,6 @@ "dev-source": "./src/plugins/device-authorization/index.ts", "types": "./dist/plugins/device-authorization/index.d.mts", "default": "./dist/plugins/device-authorization/index.mjs" - }, - "./plugins/mcp/client": { - "dev-source": "./src/plugins/mcp/client/index.ts", - "types": "./dist/plugins/mcp/client/index.d.mts", - "default": "./dist/plugins/mcp/client/index.mjs" - }, - "./plugins/mcp/client/adapters": { - "dev-source": "./src/plugins/mcp/client/adapters.ts", - "types": "./dist/plugins/mcp/client/adapters.d.mts", - "default": "./dist/plugins/mcp/client/adapters.mjs" } }, "typesVersions": { @@ -479,12 +469,6 @@ ], "plugins/device-authorization": [ "./dist/plugins/device-authorization/index.d.mts" - ], - "plugins/mcp/client": [ - "./dist/plugins/mcp/client/index.d.mts" - ], - "plugins/mcp/client/adapters": [ - "./dist/plugins/mcp/client/adapters.d.mts" ] } }, diff --git a/packages/better-auth/src/plugins/index.ts b/packages/better-auth/src/plugins/index.ts index c6c80709f5..4b8c689bbc 100644 --- a/packages/better-auth/src/plugins/index.ts +++ b/packages/better-auth/src/plugins/index.ts @@ -13,7 +13,6 @@ export * from "./haveibeenpwned"; export * from "./jwt"; export * from "./last-login-method"; export * from "./magic-link"; -export * from "./mcp"; export * from "./multi-session"; export * from "./oauth-popup"; export * from "./oauth-proxy"; diff --git a/packages/better-auth/src/plugins/mcp/authorize.ts b/packages/better-auth/src/plugins/mcp/authorize.ts deleted file mode 100644 index bf64b18678..0000000000 --- a/packages/better-auth/src/plugins/mcp/authorize.ts +++ /dev/null @@ -1,276 +0,0 @@ -import type { GenericEndpointContext } from "@better-auth/core"; -import { APIError } from "@better-auth/core/error"; -import { getSessionFromCtx } from "../../api"; -import { generateRandomString } from "../../crypto"; -import type { OAuthApplication } from "../oidc-provider/schema"; -import type { - AuthorizationQuery, - Client, - OIDCOptions, -} from "../oidc-provider/types"; - -function redirectErrorURL(url: string, error: string, description: string) { - return `${url}${ - url.includes("?") ? "&" : "?" - }error=${error}&error_description=${description}`; -} - -export async function authorizeMCPOAuth( - ctx: GenericEndpointContext, - options: OIDCOptions, -) { - ctx.setHeader("Access-Control-Allow-Origin", "*"); - ctx.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS"); - ctx.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); - ctx.setHeader("Access-Control-Max-Age", "86400"); - const opts = { - codeExpiresIn: 600, - defaultScope: "openid", - ...options, - scopes: [ - "openid", - "profile", - "email", - "offline_access", - ...(options?.scopes || []), - ], - }; - if (!ctx.request) { - throw new APIError("UNAUTHORIZED", { - error_description: "request not found", - error: "invalid_request", - }); - } - const session = await getSessionFromCtx(ctx); - if (!session) { - /** - * If the user is not logged in, we need to redirect them to the - * login page. - */ - await ctx.setSignedCookie( - "oidc_login_prompt", - JSON.stringify(ctx.query), - ctx.context.secret, - { - maxAge: 600, - path: "/", - sameSite: "lax", - }, - ); - const queryFromURL = ctx.request.url?.split("?")[1]!; - throw ctx.redirect(`${options.loginPage}?${queryFromURL}`); - } - - const query = ctx.query as AuthorizationQuery; - if (!query.client_id) { - throw ctx.redirect(`${ctx.context.baseURL}/error?error=invalid_client`); - } - - if (!query.response_type) { - throw ctx.redirect( - redirectErrorURL( - `${ctx.context.baseURL}/error`, - "invalid_request", - "response_type is required", - ), - ); - } - - const client = await ctx.context.adapter - .findOne({ - model: "oauthApplication", - where: [ - { - field: "clientId", - value: ctx.query.client_id, - }, - ], - }) - .then((res) => { - if (!res) { - return null; - } - return { - ...res, - redirectUrls: res.redirectUrls.split(","), - metadata: res.metadata ? JSON.parse(res.metadata) : {}, - } as Client; - }); - if (!client) { - throw ctx.redirect(`${ctx.context.baseURL}/error?error=invalid_client`); - } - const redirectURI = client.redirectUrls.find( - (url) => url === ctx.query.redirect_uri, - ); - - if (!redirectURI || !query.redirect_uri) { - /** - * show UI error here warning the user that the redirect URI is invalid - */ - throw new APIError("BAD_REQUEST", { - message: "Invalid redirect URI", - }); - } - if (client.disabled) { - throw ctx.redirect(`${ctx.context.baseURL}/error?error=client_disabled`); - } - - if (query.response_type !== "code") { - throw ctx.redirect( - `${ctx.context.baseURL}/error?error=unsupported_response_type`, - ); - } - - const requestScope = - query.scope?.split(" ").filter((s) => s) || opts.defaultScope.split(" "); - const invalidScopes = requestScope.filter((scope) => { - return !opts.scopes.includes(scope); - }); - if (invalidScopes.length) { - throw ctx.redirect( - redirectErrorURL( - query.redirect_uri, - "invalid_scope", - `The following scopes are invalid: ${invalidScopes.join(", ")}`, - ), - ); - } - - if ( - (!query.code_challenge || !query.code_challenge_method) && - options.requirePKCE - ) { - throw ctx.redirect( - redirectErrorURL( - query.redirect_uri, - "invalid_request", - "pkce is required", - ), - ); - } - - if (query.code_challenge_method && !query.code_challenge) { - throw ctx.redirect( - redirectErrorURL( - query.redirect_uri, - "invalid_request", - "code_challenge_method requires code_challenge", - ), - ); - } - - if (query.code_challenge) { - const allowedCodeChallengeMethods = options.allowPlainCodeChallengeMethod - ? ["s256", "plain"] - : ["s256"]; - let codeChallengeMethod: AuthorizationQuery["code_challenge_method"] = - query.code_challenge_method?.toLowerCase() as AuthorizationQuery["code_challenge_method"]; - // Backward-compat: callers who explicitly opt into plain PKCE retain the - // legacy "default missing method to `plain`" behavior. The secure default - // (`allowPlainCodeChallengeMethod: false`) still rejects a missing method. - // FIXME(legacy-plain-pkce-removal): remove this fallback on next; require - // callers to send `code_challenge_method` explicitly. - if (!codeChallengeMethod && options.allowPlainCodeChallengeMethod) { - codeChallengeMethod = "plain"; - } - if ( - !codeChallengeMethod || - !allowedCodeChallengeMethods.includes(codeChallengeMethod) - ) { - throw ctx.redirect( - redirectErrorURL( - query.redirect_uri, - "invalid_request", - "invalid code_challenge method", - ), - ); - } - // Persist the normalized value back so the verification record stores the - // lowercased and (optionally) fallback-resolved method. The token endpoint - // compares against `"plain"` exactly, so casing variations or a missing - // method on the opt-in path would otherwise break PKCE verification. - query.code_challenge_method = codeChallengeMethod; - } - - const code = generateRandomString(32, "a-z", "A-Z", "0-9"); - const codeExpiresInMs = opts.codeExpiresIn * 1000; - const expiresAt = new Date(Date.now() + codeExpiresInMs); - try { - /** - * Save the code in the database - */ - await ctx.context.internalAdapter.createVerificationValue({ - value: JSON.stringify({ - clientId: client.clientId, - redirectURI: query.redirect_uri, - scope: requestScope, - userId: session.user.id, - authTime: new Date(session.session.createdAt).getTime(), - /** - * If the prompt is set to `consent`, then we need - * to require the user to consent to the scopes. - * - * This means the code now needs to be treated as a - * consent request. - * - * once the user consents, the code will be updated - * with the actual code. This is to prevent the - * client from using the code before the user - * consents. - */ - requireConsent: query.prompt === "consent", - state: query.prompt === "consent" ? query.state : null, - codeChallenge: query.code_challenge, - codeChallengeMethod: query.code_challenge_method, - nonce: query.nonce, - }), - identifier: code, - expiresAt, - }); - } catch { - throw ctx.redirect( - redirectErrorURL( - query.redirect_uri, - "server_error", - "An error occurred while processing the request", - ), - ); - } - - // Consent is NOT required - redirect with the code immediately - if (query.prompt !== "consent") { - const redirectURIWithCode = new URL(redirectURI); - redirectURIWithCode.searchParams.set("code", code); - if (ctx.query.state) { - redirectURIWithCode.searchParams.set("state", ctx.query.state); - } - throw ctx.redirect(redirectURIWithCode.toString()); - } - - // Consent is REQUIRED - redirect to consent page or show consent HTML - if (options?.consentPage) { - // Set cookie to support cookie-based consent flows - await ctx.setSignedCookie("oidc_consent_prompt", code, ctx.context.secret, { - maxAge: 600, - path: "/", - sameSite: "lax", - }); - - // Pass the consent code as a URL parameter to support URL-BASED consent flows - const urlParams = new URLSearchParams(); - urlParams.set("consent_code", code); - urlParams.set("client_id", client.clientId); - urlParams.set("scope", requestScope.join(" ")); - const consentURI = `${options.consentPage}?${urlParams.toString()}`; - - throw ctx.redirect(consentURI); - } - - // No consent page configured - fall back to direct redirect with code - const redirectURIWithCode = new URL(redirectURI); - redirectURIWithCode.searchParams.set("code", code); - if (ctx.query.state) { - redirectURIWithCode.searchParams.set("state", ctx.query.state); - } - throw ctx.redirect(redirectURIWithCode.toString()); -} diff --git a/packages/better-auth/src/plugins/mcp/client/mcp-client.test.ts b/packages/better-auth/src/plugins/mcp/client/mcp-client.test.ts deleted file mode 100644 index f8dc375aa0..0000000000 --- a/packages/better-auth/src/plugins/mcp/client/mcp-client.test.ts +++ /dev/null @@ -1,431 +0,0 @@ -import { listen } from "listhen"; -import { afterAll, describe, expect, it } from "vitest"; -import { toNodeHandler } from "../../../integrations/node"; -import { getTestInstance } from "../../../test-utils/test-instance"; -import { jwt } from "../../jwt"; -import { mcp } from "../index"; -import { mcpAuthMcpUse } from "./adapters"; -import type { McpSession } from "./index"; -import { createMcpAuthClient } from "./index"; - -describe("mcp-client", async () => { - const tempServer = await listen( - toNodeHandler(async () => new Response("temp")), - { port: 0 }, - ); - const port = tempServer.address?.port || 3099; - const baseURL = `http://localhost:${port}`; - await tempServer.close(); - - const { auth, customFetchImpl } = await getTestInstance({ - baseURL, - plugins: [ - mcp({ - loginPage: "/login", - oidcConfig: { - loginPage: "/login", - consentPage: "/oauth/consent", - requirePKCE: true, - }, - }), - jwt(), - ], - }); - - const server = await listen(toNodeHandler(auth.handler), { port }); - afterAll(async () => { - await server.close(); - }); - - const authURL = `${baseURL}/api/auth`; - - describe("createMcpAuthClient", () => { - it("should create a client with correct authURL", () => { - const client = createMcpAuthClient({ authURL }); - expect(client.authURL).toBe(authURL); - }); - - it("should normalize trailing slash", () => { - const client = createMcpAuthClient({ authURL: `${authURL}/` }); - expect(client.authURL).toBe(authURL); - }); - - it("should return null for invalid tokens", async () => { - const client = createMcpAuthClient({ - authURL, - fetch: customFetchImpl as typeof fetch, - }); - const session = await client.verifyToken("invalid-token"); - expect(session).toBeNull(); - }); - - it("should return null for network errors in verifyToken", async () => { - const client = createMcpAuthClient({ - authURL: "http://localhost:1/api/auth", - }); - const session = await client.verifyToken("some-token"); - expect(session).toBeNull(); - }); - - it("should return 401 from handler for missing auth header", async () => { - const client = createMcpAuthClient({ - authURL, - fetch: customFetchImpl as typeof fetch, - }); - - const protectedHandler = client.handler(async (_req, session) => { - return Response.json({ userId: session.userId }); - }); - - const response = await protectedHandler( - new Request("http://localhost/mcp"), - ); - - expect(response.status).toBe(401); - const body = await response.json(); - expect(body.jsonrpc).toBe("2.0"); - expect(body.error.code).toBe(-32000); - - const wwwAuth = response.headers.get("WWW-Authenticate"); - expect(wwwAuth).toContain("Bearer"); - expect(wwwAuth).toContain("oauth-protected-resource"); - }); - - it("should return 401 from handler for invalid token", async () => { - const client = createMcpAuthClient({ - authURL, - fetch: customFetchImpl as typeof fetch, - }); - - const protectedHandler = client.handler(async (_req, session) => { - return Response.json({ userId: session.userId }); - }); - - const response = await protectedHandler( - new Request("http://localhost/mcp", { - headers: { Authorization: "Bearer invalid-token" }, - }), - ); - - expect(response.status).toBe(401); - }); - - it("should handle OPTIONS requests for CORS", async () => { - const client = createMcpAuthClient({ authURL }); - - const protectedHandler = client.handler(async () => { - return new Response("ok"); - }); - - const response = await protectedHandler( - new Request("http://localhost/mcp", { method: "OPTIONS" }), - ); - - expect(response.status).toBe(204); - expect(response.headers.get("Access-Control-Allow-Origin")).toBeDefined(); - }); - - it("should use authURL origin as default CORS origin", async () => { - const client = createMcpAuthClient({ - authURL: "http://example.com:3000/api/auth", - }); - - const protectedHandler = client.handler(async () => { - return new Response("ok"); - }); - - const response = await protectedHandler( - new Request("http://localhost/mcp", { method: "OPTIONS" }), - ); - expect(response.headers.get("Access-Control-Allow-Origin")).toBe( - "http://example.com:3000", - ); - }); - - it("should allow custom CORS origins", async () => { - const client = createMcpAuthClient({ - authURL, - allowedOrigin: "https://myapp.com", - }); - - const protectedHandler = client.handler(async () => { - return new Response("ok"); - }); - - const response = await protectedHandler( - new Request("http://localhost/mcp", { method: "OPTIONS" }), - ); - - expect(response.headers.get("Access-Control-Allow-Origin")).toBe( - "https://myapp.com", - ); - }); - }); - - describe("happy path - verifyToken + handler with valid session", () => { - const mockSession: McpSession = { - accessToken: "valid-opaque-token", - refreshToken: "refresh-token", - accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000).toISOString(), - refreshTokenExpiresAt: new Date( - Date.now() + 7 * 24 * 3600 * 1000, - ).toISOString(), - clientId: "test-client-id", - userId: "user-abc-123", - scopes: "openid profile email", - }; - - const mockFetch = (async (input: RequestInfo | URL) => { - const url = typeof input === "string" ? input : (input as Request).url; - if (url.includes("/mcp/get-session")) { - return new Response(JSON.stringify(mockSession), { - status: 200, - headers: { "Content-Type": "application/json" }, - }); - } - return new Response("Not Found", { status: 404 }); - }) as typeof fetch; - - it("should return session for a valid token", async () => { - const client = createMcpAuthClient({ - authURL: "http://mock-auth/api/auth", - fetch: mockFetch, - }); - - const session = await client.verifyToken("valid-opaque-token"); - expect(session).not.toBeNull(); - expect(session?.userId).toBe("user-abc-123"); - expect(session?.scopes).toBe("openid profile email"); - expect(session?.clientId).toBe("test-client-id"); - }); - - it("should pass session to handler for authenticated requests", async () => { - const client = createMcpAuthClient({ - authURL: "http://mock-auth/api/auth", - fetch: mockFetch, - }); - - const protectedHandler = client.handler(async (_req, session) => { - return Response.json({ - userId: session.userId, - scopes: session.scopes, - clientId: session.clientId, - }); - }); - - const response = await protectedHandler( - new Request("http://localhost/mcp", { - method: "POST", - headers: { Authorization: "Bearer valid-opaque-token" }, - }), - ); - - expect(response.status).toBe(200); - const body = await response.json(); - expect(body.userId).toBe("user-abc-123"); - expect(body.scopes).toBe("openid profile email"); - expect(body.clientId).toBe("test-client-id"); - }); - - it("should set mcpSession on req via middleware for valid token", async () => { - const client = createMcpAuthClient({ - authURL: "http://mock-auth/api/auth", - fetch: mockFetch, - }); - const mw = client.middleware(); - - const mockReq = { - headers: { authorization: "Bearer valid-opaque-token" }, - mcpSession: undefined as McpSession | undefined, - }; - const mockRes = { - set: (_key: string, _value: string) => {}, - status: (_code: number) => ({ - json: (_body: unknown) => {}, - }), - }; - - let nextCalled = false; - await mw(mockReq, mockRes, () => { - nextCalled = true; - }); - - expect(nextCalled).toBe(true); - expect(mockReq.mcpSession).toBeDefined(); - expect(mockReq.mcpSession?.userId).toBe("user-abc-123"); - expect(mockReq.mcpSession?.scopes).toBe("openid profile email"); - }); - }); - - describe("discoveryHandler", () => { - it("should proxy OAuth discovery metadata", async () => { - const client = createMcpAuthClient({ - authURL, - fetch: customFetchImpl as typeof fetch, - }); - - const handler = client.discoveryHandler(); - const response = await handler( - new Request("http://localhost/.well-known/oauth-authorization-server"), - ); - - expect(response.status).toBe(200); - const metadata = await response.json(); - expect(metadata.issuer).toBe(baseURL); - expect(metadata.authorization_endpoint).toContain("/mcp/authorize"); - expect(metadata.token_endpoint).toContain("/mcp/token"); - expect(metadata.registration_endpoint).toContain("/mcp/register"); - }); - }); - - describe("protectedResourceHandler", () => { - it("should return protected resource metadata", async () => { - const client = createMcpAuthClient({ authURL }); - - const handler = client.protectedResourceHandler("http://localhost:4000"); - const response = await handler( - new Request("http://localhost/.well-known/oauth-protected-resource"), - ); - - expect(response.status).toBe(200); - const metadata = await response.json(); - expect(metadata.resource).toBe("http://localhost:4000"); - expect(metadata.authorization_servers).toContain(authURL); - expect(metadata.bearer_methods_supported).toContain("header"); - }); - }); - - describe("mcpAuthMcpUse adapter", () => { - it("should create an mcp-use compatible provider", () => { - const provider = mcpAuthMcpUse({ authURL }); - - expect(provider.getIssuer()).toBe(authURL); - expect(provider.getAuthEndpoint()).toBe(`${authURL}/mcp/authorize`); - expect(provider.getTokenEndpoint()).toBe(`${authURL}/mcp/token`); - expect(provider.getMode()).toBe("direct"); - expect(provider.getScopesSupported()).toContain("openid"); - expect(provider.getGrantTypesSupported()).toContain("authorization_code"); - expect(provider.getRegistrationEndpoint?.()).toBe( - `${authURL}/mcp/register`, - ); - }); - - it("should throw for missing authURL", () => { - expect(() => mcpAuthMcpUse({ authURL: "" })).toThrow( - "Better Auth authURL is required", - ); - }); - - it("should reject invalid tokens via verifyToken", async () => { - const provider = mcpAuthMcpUse({ authURL }); - await expect(provider.verifyToken("bad-token")).rejects.toThrow( - "Invalid or expired token", - ); - }); - - it("should extract default user info from payload", () => { - const provider = mcpAuthMcpUse({ authURL }); - const userInfo = provider.getUserInfo({ - userId: "user-123", - scopes: "openid profile", - clientId: "client-abc", - }); - - expect(userInfo.userId).toBe("user-123"); - expect(userInfo.permissions).toEqual(["openid", "profile"]); - expect(userInfo.clientId).toBe("client-abc"); - }); - - it("should support custom getUserInfo", () => { - const provider = mcpAuthMcpUse({ - authURL, - getUserInfo: (payload) => ({ - userId: payload.userId as string, - roles: ["admin"], - email: "custom@test.com", - }), - }); - - const userInfo = provider.getUserInfo({ - userId: "user-456", - scopes: "openid", - }); - - expect(userInfo.userId).toBe("user-456"); - expect(userInfo.roles).toEqual(["admin"]); - expect(userInfo.email).toBe("custom@test.com"); - }); - }); - - describe("middleware", () => { - it("should reject requests without auth header", async () => { - const client = createMcpAuthClient({ - authURL, - fetch: customFetchImpl as typeof fetch, - }); - const mw = client.middleware(); - - let nextCalled = false; - const mockReq = { headers: {} }; - const mockRes = { - set: (_key: string, _value: string) => {}, - status: (code: number) => ({ - json: (body: unknown) => { - mockRes._status = code; - mockRes._body = body; - }, - }), - _headers: {} as Record, - _status: 0, - _body: null as unknown, - }; - mockRes.set = (key: string, value: string) => { - mockRes._headers[key] = value; - }; - - await mw(mockReq, mockRes, () => { - nextCalled = true; - }); - - expect(nextCalled).toBe(false); - expect(mockRes._status).toBe(401); - expect(mockRes._headers["WWW-Authenticate"]).toContain("Bearer"); - }); - - it("should return 401 for invalid token via middleware", async () => { - const client = createMcpAuthClient({ - authURL, - fetch: customFetchImpl as typeof fetch, - }); - const mw = client.middleware(); - - const mockReq = { - headers: { authorization: "Bearer invalid-token-for-test" }, - mcpSession: undefined as McpSession | undefined, - }; - const mockRes = { - set: (_key: string, _value: string) => {}, - status: (code: number) => ({ - json: (body: unknown) => { - mockRes._status = code; - mockRes._body = body; - }, - }), - _headers: {} as Record, - _status: 0, - _body: null as unknown, - }; - mockRes.set = (key: string, value: string) => { - mockRes._headers[key] = value; - }; - - let nextCalled = false; - await mw(mockReq, mockRes, () => { - nextCalled = true; - }); - - expect(nextCalled).toBe(false); - expect(mockRes._status).toBe(401); - }); - }); -}); diff --git a/packages/better-auth/src/plugins/mcp/index.ts b/packages/better-auth/src/plugins/mcp/index.ts deleted file mode 100644 index 18474008ef..0000000000 --- a/packages/better-auth/src/plugins/mcp/index.ts +++ /dev/null @@ -1,1183 +0,0 @@ -import type { - BetterAuthOptions, - BetterAuthPlugin, - GenericEndpointContext, -} from "@better-auth/core"; -import { - createAuthEndpoint, - createAuthMiddleware, -} from "@better-auth/core/api"; -import { isProduction, logger } from "@better-auth/core/env"; -import { safeJSONParse } from "@better-auth/core/utils/json"; -import { isSafeUrlScheme } from "@better-auth/core/utils/url"; -import { getWebcryptoSubtle } from "@better-auth/utils"; -import { base64 } from "@better-auth/utils/base64"; -import { createHash } from "@better-auth/utils/hash"; -import { SignJWT } from "jose"; -import * as z from "zod"; -import { APIError, getSessionFromCtx } from "../../api"; -import { resolveDynamicTrustedProxyHeaders } from "../../context/helpers"; -import { expireCookie, parseSetCookieHeader } from "../../cookies"; -import { constantTimeEqual, generateRandomString } from "../../crypto"; -import { HIDE_METADATA } from "../../utils"; -import { - getBaseURL, - isDynamicBaseURLConfig, - resolveBaseURL, -} from "../../utils/url"; -import { PACKAGE_VERSION } from "../../version"; -import type { - Client, - CodeVerificationValue, - OAuthAccessToken, - OIDCMetadata, - OIDCOptions, -} from "../oidc-provider"; -import { oidcProvider } from "../oidc-provider"; -import { schema } from "../oidc-provider/schema"; -import { parsePrompt } from "../oidc-provider/utils/prompt"; -import { authorizeMCPOAuth } from "./authorize"; - -declare module "@better-auth/core" { - interface BetterAuthPluginRegistry { - mcp: { - creator: typeof mcp; - }; - } -} - -interface MCPOptions { - loginPage: string; - resource?: string | undefined; - oidcConfig?: OIDCOptions | undefined; -} - -export const getMCPProviderMetadata = ( - ctx: GenericEndpointContext, - options?: OIDCOptions | undefined, -): OIDCMetadata => { - const issuer = - typeof ctx.context.options.baseURL === "string" - ? ctx.context.options.baseURL - : ""; - const baseURL = ctx.context.baseURL; - if (!issuer || !baseURL) { - throw new APIError("INTERNAL_SERVER_ERROR", { - error: "invalid_issuer", - error_description: - "issuer or baseURL is not set. If you're the app developer, please make sure to set the `baseURL` in your auth config.", - }); - } - return { - issuer, - authorization_endpoint: `${baseURL}/mcp/authorize`, - token_endpoint: `${baseURL}/mcp/token`, - userinfo_endpoint: `${baseURL}/mcp/userinfo`, - jwks_uri: `${baseURL}/mcp/jwks`, - registration_endpoint: `${baseURL}/mcp/register`, - scopes_supported: ["openid", "profile", "email", "offline_access"], - response_types_supported: ["code"], - response_modes_supported: ["query"], - grant_types_supported: ["authorization_code", "refresh_token"], - acr_values_supported: [ - "urn:mace:incommon:iap:silver", - "urn:mace:incommon:iap:bronze", - ], - subject_types_supported: ["public"], - id_token_signing_alg_values_supported: ["RS256"], - token_endpoint_auth_methods_supported: [ - "client_secret_basic", - "client_secret_post", - "none", - ], - code_challenge_methods_supported: ["S256"], - claims_supported: [ - "sub", - "iss", - "aud", - "exp", - "nbf", - "iat", - "jti", - "email", - "email_verified", - "name", - ], - ...options?.metadata, - }; -}; - -export const getMCPProtectedResourceMetadata = ( - ctx: GenericEndpointContext, - options?: MCPOptions | undefined, -) => { - const baseURL = ctx.context.baseURL; - const origin = new URL(baseURL).origin; - - return { - resource: options?.resource ?? origin, - authorization_servers: [origin], - jwks_uri: options?.oidcConfig?.metadata?.jwks_uri ?? `${baseURL}/mcp/jwks`, - scopes_supported: options?.oidcConfig?.metadata?.scopes_supported ?? [ - "openid", - "profile", - "email", - "offline_access", - ], - bearer_methods_supported: ["header"], - resource_signing_alg_values_supported: ["RS256"], - }; -}; - -const registerMcpClientBodySchema = z.object({ - // This plugin is migrating to @better-auth/oauth-provider (see the deprecation - // notice in docs/plugins/mcp). It gets only the non-breaking guard that rejects - // code-execution schemes here; full https-or-loopback parity comes from - // @better-auth/oauth-provider's SafeUrlSchema, not from tightening this plugin. - redirect_uris: z.array( - z.string().refine(isSafeUrlScheme, { - message: - "redirect_uri cannot use a javascript:, data:, or vbscript: scheme", - }), - ), - token_endpoint_auth_method: z - .enum(["none", "client_secret_basic", "client_secret_post"]) - .default("client_secret_basic") - .optional(), - grant_types: z - .array( - z.enum([ - "authorization_code", - "implicit", - "password", - "client_credentials", - "refresh_token", - "urn:ietf:params:oauth:grant-type:jwt-bearer", - "urn:ietf:params:oauth:grant-type:saml2-bearer", - ]), - ) - .default(["authorization_code"]) - .optional(), - response_types: z - .array(z.enum(["code", "token"])) - .default(["code"]) - .optional(), - client_name: z.string().optional(), - client_uri: z.string().optional(), - logo_uri: z.string().optional(), - scope: z.string().optional(), - contacts: z.array(z.string()).optional(), - tos_uri: z.string().optional(), - policy_uri: z.string().optional(), - jwks_uri: z.string().optional(), - jwks: z.record(z.string(), z.any()).optional(), - metadata: z.record(z.any(), z.any()).optional(), - software_id: z.string().optional(), - software_version: z.string().optional(), - software_statement: z.string().optional(), -}); - -const mcpOAuthTokenBodySchema = z.record(z.any(), z.any()); - -export const mcp = (options: MCPOptions) => { - const opts = { - codeExpiresIn: 600, - defaultScope: "openid", - accessTokenExpiresIn: 3600, - refreshTokenExpiresIn: 604800, - allowPlainCodeChallengeMethod: false, - ...options.oidcConfig, - loginPage: options.loginPage, - scopes: [ - "openid", - "profile", - "email", - "offline_access", - ...(options.oidcConfig?.scopes || []), - ], - }; - const modelName = { - oauthClient: "oauthApplication", - oauthAccessToken: "oauthAccessToken", - oauthConsent: "oauthConsent", - }; - const provider = oidcProvider({ ...opts, __skipDeprecationWarning: true }); - return { - id: "mcp", - version: PACKAGE_VERSION, - hooks: { - after: [ - { - matcher() { - return true; - }, - handler: createAuthMiddleware(async (ctx) => { - const cookie = await ctx.getSignedCookie( - "oidc_login_prompt", - ctx.context.secret, - ); - const cookieName = ctx.context.authCookies.sessionToken.name; - const parsedSetCookieHeader = parseSetCookieHeader( - ctx.context.responseHeaders?.get("set-cookie") || "", - ); - const hasSessionToken = parsedSetCookieHeader.has(cookieName); - if (!cookie || !hasSessionToken) { - return; - } - expireCookie(ctx, { - name: "oidc_login_prompt", - attributes: { path: "/" }, - }); - const sessionCookie = parsedSetCookieHeader.get(cookieName)?.value; - const sessionToken = sessionCookie?.split(".")[0]!; - if (!sessionToken) { - return; - } - const session = - (await ctx.context.internalAdapter.findSession(sessionToken)) || - ctx.context.newSession; - if (!session) { - return; - } - const parsedCookie = safeJSONParse>(cookie); - if (!parsedCookie) { - return; - } - ctx.query = parsedCookie; - - // Remove "login" from prompt since user just logged in - const promptSet = parsePrompt(String(ctx.query?.prompt)); - if (promptSet.has("login")) { - const newPromptSet = new Set(promptSet); - newPromptSet.delete("login"); - ctx.query = { - ...ctx.query, - prompt: Array.from(newPromptSet).join(" "), - }; - } - - ctx.context.session = session; - const response = await authorizeMCPOAuth(ctx, opts); - return response; - }), - }, - ], - }, - endpoints: { - oAuthConsent: provider.endpoints.oAuthConsent, - getMcpOAuthConfig: createAuthEndpoint( - "/.well-known/oauth-authorization-server", - { - method: "GET", - metadata: HIDE_METADATA, - }, - async (c) => { - try { - const metadata = getMCPProviderMetadata(c, options); - return c.json(metadata); - } catch (e) { - console.log(e); - return c.json(null); - } - }, - ), - getMCPProtectedResource: createAuthEndpoint( - "/.well-known/oauth-protected-resource", - { - method: "GET", - metadata: HIDE_METADATA, - }, - async (c) => { - const metadata = getMCPProtectedResourceMetadata(c, options); - return c.json(metadata); - }, - ), - mcpOAuthAuthorize: createAuthEndpoint( - "/mcp/authorize", - { - method: "GET", - query: z.record(z.string(), z.any()), - metadata: { - openapi: { - description: "Authorize an OAuth2 request using MCP", - responses: { - "200": { - description: "Authorization response generated successfully", - content: { - "application/json": { - schema: { - type: "object", - additionalProperties: true, - description: - "Authorization response, contents depend on the authorize function implementation", - }, - }, - }, - }, - }, - }, - }, - }, - async (ctx) => { - return authorizeMCPOAuth(ctx, opts); - }, - ), - mcpOAuthToken: createAuthEndpoint( - "/mcp/token", - { - method: "POST", - body: mcpOAuthTokenBodySchema, - metadata: { - ...HIDE_METADATA, - allowedMediaTypes: [ - "application/x-www-form-urlencoded", - "application/json", - ], - }, - }, - async (ctx) => { - let { body } = ctx; - if (!body) { - throw ctx.error("BAD_REQUEST", { - error_description: "request body not found", - error: "invalid_request", - }); - } - if (body instanceof FormData) { - body = Object.fromEntries(body.entries()); - } - if (!(body instanceof Object)) { - throw new APIError("BAD_REQUEST", { - error_description: "request body is not an object", - error: "invalid_request", - }); - } - let { client_id, client_secret } = body; - const authorization = - ctx.request?.headers.get("authorization") || null; - if ( - authorization && - !client_secret && - authorization.startsWith("Basic ") - ) { - let decoded: string; - try { - const encoded = authorization.replace("Basic ", ""); - decoded = new TextDecoder().decode(base64.decode(encoded)); - } catch { - throw new APIError("UNAUTHORIZED", { - error_description: "invalid authorization header format", - error: "invalid_client", - }); - } - // RFC 6749 §2.3.1: split on the first `:` (the secret may contain - // further colons), then percent-decode each half before comparing - // against stored credentials (the client encodes reserved - // characters per RFC 3986 before base64). - const colonIndex = decoded.indexOf(":"); - if (colonIndex === -1) { - throw new APIError("UNAUTHORIZED", { - error_description: "invalid authorization header format", - error: "invalid_client", - }); - } - let id: string; - let secret: string; - try { - id = decodeURIComponent(decoded.slice(0, colonIndex)); - secret = decodeURIComponent(decoded.slice(colonIndex + 1)); - } catch { - throw new APIError("UNAUTHORIZED", { - error_description: "invalid authorization header format", - error: "invalid_client", - }); - } - if (!id || !secret) { - throw new APIError("UNAUTHORIZED", { - error_description: "invalid authorization header format", - error: "invalid_client", - }); - } - if (client_id && client_id.toString() !== id) { - throw new APIError("UNAUTHORIZED", { - error_description: - "client_id in body does not match Authorization header", - error: "invalid_client", - }); - } - client_id = id; - client_secret = secret; - } - const { - grant_type, - code, - redirect_uri, - refresh_token, - code_verifier, - } = body; - if (grant_type === "refresh_token") { - if (!refresh_token) { - throw new APIError("BAD_REQUEST", { - error_description: "refresh_token is required", - error: "invalid_request", - }); - } - const token = await ctx.context.adapter.findOne({ - model: "oauthAccessToken", - where: [ - { - field: "refreshToken", - value: refresh_token.toString(), - }, - ], - }); - if (!token) { - throw new APIError("UNAUTHORIZED", { - error_description: "invalid refresh token", - error: "invalid_grant", - }); - } - if (token.clientId !== client_id?.toString()) { - throw new APIError("UNAUTHORIZED", { - error_description: "invalid client_id", - error: "invalid_client", - }); - } - if (token.refreshTokenExpiresAt < new Date()) { - throw new APIError("UNAUTHORIZED", { - error_description: "refresh token expired", - error: "invalid_grant", - }); - } - // A refresh token is only legitimate when the original grant - // included offline_access. Rejecting redemption otherwise makes a - // refresh token that was stored without offline_access (and may be - // exposed to its access-token holder) unusable for escalation. - if (!token.scopes?.split(" ").includes("offline_access")) { - throw new APIError("UNAUTHORIZED", { - error_description: - "refresh token was not issued for the offline_access scope", - error: "invalid_grant", - }); - } - const refreshClient = await ctx.context.adapter - .findOne>({ - model: modelName.oauthClient, - where: [{ field: "clientId", value: client_id.toString() }], - }) - .then((res) => { - if (!res) { - return null; - } - return { - ...res, - redirectUrls: res.redirectUrls.split(","), - metadata: res.metadata ? JSON.parse(res.metadata) : {}, - } as Client; - }); - if (!refreshClient) { - throw new APIError("UNAUTHORIZED", { - error_description: "invalid client_id", - error: "invalid_client", - }); - } - if (refreshClient.disabled) { - throw new APIError("UNAUTHORIZED", { - error_description: "client is disabled", - error: "invalid_client", - }); - } - if (refreshClient.type !== "public") { - if (!refreshClient.clientSecret || !client_secret) { - throw new APIError("UNAUTHORIZED", { - error_description: - "client_secret is required for confidential clients", - error: "invalid_client", - }); - } - const isValidSecret = constantTimeEqual( - refreshClient.clientSecret, - client_secret.toString(), - ); - if (!isValidSecret) { - throw new APIError("UNAUTHORIZED", { - error_description: "invalid client_secret", - error: "invalid_client", - }); - } - } - const accessToken = generateRandomString(32, "a-z", "A-Z"); - const newRefreshToken = generateRandomString(32, "a-z", "A-Z"); - const accessTokenExpiresAt = new Date( - Date.now() + opts.accessTokenExpiresIn * 1000, - ); - const refreshTokenExpiresAt = new Date( - Date.now() + opts.refreshTokenExpiresIn * 1000, - ); - await ctx.context.adapter.create({ - model: modelName.oauthAccessToken, - data: { - accessToken, - refreshToken: newRefreshToken, - accessTokenExpiresAt, - refreshTokenExpiresAt, - clientId: client_id.toString(), - userId: token.userId, - scopes: token.scopes, - createdAt: new Date(), - updatedAt: new Date(), - }, - }); - return ctx.json({ - access_token: accessToken, - token_type: "bearer", - expires_in: opts.accessTokenExpiresIn, - refresh_token: newRefreshToken, - scope: token.scopes, - }); - } - - if (!code) { - throw new APIError("BAD_REQUEST", { - error_description: "code is required", - error: "invalid_request", - }); - } - - if (opts.requirePKCE && !code_verifier) { - throw new APIError("BAD_REQUEST", { - error_description: "code verifier is missing", - error: "invalid_request", - }); - } - - // Atomic single-use redemption per RFC 6749 §4.1.2. The first - // caller receives the row; concurrent racers receive `null` - // and fall through to the `invalid_grant` error path. - // - // TODO(legacy-hardening-coordinate): in-flight follow-ups at - // https://github.com/better-auth/better-auth/security/advisories/GHSA-9h47-pqcx-hjr4 - // and https://github.com/better-auth/better-auth/security/advisories/GHSA-pw9m-5jxm-xr6h - // touch this same surface. Whoever lands second must rebase - // to keep the atomic consume + `invalid_grant` semantics in - // place; do not regress to a `findVerificationValue` + - // delete pair. - const verificationValue = - await ctx.context.internalAdapter.consumeVerificationValue( - code.toString(), - ); - if (!verificationValue) { - throw new APIError("UNAUTHORIZED", { - error_description: "invalid code", - error: "invalid_grant", - }); - } - - if (!client_id) { - throw new APIError("UNAUTHORIZED", { - error_description: "client_id is required", - error: "invalid_client", - }); - } - if (!grant_type) { - throw new APIError("BAD_REQUEST", { - error_description: "grant_type is required", - error: "invalid_request", - }); - } - if (grant_type !== "authorization_code") { - throw new APIError("BAD_REQUEST", { - error_description: "grant_type must be 'authorization_code'", - error: "unsupported_grant_type", - }); - } - - if (!redirect_uri) { - throw new APIError("BAD_REQUEST", { - error_description: "redirect_uri is required", - error: "invalid_request", - }); - } - - const client = await ctx.context.adapter - .findOne>({ - model: modelName.oauthClient, - where: [{ field: "clientId", value: client_id.toString() }], - }) - .then((res) => { - if (!res) { - return null; - } - return { - ...res, - redirectUrls: res.redirectUrls.split(","), - metadata: res.metadata ? JSON.parse(res.metadata) : {}, - } as Client; - }); - if (!client) { - throw new APIError("UNAUTHORIZED", { - error_description: "invalid client_id", - error: "invalid_client", - }); - } - if (client.disabled) { - throw new APIError("UNAUTHORIZED", { - error_description: "client is disabled", - error: "invalid_client", - }); - } - // For public clients (type: 'public'), validate PKCE instead of client_secret - if (client.type === "public") { - // Public clients must use PKCE - if (!code_verifier) { - throw new APIError("BAD_REQUEST", { - error_description: - "code verifier is required for public clients", - error: "invalid_request", - }); - } - // PKCE validation happens later in the flow, so we skip client_secret validation - } else { - // For confidential clients, validate client_secret - if (!client.clientSecret || !client_secret) { - throw new APIError("UNAUTHORIZED", { - error_description: - "client_secret is required for confidential clients", - error: "invalid_client", - }); - } - const isValidSecret = constantTimeEqual( - client.clientSecret, - client_secret.toString(), - ); - if (!isValidSecret) { - throw new APIError("UNAUTHORIZED", { - error_description: "invalid client_secret", - error: "invalid_client", - }); - } - } - const value = JSON.parse( - verificationValue.value, - ) as CodeVerificationValue; - if (value.clientId !== client_id.toString()) { - throw new APIError("UNAUTHORIZED", { - error_description: "invalid client_id", - error: "invalid_client", - }); - } - if (value.redirectURI !== redirect_uri.toString()) { - throw new APIError("UNAUTHORIZED", { - error_description: "invalid redirect_uri", - error: "invalid_client", - }); - } - if (value.codeChallenge && !code_verifier) { - throw new APIError("BAD_REQUEST", { - error_description: "code verifier is missing", - error: "invalid_request", - }); - } - - if (value.codeChallenge) { - const challenge = - value.codeChallengeMethod === "plain" - ? code_verifier - : await createHash("SHA-256", "base64urlnopad").digest( - code_verifier, - ); - - if (challenge !== value.codeChallenge) { - throw new APIError("UNAUTHORIZED", { - error_description: "code verification failed", - error: "invalid_request", - }); - } - } - - const requestedScopes = value.scope; - const accessToken = generateRandomString(32, "a-z", "A-Z"); - const refreshToken = generateRandomString(32, "A-Z", "a-z"); - const accessTokenExpiresAt = new Date( - Date.now() + opts.accessTokenExpiresIn * 1000, - ); - const refreshTokenExpiresAt = new Date( - Date.now() + opts.refreshTokenExpiresIn * 1000, - ); - await ctx.context.adapter.create({ - model: modelName.oauthAccessToken, - data: { - accessToken, - refreshToken, - accessTokenExpiresAt, - refreshTokenExpiresAt, - clientId: client_id.toString(), - userId: value.userId, - scopes: requestedScopes.join(" "), - createdAt: new Date(), - updatedAt: new Date(), - }, - }); - const user = await ctx.context.internalAdapter.findUserById( - value.userId, - ); - if (!user) { - throw new APIError("UNAUTHORIZED", { - error_description: "user not found", - error: "invalid_grant", - }); - } - const secretKey = { - alg: "HS256", - key: await getWebcryptoSubtle().generateKey( - { - name: "HMAC", - hash: "SHA-256", - }, - true, - ["sign", "verify"], - ), - }; - const profile = { - given_name: user.name.split(" ")[0]!, - family_name: user.name.split(" ")[1]!, - name: user.name, - profile: user.image, - updated_at: Math.floor(new Date(user.updatedAt).getTime() / 1000), - }; - const email = { - email: user.email, - email_verified: user.emailVerified, - }; - const userClaims = { - ...(requestedScopes.includes("profile") ? profile : {}), - ...(requestedScopes.includes("email") ? email : {}), - }; - - const additionalUserClaims = opts.getAdditionalUserInfoClaim - ? await opts.getAdditionalUserInfoClaim( - user, - requestedScopes, - client, - ) - : {}; - - const idToken = await new SignJWT({ - sub: user.id, - aud: client_id.toString(), - iat: Date.now(), - auth_time: ctx.context.session - ? new Date(ctx.context.session.session.createdAt).getTime() - : undefined, - nonce: value.nonce, - acr: "urn:mace:incommon:iap:silver", // default to silver - ⚠︎ this should be configurable and should be validated against the client's metadata - ...userClaims, - ...additionalUserClaims, - }) - .setProtectedHeader({ alg: secretKey.alg }) - .setIssuedAt() - .setExpirationTime( - Math.floor(Date.now() / 1000) + opts.accessTokenExpiresIn, - ) - .sign(secretKey.key); - return ctx.json( - { - access_token: accessToken, - token_type: "Bearer", - expires_in: opts.accessTokenExpiresIn, - refresh_token: requestedScopes.includes("offline_access") - ? refreshToken - : undefined, - scope: requestedScopes.join(" "), - id_token: requestedScopes.includes("openid") - ? idToken - : undefined, - }, - { - headers: { - "Cache-Control": "no-store", - Pragma: "no-cache", - }, - }, - ); - }, - ), - registerMcpClient: createAuthEndpoint( - "/mcp/register", - { - method: "POST", - body: registerMcpClientBodySchema, - metadata: { - openapi: { - description: "Register an OAuth2 application", - responses: { - "200": { - description: "OAuth2 application registered successfully", - content: { - "application/json": { - schema: { - type: "object", - properties: { - name: { - type: "string", - description: "Name of the OAuth2 application", - }, - icon: { - type: "string", - nullable: true, - description: "Icon URL for the application", - }, - metadata: { - type: "object", - additionalProperties: true, - nullable: true, - description: - "Additional metadata for the application", - }, - clientId: { - type: "string", - description: "Unique identifier for the client", - }, - clientSecret: { - type: "string", - description: - "Secret key for the client. Not included for public clients.", - }, - redirectUrls: { - type: "array", - items: { type: "string", format: "uri" }, - description: "List of allowed redirect URLs", - }, - type: { - type: "string", - description: "Type of the client", - enum: ["web", "public"], - }, - authenticationScheme: { - type: "string", - description: - "Authentication scheme used by the client", - enum: ["client_secret", "none"], - }, - disabled: { - type: "boolean", - description: "Whether the client is disabled", - enum: [false], - }, - userId: { - type: "string", - nullable: true, - description: - "ID of the user who registered the client, null if registered anonymously", - }, - createdAt: { - type: "string", - format: "date-time", - description: "Creation timestamp", - }, - updatedAt: { - type: "string", - format: "date-time", - description: "Last update timestamp", - }, - }, - required: [ - "name", - "clientId", - "redirectUrls", - "type", - "authenticationScheme", - "disabled", - "createdAt", - "updatedAt", - ], - }, - }, - }, - }, - }, - }, - }, - }, - async (ctx) => { - const body = ctx.body; - const session = await getSessionFromCtx(ctx); - ctx.setHeader("Access-Control-Allow-Origin", "*"); - ctx.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS"); - ctx.setHeader( - "Access-Control-Allow-Headers", - "Content-Type, Authorization", - ); - ctx.setHeader("Access-Control-Max-Age", "86400"); - ctx.headers?.set("Access-Control-Max-Age", "86400"); - if ( - (!body.grant_types || - body.grant_types.includes("authorization_code") || - body.grant_types.includes("implicit")) && - (!body.redirect_uris || body.redirect_uris.length === 0) - ) { - throw new APIError("BAD_REQUEST", { - error: "invalid_redirect_uri", - error_description: - "Redirect URIs are required for authorization_code and implicit grant types", - }); - } - - if (body.grant_types && body.response_types) { - if ( - body.grant_types.includes("authorization_code") && - !body.response_types.includes("code") - ) { - throw new APIError("BAD_REQUEST", { - error: "invalid_client_metadata", - error_description: - "When 'authorization_code' grant type is used, 'code' response type must be included", - }); - } - if ( - body.grant_types.includes("implicit") && - !body.response_types.includes("token") - ) { - throw new APIError("BAD_REQUEST", { - error: "invalid_client_metadata", - error_description: - "When 'implicit' grant type is used, 'token' response type must be included", - }); - } - } - - const clientId = - opts.generateClientId?.() || generateRandomString(32, "a-z", "A-Z"); - const clientSecret = - opts.generateClientSecret?.() || - generateRandomString(32, "a-z", "A-Z"); - - // Determine client type based on auth method - const clientType = - body.token_endpoint_auth_method === "none" ? "public" : "web"; - const finalClientSecret = clientType === "public" ? "" : clientSecret; - - await ctx.context.adapter.create({ - model: modelName.oauthClient, - data: { - name: body.client_name, - icon: body.logo_uri, - metadata: body.metadata ? JSON.stringify(body.metadata) : null, - clientId: clientId, - clientSecret: finalClientSecret, - redirectUrls: body.redirect_uris.join(","), - type: clientType, - authenticationScheme: - body.token_endpoint_auth_method || "client_secret_basic", - disabled: false, - userId: session?.session.userId, - createdAt: new Date(), - updatedAt: new Date(), - }, - }); - - const responseData = { - client_id: clientId, - client_id_issued_at: Math.floor(Date.now() / 1000), - redirect_uris: body.redirect_uris, - token_endpoint_auth_method: - body.token_endpoint_auth_method || "client_secret_basic", - grant_types: body.grant_types || ["authorization_code"], - response_types: body.response_types || ["code"], - client_name: body.client_name, - client_uri: body.client_uri, - logo_uri: body.logo_uri, - scope: body.scope, - contacts: body.contacts, - tos_uri: body.tos_uri, - policy_uri: body.policy_uri, - jwks_uri: body.jwks_uri, - jwks: body.jwks, - software_id: body.software_id, - software_version: body.software_version, - software_statement: body.software_statement, - metadata: body.metadata, - ...(clientType !== "public" - ? { - client_secret: finalClientSecret, - client_secret_expires_at: 0, // 0 means it doesn't expire - } - : {}), - }; - - return new Response(JSON.stringify(responseData), { - status: 201, - headers: { - "Content-Type": "application/json", - "Cache-Control": "no-store", - Pragma: "no-cache", - }, - }); - }, - ), - getMcpSession: createAuthEndpoint( - "/mcp/get-session", - { - method: "GET", - requireHeaders: true, - }, - async (c) => { - const accessToken = c.headers - ?.get("Authorization") - ?.replace("Bearer ", ""); - if (!accessToken) { - c.headers?.set("WWW-Authenticate", "Bearer"); - return c.json(null); - } - const accessTokenData = - await c.context.adapter.findOne({ - model: modelName.oauthAccessToken, - where: [ - { - field: "accessToken", - value: accessToken, - }, - ], - }); - if (!accessTokenData) { - return c.json(null); - } - // An access token authorizes a protected resource only while it is - // unexpired. Without this gate an expired bearer token keeps - // authorizing handlers until its row is deleted (RFC 6750 §3.1 - // treats an expired token as `invalid_token`). - if (accessTokenData.accessTokenExpiresAt < new Date()) { - c.headers?.set("WWW-Authenticate", "Bearer"); - return c.json(null); - } - return c.json(accessTokenData); - }, - ), - }, - schema, - options, - } satisfies BetterAuthPlugin; -}; - -export const withMcpAuth = < - Auth extends { - api: { - getMcpSession: (...args: any) => Promise; - }; - options: BetterAuthOptions; - }, ->( - auth: Auth, - handler: ( - req: Request, - session: OAuthAccessToken, - ) => Response | Promise, -) => { - return async (req: Request) => { - const basePath = auth.options.basePath || "/api/auth"; - const trustedProxyHeaders = resolveDynamicTrustedProxyHeaders(auth.options); - const baseURL = isDynamicBaseURLConfig(auth.options.baseURL) - ? resolveBaseURL( - auth.options.baseURL, - basePath, - req, - undefined, - trustedProxyHeaders, - ) - : getBaseURL( - typeof auth.options.baseURL === "string" - ? auth.options.baseURL - : undefined, - basePath, - ); - if (!baseURL && !isProduction) { - logger.warn("Unable to get the baseURL, please check your config!"); - } - const session = await auth.api.getMcpSession({ - request: req, - headers: req.headers, - asResponse: false, - }); - // Omit the `resource_metadata` URL when we can't build a valid one, - // so clients don't follow `Bearer resource_metadata="undefined/..."`. - const wwwAuthenticateValue = baseURL - ? `Bearer resource_metadata="${baseURL}/.well-known/oauth-protected-resource"` - : "Bearer"; - if (!session) { - return Response.json( - { - jsonrpc: "2.0", - error: { - code: -32000, - message: "Unauthorized: Authentication required", - "www-authenticate": wwwAuthenticateValue, - }, - id: null, - }, - { - status: 401, - headers: { - "WWW-Authenticate": wwwAuthenticateValue, - // we also add this headers otherwise browser based clients will not be able to read the `www-authenticate` header - "Access-Control-Expose-Headers": "WWW-Authenticate", - }, - }, - ); - } - return handler(req, session); - }; -}; - -export const oAuthDiscoveryMetadata = < - Auth extends { - api: { - getMcpOAuthConfig: (...args: any) => any; - }; - }, ->( - auth: Auth, -) => { - return async (request: Request) => { - const res = await auth.api.getMcpOAuthConfig({ - request, - asResponse: false, - }); - return new Response(JSON.stringify(res), { - status: 200, - headers: { - "Content-Type": "application/json", - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization", - "Access-Control-Max-Age": "86400", - }, - }); - }; -}; - -export const oAuthProtectedResourceMetadata = < - Auth extends { - api: { - getMCPProtectedResource: (...args: any) => any; - }; - }, ->( - auth: Auth, -) => { - return async (request: Request) => { - const res = await auth.api.getMCPProtectedResource({ - request, - asResponse: false, - }); - return new Response(JSON.stringify(res), { - status: 200, - headers: { - "Content-Type": "application/json", - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "POST, OPTIONS", - "Access-Control-Allow-Headers": "Content-Type, Authorization", - "Access-Control-Max-Age": "86400", - }, - }); - }; -}; diff --git a/packages/better-auth/src/plugins/mcp/mcp.test.ts b/packages/better-auth/src/plugins/mcp/mcp.test.ts deleted file mode 100644 index 8d23119e6a..0000000000 --- a/packages/better-auth/src/plugins/mcp/mcp.test.ts +++ /dev/null @@ -1,1316 +0,0 @@ -import { listen } from "listhen"; -import { afterAll, describe, expect, it } from "vitest"; -import { createAuthClient } from "../../client"; -import { toNodeHandler } from "../../integrations/node"; -import { getTestInstance } from "../../test-utils/test-instance"; -import { genericOAuth } from "../generic-oauth"; -import { jwt } from "../jwt"; -import type { Client } from "../oidc-provider/types"; -import { mcp, withMcpAuth } from "."; - -// Pre-verifies any user the RP creates via OAuth signup so the existing-user -// path on the RP side does not trip the local-emailVerified gate. -const autoVerifyUserHook = { - user: { - create: { - before: async (user: Record) => ({ - data: { ...user, emailVerified: true }, - }), - }, - }, -} as const; - -describe("mcp", async () => { - // Start server on ephemeral port first to get available port - const tempServer = await listen( - toNodeHandler(async () => new Response("temp")), - { - port: 0, - }, - ); - const port = tempServer.address?.port || 3001; - const baseURL = `http://localhost:${port}`; - await tempServer.close(); - - const { auth, signInWithTestUser, customFetchImpl } = await getTestInstance({ - baseURL, - plugins: [ - mcp({ - loginPage: "/login", - oidcConfig: { - loginPage: "/login", - consentPage: "/oauth/consent", - requirePKCE: true, - - getAdditionalUserInfoClaim(user, scopes, client) { - return { - custom: "custom value", - userId: user.id, - }; - }, - }, - }), - jwt(), - ], - }); - - const signInResult = await signInWithTestUser(); - const headers = signInResult.headers; - - const serverClient = createAuthClient({ - baseURL, - fetchOptions: { - customFetchImpl, - headers, - }, - }); - - const server = await listen(toNodeHandler(auth.handler), { - port, - }); - afterAll(async () => { - await server.close(); - }); - - let publicClient: Client; - let confidentialClient: Client; - - it("should register public client with token_endpoint_auth_method: none", async ({ - expect, - }) => { - const createdClient = await serverClient.$fetch("/mcp/register", { - method: "POST", - body: { - client_name: "test-public-client", - redirect_uris: ["http://localhost:3000/api/auth/callback/test-public"], - logo_uri: "", - token_endpoint_auth_method: "none", - }, - onResponse(context) { - expect(context.response.status).toBe(201); - expect(context.response.headers.get("Content-Type")).toBe( - "application/json", - ); - }, - }); - - expect(createdClient.data).toMatchObject({ - client_id: expect.any(String), - client_name: "test-public-client", - logo_uri: "", - redirect_uris: ["http://localhost:3000/api/auth/callback/test-public"], - grant_types: ["authorization_code"], - response_types: ["code"], - token_endpoint_auth_method: "none", - client_id_issued_at: expect.any(Number), - }); - - // Public clients should NOT receive client_secret or client_secret_expires_at - expect(createdClient.data).not.toHaveProperty("client_secret"); - expect(createdClient.data).not.toHaveProperty("client_secret_expires_at"); - - publicClient = { - clientId: (createdClient.data as any).client_id, - clientSecret: "", // Public clients don't have secrets, but our type expects a string - redirectUrls: (createdClient.data as any).redirect_uris, - metadata: {}, - icon: (createdClient.data as any).logo_uri || "", - type: "public", - disabled: false, - name: (createdClient.data as any).client_name || "", - }; - }); - - it("should register confidential client with client_secret_basic", async ({ - expect, - }) => { - const createdClient = await serverClient.$fetch("/mcp/register", { - method: "POST", - body: { - client_name: "test-confidential-client", - redirect_uris: [ - "http://localhost:3000/api/auth/callback/test-confidential", - ], - logo_uri: "", - token_endpoint_auth_method: "client_secret_basic", - }, - }); - - expect(createdClient.data).toMatchObject({ - client_id: expect.any(String), - client_secret: expect.any(String), - client_name: "test-confidential-client", - logo_uri: "", - redirect_uris: [ - "http://localhost:3000/api/auth/callback/test-confidential", - ], - grant_types: ["authorization_code"], - response_types: ["code"], - token_endpoint_auth_method: "client_secret_basic", - client_id_issued_at: expect.any(Number), - client_secret_expires_at: 0, - }); - - // Confidential clients should receive client_secret and client_secret_expires_at - expect(createdClient.data).toHaveProperty("client_secret"); - expect(createdClient.data).toHaveProperty("client_secret_expires_at"); - - confidentialClient = { - clientId: (createdClient.data as any).client_id, - clientSecret: (createdClient.data as any).client_secret, - redirectUrls: (createdClient.data as any).redirect_uris, - metadata: {}, - icon: (createdClient.data as any).logo_uri || "", - type: "web", - disabled: false, - name: (createdClient.data as any).client_name || "", - }; - }); - - it("should authenticate public client with PKCE only", async ({ expect }) => { - const { customFetchImpl: customFetchImplRP, cookieSetter } = - await getTestInstance({ - account: { - accountLinking: { - trustedProviders: ["test-public"], - }, - }, - databaseHooks: autoVerifyUserHook, - plugins: [ - genericOAuth({ - config: [ - { - providerId: "test-public", - clientId: publicClient.clientId, - clientSecret: "", // Public client has no secret - authorizationUrl: `${baseURL}/api/auth/mcp/authorize`, - tokenUrl: `${baseURL}/api/auth/mcp/token`, - scopes: ["openid", "profile", "email"], - pkce: true, - }, - ], - }), - ], - }); - - const client = createAuthClient({ - baseURL: "http://localhost:5001", - fetchOptions: { - customFetchImpl: customFetchImplRP, - }, - }); - const oAuthHeaders = new Headers(); - const data = await client.signIn.social( - { - provider: "test-public", - callbackURL: "/dashboard", - }, - { - throw: true, - onSuccess: cookieSetter(oAuthHeaders), - }, - ); - - expect(data.url).toContain(`${baseURL}/api/auth/mcp/authorize`); - expect(data.url).toContain(`client_id=${publicClient.clientId}`); - expect(data.url).toContain("code_challenge="); - expect(data.url).toContain("code_challenge_method=S256"); - - let redirectURI = ""; - await serverClient.$fetch(data.url!, { - method: "GET", - onError(context: any) { - redirectURI = context.response.headers.get("Location") || ""; - }, - }); - expect(redirectURI).toContain( - "http://localhost:3000/api/auth/callback/test-public?code=", - ); - - let callbackURL = ""; - await client.$fetch(redirectURI, { - headers: oAuthHeaders, - onError(context: any) { - callbackURL = context.response.headers.get("Location") || ""; - }, - }); - expect(callbackURL).toContain("/dashboard"); - }); - - it("should reject public client without code_verifier", async ({ - expect, - }) => { - // Create a mock token request without code_verifier - const authCode = "test-auth-code"; - - const result = await serverClient.$fetch("/mcp/token", { - method: "POST", - body: { - grant_type: "authorization_code", - client_id: publicClient.clientId, - code: authCode, - redirect_uri: publicClient.redirectUrls[0], - // Missing code_verifier for public client - }, - }); - - expect(result.error).toBeTruthy(); - expect((result.error as any).error).toBe("invalid_request"); - expect((result.error as any).error_description).toContain( - "code verifier is missing", - ); - }); - - it("should still support confidential clients in MCP context", async ({ - expect, - }) => { - const { customFetchImpl: customFetchImplRP, cookieSetter } = - await getTestInstance({ - account: { - accountLinking: { - trustedProviders: ["test-confidential"], - }, - }, - databaseHooks: autoVerifyUserHook, - plugins: [ - genericOAuth({ - config: [ - { - providerId: "test-confidential", - clientId: confidentialClient.clientId, - clientSecret: confidentialClient.clientSecret || "", - authorizationUrl: `${baseURL}/api/auth/mcp/authorize`, - tokenUrl: `${baseURL}/api/auth/mcp/token`, - scopes: ["openid", "profile", "email"], - pkce: true, - }, - ], - }), - ], - }); - const oAuthHeaders = new Headers(); - const client = createAuthClient({ - baseURL: "http://localhost:5001", - fetchOptions: { - customFetchImpl: customFetchImplRP, - }, - }); - - const data = await client.signIn.social( - { - provider: "test-confidential", - callbackURL: "/dashboard", - }, - { - throw: true, - onSuccess: cookieSetter(oAuthHeaders), - }, - ); - - expect(data.url).toContain(`${baseURL}/api/auth/mcp/authorize`); - expect(data.url).toContain(`client_id=${confidentialClient.clientId}`); - - let redirectURI = ""; - await serverClient.$fetch(data.url!, { - method: "GET", - onError(context: any) { - redirectURI = context.response.headers.get("Location") || ""; - }, - }); - expect(redirectURI).toContain( - "http://localhost:3000/api/auth/callback/test-confidential?code=", - ); - - let callbackURL = ""; - await client.$fetch(redirectURI, { - headers: oAuthHeaders, - onError(context: any) { - callbackURL = context.response.headers.get("Location") || ""; - }, - }); - expect(callbackURL).toContain("/dashboard"); - }); - - // FIXME(mcp-race-coverage): write a `/mcp/token` race-redemption - // regression test once the consent + PKCE harness here can hand us a code - // without going through genericOAuth. The `/mcp/token` handler calls the - // same `internalAdapter.consumeVerificationValue` primitive that - // `@better-auth/oauth-provider` and `better-auth`'s `oidc-provider` plugin - // use, both of which already carry the race regression test, so a - // regression in the primitive surfaces in those tests today. - it.skip("rejects concurrent redemption of the same authorization code", async ({ - expect, - }) => { - const { customFetchImpl: customFetchImplRP, cookieSetter } = - await getTestInstance({ - account: { - accountLinking: { - trustedProviders: ["test-confidential"], - }, - }, - plugins: [ - genericOAuth({ - config: [ - { - providerId: "test-confidential", - clientId: confidentialClient.clientId, - clientSecret: confidentialClient.clientSecret || "", - authorizationUrl: `${baseURL}/api/auth/mcp/authorize`, - tokenUrl: `${baseURL}/api/auth/mcp/token`, - scopes: ["openid", "profile", "email"], - // Confidential client authenticates via client_secret; - // no PKCE so we can replay the issued code at the - // token endpoint without a stored verifier. - pkce: false, - }, - ], - }), - ], - }); - const oAuthHeaders = new Headers(); - const client = createAuthClient({ - baseURL: "http://localhost:5004", - fetchOptions: { customFetchImpl: customFetchImplRP }, - }); - - const data = await client.signIn.social( - { provider: "test-confidential", callbackURL: "/dashboard" }, - { throw: true, onSuccess: cookieSetter(oAuthHeaders) }, - ); - - let redirectURI = ""; - await serverClient.$fetch(data.url!, { - method: "GET", - onError(context: any) { - redirectURI = context.response.headers.get("Location") || ""; - }, - }); - const code = new URL(redirectURI).searchParams.get("code"); - expect(code).toBeTruthy(); - - // `redirect_uri` matches what the auth-code flow recorded for the - // client; PKCE is verified server-side from the stored verifier. - const redirectUri = confidentialClient.redirectUrls[0]!; - const exchange = () => - serverClient.$fetch<{ access_token?: string; error?: string }>( - "/mcp/token", - { - method: "POST", - body: { - grant_type: "authorization_code", - client_id: confidentialClient.clientId, - client_secret: confidentialClient.clientSecret, - code, - redirect_uri: redirectUri, - }, - }, - ); - - const [first, second] = await Promise.all([exchange(), exchange()]); - const successes = [first, second].filter( - (r) => (r.data as any)?.access_token != null, - ); - const failures = [first, second].filter((r) => r.error != null); - expect(successes).toHaveLength(1); - expect(failures).toHaveLength(1); - expect((failures[0]!.error as any).error).toBe("invalid_grant"); - }); - - it("should expose OAuth discovery metadata", async ({ expect }) => { - const metadata = await serverClient.$fetch( - "/.well-known/oauth-authorization-server", - ); - - expect(metadata.data).toMatchObject({ - issuer: baseURL, - authorization_endpoint: `${baseURL}/api/auth/mcp/authorize`, - token_endpoint: `${baseURL}/api/auth/mcp/token`, - userinfo_endpoint: `${baseURL}/api/auth/mcp/userinfo`, - jwks_uri: `${baseURL}/api/auth/mcp/jwks`, - registration_endpoint: `${baseURL}/api/auth/mcp/register`, - scopes_supported: ["openid", "profile", "email", "offline_access"], - response_types_supported: ["code"], - response_modes_supported: ["query"], - grant_types_supported: ["authorization_code", "refresh_token"], - subject_types_supported: ["public"], - id_token_signing_alg_values_supported: ["RS256"], - token_endpoint_auth_methods_supported: [ - "client_secret_basic", - "client_secret_post", - "none", - ], - code_challenge_methods_supported: ["S256"], - claims_supported: [ - "sub", - "iss", - "aud", - "exp", - "nbf", - "iat", - "jti", - "email", - "email_verified", - "name", - ], - }); - }); - - it("should expose OAuth protected resource metadata", async ({ expect }) => { - const metadata = await serverClient.$fetch( - "/.well-known/oauth-protected-resource", - ); - const origin = new URL(baseURL).origin; - - expect(metadata.data).toMatchObject({ - resource: origin, - authorization_servers: [origin], - jwks_uri: `${baseURL}/api/auth/mcp/jwks`, - scopes_supported: ["openid", "profile", "email", "offline_access"], - bearer_methods_supported: ["header"], - resource_signing_alg_values_supported: ["RS256"], - }); - }); - - it("should handle token refresh flow", async ({ expect }) => { - // Create a confidential client for easier testing (avoids PKCE complexity) - const createdClient = await serverClient.$fetch("/mcp/register", { - method: "POST", - body: { - client_name: "test-refresh-client", - redirect_uris: ["http://localhost:3000/api/auth/callback/test-refresh"], - logo_uri: "", - token_endpoint_auth_method: "client_secret_basic", - }, - }); - - // Create a mock access token in the database to test refresh functionality - // We'll simulate an existing token with refresh capabilities - const clientId = (createdClient.data as any).client_id; - const clientSecret = (createdClient.data as any).client_secret; - - // Test the refresh token flow by creating a refresh token request - // For this test, we'll verify the endpoint handles refresh_token grant_type - const refreshTokenRequest = await serverClient.$fetch("/mcp/token", { - method: "POST", - body: { - grant_type: "refresh_token", - client_id: clientId, - client_secret: clientSecret, - refresh_token: "invalid-refresh-token", // This should fail but test the flow - }, - }); - - // Should fail with invalid_grant error for invalid refresh token - expect(refreshTokenRequest.error).toBeTruthy(); - expect((refreshTokenRequest.error as any).error).toBe("invalid_grant"); - expect((refreshTokenRequest.error as any).error_description).toContain( - "invalid refresh token", - ); - }); - - it("should return user info from userinfo endpoint", async ({ expect }) => { - // First get an access token through the OAuth flow - const createdClient = await serverClient.$fetch("/mcp/register", { - method: "POST", - body: { - client_name: "test-userinfo-client", - redirect_uris: [ - "http://localhost:3000/api/auth/callback/test-userinfo", - ], - logo_uri: "", - token_endpoint_auth_method: "none", - }, - }); - - const userinfoClient = { - clientId: (createdClient.data as any).client_id, - clientSecret: (createdClient.data as any).client_secret, - redirectUrls: (createdClient.data as any).redirect_uris, - }; - - // Set up OAuth flow - const { customFetchImpl: customFetchImplRP } = await getTestInstance({ - account: { - accountLinking: { - trustedProviders: ["test-userinfo"], - }, - }, - databaseHooks: autoVerifyUserHook, - plugins: [ - genericOAuth({ - config: [ - { - providerId: "test-userinfo", - clientId: userinfoClient.clientId, - clientSecret: "", - authorizationUrl: `${baseURL}/api/auth/mcp/authorize`, - tokenUrl: `${baseURL}/api/auth/mcp/token`, - scopes: ["openid", "profile", "email"], - pkce: true, - }, - ], - }), - ], - }); - - const client = createAuthClient({ - baseURL: "http://localhost:5003", - fetchOptions: { - customFetchImpl: customFetchImplRP, - }, - }); - - // Perform OAuth flow - await client.signIn.social( - { - provider: "test-userinfo", - callbackURL: "/dashboard", - }, - { - throw: true, - }, - ); - - // Follow OAuth flow to get access token (simplified version) - // In a real test, we'd complete the full flow, but for this test we'll - // use the getMcpSession endpoint which validates bearer tokens - - // For now, let's test the userinfo endpoint structure by calling it directly - // This will fail auth but we can check the endpoint exists and returns proper errors - const userinfoResponse = await serverClient.$fetch("/mcp/userinfo", { - method: "GET", - headers: { - Authorization: "Bearer invalid-token", - }, - }); - - // Should return null for invalid token - expect(userinfoResponse.data).toBeNull(); - }); - - it("should handle ID token requests", async ({ expect }) => { - // Create a confidential client to test ID token flow - const createdClient = await serverClient.$fetch("/mcp/register", { - method: "POST", - body: { - client_name: "test-idtoken-client", - redirect_uris: ["http://localhost:3000/api/auth/callback/test-idtoken"], - logo_uri: "", - token_endpoint_auth_method: "client_secret_basic", - }, - }); - - const clientId = (createdClient.data as any).client_id; - const clientSecret = (createdClient.data as any).client_secret; - - // Test that token endpoint handles openid scope properly - // We'll test with invalid code but valid structure to verify ID token logic - const tokenRequest = await serverClient.$fetch("/mcp/token", { - method: "POST", - body: { - grant_type: "authorization_code", - client_id: clientId, - client_secret: clientSecret, - code: "invalid-auth-code", - redirect_uri: (createdClient.data as any).redirect_uris[0], - // Missing code_verifier but that's OK for confidential clients - }, - }); - - // Should fail due to missing code verifier, but this tests the ID token flow exists - expect(tokenRequest.error).toBeTruthy(); - expect((tokenRequest.error as any).error).toBe("invalid_request"); - expect((tokenRequest.error as any).error_description).toContain( - "code verifier is missing", - ); - }); - - it("should handle consent flow with prompt=consent", async ({ expect }) => { - // Register a client for consent flow testing - const consentClient = await serverClient.$fetch("/mcp/register", { - method: "POST", - body: { - client_name: "test-consent-client", - redirect_uris: ["http://localhost:3000/api/auth/callback/test-consent"], - logo_uri: "", - token_endpoint_auth_method: "none", - }, - }); - - const clientId = (consentClient.data as any).client_id; - const redirectUri = (consentClient.data as any).redirect_uris[0]; - - // Construct authorization URL with prompt=consent - const authURL = new URL(`${baseURL}/api/auth/mcp/authorize`); - authURL.searchParams.set("client_id", clientId); - authURL.searchParams.set("redirect_uri", redirectUri); - authURL.searchParams.set("response_type", "code"); - authURL.searchParams.set("scope", "openid profile email"); - authURL.searchParams.set("state", "test-state"); - authURL.searchParams.set("prompt", "consent"); - authURL.searchParams.set("code_challenge", "test-challenge"); - authURL.searchParams.set("code_challenge_method", "S256"); - - // Make authorization request with authenticated session - let redirectLocation = ""; - const consentHeaders = new Headers(); - await serverClient.$fetch(authURL.toString(), { - method: "GET", - onError(context: any) { - redirectLocation = context.response.headers.get("Location") || ""; - // Capture consent cookies (oidc_consent_prompt) - const setCookieHeaders = - context.response.headers.getSetCookie?.() || []; - for (const cookie of setCookieHeaders) { - if (cookie.includes("oidc_consent_prompt=")) { - const existingCookies = consentHeaders.get("Cookie") || ""; - const cookieValue = cookie.split(";")[0]; // Extract just the name=value part - consentHeaders.set( - "Cookie", - existingCookies - ? `${existingCookies}; ${cookieValue}` - : cookieValue, - ); - } - } - }, - }); - - // Verify redirect to consent page (not direct code callback) - expect(redirectLocation).toContain("/oauth/consent"); - expect(redirectLocation).toContain("consent_code="); - expect(redirectLocation).toContain(`client_id=${clientId}`); - expect(redirectLocation).toContain("scope="); - expect(redirectLocation).not.toContain("?code="); // Should NOT have authorization code yet - - // Extract consent_code from redirect URL - const consentURL = new URL(redirectLocation, baseURL); - const consentCode = consentURL.searchParams.get("consent_code"); - expect(consentCode).toBeTruthy(); - - // Merge session headers with consent cookies - const authHeaders = new Headers(headers); - consentHeaders.forEach((value, key) => { - if (key.toLowerCase() === "cookie") { - const existing = authHeaders.get("Cookie") || ""; - authHeaders.set("Cookie", existing ? `${existing}; ${value}` : value); - } - }); - - // Accept consent - let finalRedirect = ""; - try { - const consentResponse = await serverClient.$fetch("/oauth2/consent", { - method: "POST", - headers: authHeaders, - body: { - accept: true, - consent_code: consentCode, - }, - }); - - // The response should contain redirectURI - if (consentResponse.data) { - finalRedirect = (consentResponse.data as any).redirectURI; - } - } catch (error) { - // In case of error, log it for debugging - console.error("Consent request failed:", error); - throw error; - } - - // Verify we get the final redirect with authorization code - expect(finalRedirect).toBeTruthy(); - expect(finalRedirect).toContain(redirectUri); - expect(finalRedirect).toContain("code="); - expect(finalRedirect).toContain("state=test-state"); - }); - - it("should skip consent flow when prompt is not consent", async ({ - expect, - }) => { - // Register a client for non-consent flow testing - const noConsentClient = await serverClient.$fetch("/mcp/register", { - method: "POST", - body: { - client_name: "test-no-consent-client", - redirect_uris: [ - "http://localhost:3000/api/auth/callback/test-no-consent", - ], - logo_uri: "", - token_endpoint_auth_method: "none", - }, - }); - - const clientId = (noConsentClient.data as any).client_id; - const redirectUri = (noConsentClient.data as any).redirect_uris[0]; - - // Construct authorization URL WITHOUT prompt=consent - const authURL = new URL(`${baseURL}/api/auth/mcp/authorize`); - authURL.searchParams.set("client_id", clientId); - authURL.searchParams.set("redirect_uri", redirectUri); - authURL.searchParams.set("response_type", "code"); - authURL.searchParams.set("scope", "openid profile email"); - authURL.searchParams.set("state", "test-state-2"); - authURL.searchParams.set("code_challenge", "test-challenge-2"); - authURL.searchParams.set("code_challenge_method", "S256"); - - // Make authorization request with authenticated session - let redirectLocation = ""; - await serverClient.$fetch(authURL.toString(), { - method: "GET", - onError(context: any) { - redirectLocation = context.response.headers.get("Location") || ""; - }, - }); - - // Verify redirect directly to callback with code (skip consent) - expect(redirectLocation).toContain(redirectUri); - expect(redirectLocation).toContain("code="); - expect(redirectLocation).toContain("state=test-state-2"); - expect(redirectLocation).not.toContain("consent_code="); // Should NOT redirect to consent page - }); - - it("should not include state=undefined in redirect URL when state query parameter is not present", async ({ - expect, - }) => { - // Register a client for testing - const testClient = await serverClient.$fetch("/mcp/register", { - method: "POST", - body: { - client_name: "test-no-state-client", - redirect_uris: [ - "http://localhost:3000/api/auth/callback/test-no-state", - ], - logo_uri: "", - token_endpoint_auth_method: "none", - }, - }); - - const clientId = (testClient.data as any).client_id; - const redirectUri = (testClient.data as any).redirect_uris[0]; - - // Construct authorization URL WITHOUT state parameter - const authURL = new URL(`${baseURL}/api/auth/mcp/authorize`); - authURL.searchParams.set("client_id", clientId); - authURL.searchParams.set("redirect_uri", redirectUri); - authURL.searchParams.set("response_type", "code"); - authURL.searchParams.set("scope", "openid profile email"); - // Intentionally NOT setting state parameter - authURL.searchParams.set("code_challenge", "test-challenge-no-state"); - authURL.searchParams.set("code_challenge_method", "S256"); - - // Make authorization request with authenticated session - let redirectLocation = ""; - await serverClient.$fetch(authURL.toString(), { - method: "GET", - onError(context: any) { - redirectLocation = context.response.headers.get("Location") || ""; - }, - }); - - const redirectUrl = new URL(redirectLocation); - - // Verify redirect doesn't contain state=undefined - expect(redirectUrl.toString()).toContain(redirectUri); - expect(redirectUrl.searchParams.has("code")).toBe(true); - expect(redirectUrl.searchParams.has("state")).toBe(false); - }); - - describe("withMCPAuth", () => { - it("should return 401 if the request is not authenticated returning the right WWW-Authenticate header", async ({ - expect, - }) => { - // Test the handler using a newly instantiated Request instead of the server, since this route isn't handled by the server - const response = await withMcpAuth(auth, async () => { - // it will never be reached since the request is not authenticated - return new Response("unnecessary"); - })(new Request(`${baseURL}/mcp`)); - - expect(response.status).toBe(401); - expect(response.headers.get("WWW-Authenticate")).toBe( - `Bearer resource_metadata="${baseURL}/api/auth/.well-known/oauth-protected-resource"`, - ); - expect(response.headers.get("Access-Control-Expose-Headers")).toBe( - "WWW-Authenticate", - ); - }); - }); - - describe("OAuth cookie persistence", () => { - it("should redirect back to client after login, not to /api/auth/error", async ({ - expect, - }) => { - // Use unique email to avoid conflicts with other test runs - const uniqueEmail = `test-${Date.now()}@test.com`; - const password = "testpassword123"; - - // Create a test user - await customFetchImpl(`${baseURL}/api/auth/sign-up/email`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - email: uniqueEmail, - password: password, - name: "Test User", - }), - }); - - // Register a test client - const testClient = await serverClient.$fetch("/mcp/register", { - method: "POST", - body: { - client_name: "test-magiclink-client", - redirect_uris: [ - "http://localhost:3000/api/auth/callback/magiclink-test", - ], - token_endpoint_auth_method: "none", - }, - }); - - const clientId = (testClient.data as any).client_id; - const redirectUri = (testClient.data as any).redirect_uris[0]; - - // Logout to simulate unauthenticated user - await serverClient.$fetch("/sign-out", { - method: "POST", - }); - - // Initiate OAuth authorization without being logged in - const authURL = new URL(`${baseURL}/api/auth/mcp/authorize`); - authURL.searchParams.set("client_id", clientId); - authURL.searchParams.set("redirect_uri", redirectUri); - authURL.searchParams.set("response_type", "code"); - authURL.searchParams.set("scope", "openid profile email"); - authURL.searchParams.set("state", "magiclink-test-state"); - authURL.searchParams.set("code_challenge", "test-challenge-magiclink"); - authURL.searchParams.set("code_challenge_method", "S256"); - - let redirectLocation = ""; - const oidcHeaders = new Headers(); - - await customFetchImpl(authURL.toString(), { - method: "GET", - redirect: "manual", - }).then((res) => { - redirectLocation = res.headers.get("Location") || ""; - // Capture oidc_login_prompt cookie - const setCookie = res.headers.get("set-cookie"); - if (setCookie) { - oidcHeaders.set("Cookie", setCookie); - } - }); - - // Should redirect to login page with oidc_login_prompt cookie set - expect(redirectLocation).toContain("/login"); - expect(oidcHeaders.get("Cookie")).toContain("oidc_login_prompt"); - - const oidcCookie = oidcHeaders.get("Cookie")?.split(";")[0] || ""; - - const user = await customFetchImpl(`${baseURL}/api/auth/sign-in/email`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Cookie: oidcCookie, - }, - body: JSON.stringify({ - email: uniqueEmail, - password: password, - }), - redirect: "manual", - }); - redirectLocation = user.headers.get("Location") || ""; - - // Verify the redirect after authenticated login - expect(redirectLocation).not.toContain("error=invalid_client"); - expect(redirectLocation).not.toContain("/api/auth/error"); - - // Should either redirect to consent or directly to callback with code - const shouldHaveValidRedirect = - redirectLocation.includes("consent_code=") || - (redirectLocation.includes(redirectUri) && - redirectLocation.includes("code=")); - - expect(shouldHaveValidRedirect).toBe(true); - }); - }); -}); - -/** - * @see https://github.com/better-auth/better-auth/security/advisories/GHSA-pw9m-5jxm-xr6h - */ -describe("mcp refresh_token grant client authentication", () => { - const REFRESH_TOKEN = "pw9m-mcp-test-refresh-token"; - const CLIENT_ID = "pw9m-mcp-confidential-test-client"; - const CLIENT_SECRET = "pw9m-mcp-secret-only-the-client-knows"; - - async function seedConfidentialClientAndToken( - db: Awaited>["db"], - userId: string, - ) { - await db.create({ - model: "oauthApplication", - data: { - clientId: CLIENT_ID, - clientSecret: CLIENT_SECRET, - type: "web", - name: "Confidential Test Client", - redirectUrls: "http://localhost/callback", - disabled: false, - metadata: null, - icon: null, - userId: null, - createdAt: new Date(), - updatedAt: new Date(), - }, - }); - await db.create({ - model: "oauthAccessToken", - data: { - accessToken: "stale-access-token-not-used", - refreshToken: REFRESH_TOKEN, - accessTokenExpiresAt: new Date(Date.now() - 60 * 1000), - refreshTokenExpiresAt: new Date(Date.now() + 7 * 24 * 3600 * 1000), - clientId: CLIENT_ID, - userId, - scopes: "openid profile email offline_access", - createdAt: new Date(), - updatedAt: new Date(), - }, - }); - } - - it("should reject refresh_token grant on confidential client without client_secret", async () => { - const { customFetchImpl, signInWithTestUser, db } = await getTestInstance({ - plugins: [mcp({ loginPage: "/login" })], - }); - const { user } = await signInWithTestUser(); - await seedConfidentialClientAndToken(db, user.id); - - const response = await customFetchImpl( - "http://localhost:3000/api/auth/mcp/token", - { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: REFRESH_TOKEN, - client_id: CLIENT_ID, - }).toString(), - }, - ); - const body = await response.json().catch(() => null); - - expect(response.status).toBe(401); - expect(body?.error).toBe("invalid_client"); - expect(body?.access_token).toBeUndefined(); - }); - - it("should reject refresh_token grant on confidential client with wrong client_secret", async () => { - const { customFetchImpl, signInWithTestUser, db } = await getTestInstance({ - plugins: [mcp({ loginPage: "/login" })], - }); - const { user } = await signInWithTestUser(); - await seedConfidentialClientAndToken(db, user.id); - - const response = await customFetchImpl( - "http://localhost:3000/api/auth/mcp/token", - { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: REFRESH_TOKEN, - client_id: CLIENT_ID, - client_secret: "wrong-secret", - }).toString(), - }, - ); - const body = await response.json().catch(() => null); - - expect(response.status).toBe(401); - expect(body?.error).toBe("invalid_client"); - expect(body?.access_token).toBeUndefined(); - }); - - it("should accept refresh_token grant when client_secret comes via Authorization: Basic", async () => { - const { customFetchImpl, signInWithTestUser, db } = await getTestInstance({ - plugins: [mcp({ loginPage: "/login" })], - }); - const { user } = await signInWithTestUser(); - await seedConfidentialClientAndToken(db, user.id); - - const basic = `Basic ${Buffer.from( - `${CLIENT_ID}:${CLIENT_SECRET}`, - ).toString("base64")}`; - - const response = await customFetchImpl( - "http://localhost:3000/api/auth/mcp/token", - { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - authorization: basic, - }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: REFRESH_TOKEN, - }).toString(), - }, - ); - const body = await response.json().catch(() => null); - - expect(response.status).toBe(200); - expect(body?.access_token).toBeDefined(); - expect(body?.refresh_token).toBeDefined(); - }); - - it("should accept refresh_token grant when Authorization: Basic and matching client_id is in body", async () => { - const { customFetchImpl, signInWithTestUser, db } = await getTestInstance({ - plugins: [mcp({ loginPage: "/login" })], - }); - const { user } = await signInWithTestUser(); - await seedConfidentialClientAndToken(db, user.id); - - const basic = `Basic ${Buffer.from( - `${CLIENT_ID}:${CLIENT_SECRET}`, - ).toString("base64")}`; - - const response = await customFetchImpl( - "http://localhost:3000/api/auth/mcp/token", - { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - authorization: basic, - }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: REFRESH_TOKEN, - client_id: CLIENT_ID, - }).toString(), - }, - ); - const body = await response.json().catch(() => null); - - expect(response.status).toBe(200); - expect(body?.access_token).toBeDefined(); - }); - - it("should reject refresh_token grant when body client_id does not match Authorization: Basic", async () => { - const { customFetchImpl, signInWithTestUser, db } = await getTestInstance({ - plugins: [mcp({ loginPage: "/login" })], - }); - const { user } = await signInWithTestUser(); - await seedConfidentialClientAndToken(db, user.id); - - const basic = `Basic ${Buffer.from( - `${CLIENT_ID}:${CLIENT_SECRET}`, - ).toString("base64")}`; - - const response = await customFetchImpl( - "http://localhost:3000/api/auth/mcp/token", - { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - authorization: basic, - }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: REFRESH_TOKEN, - client_id: "different-client-id", - }).toString(), - }, - ); - const body = await response.json().catch(() => null); - - expect(response.status).toBe(401); - expect(body?.error).toBe("invalid_client"); - }); - - it("should reject refresh_token grant when the confidential client is disabled", async () => { - const { customFetchImpl, signInWithTestUser, db } = await getTestInstance({ - plugins: [mcp({ loginPage: "/login" })], - }); - const { user } = await signInWithTestUser(); - await seedConfidentialClientAndToken(db, user.id); - await db.update<{ disabled: boolean }>({ - model: "oauthApplication", - where: [{ field: "clientId", value: CLIENT_ID }], - update: { disabled: true }, - }); - - const response = await customFetchImpl( - "http://localhost:3000/api/auth/mcp/token", - { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: REFRESH_TOKEN, - client_id: CLIENT_ID, - client_secret: CLIENT_SECRET, - }).toString(), - }, - ); - const body = await response.json().catch(() => null); - expect(response.status).toBe(401); - expect(body?.error).toBe("invalid_client"); - expect(body?.access_token).toBeUndefined(); - }); -}); - -/** - * @see https://github.com/better-auth/better-auth/security/advisories/GHSA-9h47-pqcx-hjr4 - */ -describe("mcp discovery metadata (security)", async () => { - const { auth } = await getTestInstance({ - baseURL: "http://localhost:3000", - plugins: [mcp({ loginPage: "/login" })], - }); - - it("/.well-known/oauth-authorization-server must not advertise alg=none", async () => { - const res = await auth.handler( - new Request( - "http://localhost:3000/api/auth/.well-known/oauth-authorization-server", - { method: "GET" }, - ), - ); - const body = (await res.json()) as { - id_token_signing_alg_values_supported: string[]; - }; - expect(body.id_token_signing_alg_values_supported).not.toContain("none"); - }); - - it("/.well-known/oauth-protected-resource must not advertise alg=none", async () => { - const res = await auth.handler( - new Request( - "http://localhost:3000/api/auth/.well-known/oauth-protected-resource", - { method: "GET" }, - ), - ); - const body = (await res.json()) as { - resource_signing_alg_values_supported: string[]; - }; - expect(body.resource_signing_alg_values_supported).not.toContain("none"); - }); -}); - -describe("mcp session freshness (security)", () => { - async function seedToken( - db: Awaited>["db"], - userId: string, - overrides: Record, - ) { - await db.create({ - model: "oauthApplication", - data: { - clientId: "freshness-test-client", - clientSecret: "freshness-secret", - type: "web", - name: "Freshness Test Client", - redirectUrls: "http://localhost/callback", - disabled: false, - metadata: null, - icon: null, - userId: null, - createdAt: new Date(), - updatedAt: new Date(), - }, - }); - await db.create({ - model: "oauthAccessToken", - data: { - accessToken: "freshness-access-token", - refreshToken: "freshness-refresh-token", - accessTokenExpiresAt: new Date(Date.now() + 3600 * 1000), - refreshTokenExpiresAt: new Date(Date.now() + 7 * 24 * 3600 * 1000), - clientId: "freshness-test-client", - userId, - scopes: "openid profile email offline_access", - createdAt: new Date(), - updatedAt: new Date(), - ...overrides, - }, - }); - } - - it("rejects an expired access token and does not invoke the handler", async () => { - const { auth, signInWithTestUser, db } = await getTestInstance({ - baseURL: "http://localhost:3000", - plugins: [mcp({ loginPage: "/login" })], - }); - const { user } = await signInWithTestUser(); - await seedToken(db, user.id, { - accessToken: "expired-access-token", - accessTokenExpiresAt: new Date(Date.now() - 60 * 1000), - }); - - let handlerCalled = false; - const response = await withMcpAuth(auth, async () => { - handlerCalled = true; - return new Response("ok"); - })( - new Request("http://localhost:3000/mcp", { - headers: { Authorization: "Bearer expired-access-token" }, - }), - ); - - expect(response.status).toBe(401); - expect(handlerCalled).toBe(false); - }); - - it("accepts an unexpired access token", async () => { - const { auth, signInWithTestUser, db } = await getTestInstance({ - baseURL: "http://localhost:3000", - plugins: [mcp({ loginPage: "/login" })], - }); - const { user } = await signInWithTestUser(); - await seedToken(db, user.id, { accessToken: "live-access-token" }); - - const response = await withMcpAuth( - auth, - async () => new Response("ok"), - )( - new Request("http://localhost:3000/mcp", { - headers: { Authorization: "Bearer live-access-token" }, - }), - ); - - expect(response.status).toBe(200); - }); - - it("rejects a refresh_token grant whose original scopes lack offline_access", async () => { - const { customFetchImpl, signInWithTestUser, db } = await getTestInstance({ - baseURL: "http://localhost:3000", - plugins: [mcp({ loginPage: "/login" })], - }); - const { user } = await signInWithTestUser(); - await seedToken(db, user.id, { - refreshToken: "no-offline-refresh-token", - scopes: "openid profile email", - }); - - const response = await customFetchImpl( - "http://localhost:3000/api/auth/mcp/token", - { - method: "POST", - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - body: new URLSearchParams({ - grant_type: "refresh_token", - refresh_token: "no-offline-refresh-token", - client_id: "freshness-test-client", - client_secret: "freshness-secret", - }).toString(), - }, - ); - const body = await response.json().catch(() => null); - - expect(response.status).toBe(401); - expect(body?.error).toBe("invalid_grant"); - expect(body?.access_token).toBeUndefined(); - }); -}); diff --git a/packages/better-auth/tsdown.config.ts b/packages/better-auth/tsdown.config.ts index 20240f0c25..c7940938f3 100644 --- a/packages/better-auth/tsdown.config.ts +++ b/packages/better-auth/tsdown.config.ts @@ -61,8 +61,6 @@ export default defineConfig({ "./src/plugins/haveibeenpwned/index.ts", "./src/plugins/one-time-token/index.ts", "./src/plugins/siwe/index.ts", - "./src/plugins/mcp/client/index.ts", - "./src/plugins/mcp/client/adapters.ts", "./src/test-utils/index.ts", ], treeshake: true, diff --git a/packages/mcp/README.md b/packages/mcp/README.md new file mode 100644 index 0000000000..3ae30176a8 --- /dev/null +++ b/packages/mcp/README.md @@ -0,0 +1,21 @@ +# @better-auth/mcp + +Model Context Protocol (MCP) plugin for [Better Auth](https://www.better-auth.com). + +`mcp()` turns your Better Auth app into an OAuth 2.1 authorization server for MCP +clients, built on [`@better-auth/oauth-provider`](https://www.better-auth.com/docs/plugins/oauth-provider). +It serves the RFC 9728 protected resource metadata so MCP clients can discover it. +To protect an MCP route, wrap its handler with `requireMcpAuth` (or `mcpHandler`), +which verifies bearer tokens against the published JWKS. + +```ts +import { betterAuth } from "better-auth"; +import { jwt } from "better-auth/plugins"; +import { mcp } from "@better-auth/mcp"; + +export const auth = betterAuth({ + plugins: [jwt(), mcp({ loginPage: "/login", consentPage: "/consent" })], +}); +``` + +See the [MCP plugin documentation](https://www.better-auth.com/docs/plugins/mcp). diff --git a/packages/mcp/package.json b/packages/mcp/package.json new file mode 100644 index 0000000000..202b089564 --- /dev/null +++ b/packages/mcp/package.json @@ -0,0 +1,92 @@ +{ + "name": "@better-auth/mcp", + "version": "1.7.0-beta.5", + "description": "Model Context Protocol (MCP) plugin for Better Auth", + "type": "module", + "license": "MIT", + "homepage": "https://www.better-auth.com/docs/plugins/mcp", + "repository": { + "type": "git", + "url": "git+https://github.com/better-auth/better-auth.git", + "directory": "packages/mcp" + }, + "keywords": [ + "mcp", + "model context protocol", + "auth", + "oauth", + "oidc", + "rfc9728", + "rfc8707", + "typescript", + "better-auth" + ], + "publishConfig": { + "access": "public" + }, + "sideEffects": false, + "scripts": { + "build": "tsdown", + "dev": "tsdown --watch", + "lint:package": "publint run --strict --pack false", + "lint:types": "attw --profile esm-only --pack .", + "typecheck": "tsc --project tsconfig.json", + "test": "vitest", + "coverage": "vitest run --coverage --coverage.provider=istanbul" + }, + "files": [ + "dist" + ], + "main": "./dist/index.mjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "exports": { + ".": { + "dev-source": "./src/index.ts", + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "./client": { + "dev-source": "./src/client/index.ts", + "types": "./dist/client/index.d.mts", + "default": "./dist/client/index.mjs" + }, + "./client/adapters": { + "dev-source": "./src/client/adapters.ts", + "types": "./dist/client/adapters.d.mts", + "default": "./dist/client/adapters.mjs" + } + }, + "typesVersions": { + "*": { + "*": [ + "./dist/index.d.mts" + ], + "client": [ + "./dist/client/index.d.mts" + ], + "client/adapters": [ + "./dist/client/adapters.d.mts" + ] + } + }, + "dependencies": { + "@better-auth/oauth-provider": "workspace:^", + "jose": "^6.1.3" + }, + "devDependencies": { + "@better-auth/core": "workspace:*", + "@modelcontextprotocol/sdk": "^1.27.1", + "better-auth": "workspace:*", + "better-call": "catalog:", + "listhen": "1.9.0", + "tsdown": "catalog:" + }, + "peerDependencies": { + "@better-auth/core": "workspace:^", + "@better-auth/utils": "catalog:", + "@better-fetch/fetch": "catalog:", + "better-auth": "workspace:^", + "better-call": "catalog:" + } +} diff --git a/packages/better-auth/src/plugins/mcp/client/adapters.ts b/packages/mcp/src/client/adapters.ts similarity index 62% rename from packages/better-auth/src/plugins/mcp/client/adapters.ts rename to packages/mcp/src/client/adapters.ts index ae759e7c85..6cc0f459c5 100644 --- a/packages/better-auth/src/plugins/mcp/client/adapters.ts +++ b/packages/mcp/src/client/adapters.ts @@ -1,9 +1,9 @@ import type { - McpAuthClient, - McpAuthClientOptions, + McpResourceClient, + McpResourceClientOptions, McpSession, -} from "./index.js"; -import { createMcpAuthClient } from "./index.js"; +} from "./index"; +import { createMcpResourceClient } from "./index"; interface HonoContext { req: { header: (name: string) => string | undefined; raw: Request }; @@ -25,14 +25,39 @@ interface HonoApp { get: (path: string, handler: (c: HonoContext) => Promise) => void; } -export function mcpAuthHono(options: McpAuthClientOptions): { - client: McpAuthClient; +const PROTECTED_RESOURCE_METADATA_PATH = + "/.well-known/oauth-protected-resource"; + +function getProtectedResourceMetadataPath(resource: string): string { + const resourceUrl = new URL(resource); + if (resourceUrl.origin === "null") { + return PROTECTED_RESOURCE_METADATA_PATH; + } + const resourcePath = + resourceUrl.pathname === "/" ? "" : resourceUrl.pathname.replace(/\/$/, ""); + return `${PROTECTED_RESOURCE_METADATA_PATH}${resourcePath}`; +} + +function getProtectedResourceMetadataURL(resource: string): string { + const resourceUrl = new URL(resource); + if (resourceUrl.origin === "null") { + throw new Error( + "MCP resource_metadata requires an origin-based resource URL", + ); + } + return `${resourceUrl.origin}${getProtectedResourceMetadataPath(resource)}${resourceUrl.search}`; +} + +export function mcpAuthHono(options: McpResourceClientOptions): { + client: McpResourceClient; middleware: HonoMiddleware; discoveryRoutes: (app: HonoApp, serverURL: string) => void; } { - const client = createMcpAuthClient(options); + const client = createMcpResourceClient(options); - const resourceBase = options.resource ?? client.authURL; + const resourceMetadata = getProtectedResourceMetadataURL( + options.resource ?? client.authURL, + ); const middleware: HonoMiddleware = async (c, next) => { const authHeader = c.req.header("Authorization"); @@ -42,7 +67,7 @@ export function mcpAuthHono(options: McpAuthClientOptions): { if (!token) { c.header( "WWW-Authenticate", - `Bearer resource_metadata="${resourceBase}/.well-known/oauth-protected-resource"`, + `Bearer resource_metadata="${resourceMetadata}"`, ); return c.json( { @@ -61,7 +86,7 @@ export function mcpAuthHono(options: McpAuthClientOptions): { if (!session) { c.header( "WWW-Authenticate", - `Bearer resource_metadata="${resourceBase}/.well-known/oauth-protected-resource"`, + `Bearer resource_metadata="${resourceMetadata}"`, ); return c.json( { @@ -80,6 +105,10 @@ export function mcpAuthHono(options: McpAuthClientOptions): { const discoveryRoutes = (app: HonoApp, serverURL: string) => { const discoveryFn = client.discoveryHandler(); const protectedResourceFn = client.protectedResourceHandler(serverURL); + const protectedResourcePaths = new Set([ + PROTECTED_RESOURCE_METADATA_PATH, + getProtectedResourceMetadataPath(options.resource ?? client.authURL), + ]); app.get( "/.well-known/oauth-authorization-server", @@ -92,24 +121,26 @@ export function mcpAuthHono(options: McpAuthClientOptions): { }, ); - app.get("/.well-known/oauth-protected-resource", async (c: HonoContext) => { - const response = await protectedResourceFn(c.req.raw); - const data: unknown = await response.json().catch(() => ({ - error: "Invalid response from auth server", - })); - return c.json(data, response.status as 200 | 502); - }); + for (const path of protectedResourcePaths) { + app.get(path, async (c: HonoContext) => { + const response = await protectedResourceFn(c.req.raw); + const data: unknown = await response.json().catch(() => ({ + error: "Invalid response from auth server", + })); + return c.json(data, response.status as 200 | 502); + }); + } }; return { client, middleware, discoveryRoutes }; } -export function mcpAuthOfficial(options: McpAuthClientOptions): { - client: McpAuthClient; - handler: McpAuthClient["handler"]; - verifyToken: McpAuthClient["verifyToken"]; +export function mcpAuthOfficial(options: McpResourceClientOptions): { + client: McpResourceClient; + handler: McpResourceClient["handler"]; + verifyToken: McpResourceClient["verifyToken"]; } { - const client = createMcpAuthClient(options); + const client = createMcpResourceClient(options); return { client, handler: client.handler, @@ -155,7 +186,7 @@ export function mcpAuthMcpUse(config: McpUseBetterAuthConfig): OAuthProvider { ); } - const client = createMcpAuthClient({ authURL }); + const client = createMcpResourceClient({ authURL }); return { async verifyToken( @@ -173,13 +204,13 @@ export function mcpAuthMcpUse(config: McpUseBetterAuthConfig): OAuthProvider { return config.getUserInfo(payload); } const scopes = - typeof payload.scopes === "string" ? payload.scopes.split(" ") : []; + typeof payload.scope === "string" ? payload.scope.split(" ") : []; return { - userId: payload.userId as string, + userId: payload.sub as string, roles: [], permissions: scopes, - scopes: payload.scopes as string | undefined, - clientId: payload.clientId as string | undefined, + scopes: payload.scope as string | undefined, + clientId: (payload.azp ?? payload.client_id) as string | undefined, }; }, @@ -188,11 +219,11 @@ export function mcpAuthMcpUse(config: McpUseBetterAuthConfig): OAuthProvider { }, getAuthEndpoint() { - return `${authURL}/mcp/authorize`; + return `${authURL}/oauth2/authorize`; }, getTokenEndpoint() { - return `${authURL}/mcp/token`; + return `${authURL}/oauth2/token`; }, getScopesSupported() { @@ -208,7 +239,7 @@ export function mcpAuthMcpUse(config: McpUseBetterAuthConfig): OAuthProvider { }, getRegistrationEndpoint() { - return `${authURL}/mcp/register`; + return `${authURL}/oauth2/register`; }, }; } @@ -218,4 +249,4 @@ function normalizeURL(url: string | undefined | null): string | undefined { return url.endsWith("/") ? url.slice(0, -1) : url; } -export type { McpSession, McpAuthClient, McpAuthClientOptions }; +export type { McpSession, McpResourceClient, McpResourceClientOptions }; diff --git a/packages/mcp/src/client/index.test.ts b/packages/mcp/src/client/index.test.ts new file mode 100644 index 0000000000..242c907410 --- /dev/null +++ b/packages/mcp/src/client/index.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "vitest"; +import { mcpAuthHono } from "./adapters"; +import { createMcpResourceClient } from "./index"; + +describe("createMcpResourceClient", () => { + /** + * @see https://github.com/better-auth/better-auth/pull/9992 + */ + it("uses RFC 9728 path insertion for the default resource_metadata challenge", async () => { + const client = createMcpResourceClient({ + authURL: "https://app.example.com/api/auth", + }); + + const response = await client.handler(() => new Response("unreachable"))( + new Request("https://app.example.com/mcp"), + ); + + expect(response.status).toBe(401); + expect(response.headers.get("WWW-Authenticate")).toBe( + `Bearer resource_metadata="https://app.example.com/.well-known/oauth-protected-resource/api/auth"`, + ); + }); + + /** + * @see https://github.com/better-auth/better-auth/pull/9992 + */ + it("uses RFC 9728 path insertion for an explicit resource", async () => { + const client = createMcpResourceClient({ + authURL: "https://auth.example.com/api/auth", + resource: "https://mcp.example.com/server/mcp", + }); + + const response = await client.handler(() => new Response("unreachable"))( + new Request("https://mcp.example.com/server/mcp"), + ); + + expect(response.status).toBe(401); + expect(response.headers.get("WWW-Authenticate")).toBe( + `Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/server/mcp"`, + ); + }); + + /** + * @see https://github.com/better-auth/better-auth/pull/9992 + */ + it("preserves the resource query in the resource_metadata challenge", async () => { + const client = createMcpResourceClient({ + authURL: "https://auth.example.com/api/auth", + resource: "https://mcp.example.com/server/mcp?tenant=a", + }); + + const response = await client.handler(() => new Response("unreachable"))( + new Request("https://mcp.example.com/server/mcp"), + ); + + expect(response.status).toBe(401); + expect(response.headers.get("WWW-Authenticate")).toBe( + `Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/server/mcp?tenant=a"`, + ); + }); + + /** + * @see https://github.com/better-auth/better-auth/pull/9992 + */ + it("does not invent protected-resource scopes for remote metadata", async () => { + const client = createMcpResourceClient({ + authURL: "https://auth.example.com/api/auth", + resource: "https://mcp.example.com/server/mcp", + }); + + const response = await client.protectedResourceHandler( + "https://mcp.example.com/server/mcp", + )( + new Request( + "https://mcp.example.com/.well-known/oauth-protected-resource", + ), + ); + const metadata = (await response.json()) as { + scopes_supported?: string[]; + }; + + expect(metadata.scopes_supported).toBeUndefined(); + }); +}); + +describe("mcpAuthHono", () => { + /** + * @see https://github.com/better-auth/better-auth/pull/9992 + */ + it("uses RFC 9728 path insertion for the resource_metadata challenge", async () => { + const auth = mcpAuthHono({ + authURL: "https://app.example.com/api/auth", + }); + let challenge = ""; + const context: Parameters[0] = { + req: { + header: () => undefined, + raw: new Request("https://app.example.com/mcp"), + }, + set: () => undefined, + header: (_name, value) => { + challenge = value; + }, + json: (data, status, headers) => Response.json(data, { status, headers }), + }; + + const response = await auth.middleware(context, async () => {}); + + expect(response?.status).toBe(401); + expect(challenge).toBe( + `Bearer resource_metadata="https://app.example.com/.well-known/oauth-protected-resource/api/auth"`, + ); + }); + + /** + * @see https://github.com/better-auth/better-auth/pull/9992 + */ + it("registers the path-inserted protected-resource metadata alias", () => { + const auth = mcpAuthHono({ + authURL: "https://app.example.com/api/auth", + }); + const routes: string[] = []; + const app: Parameters[0] = { + get: (path) => { + routes.push(path); + }, + }; + + auth.discoveryRoutes(app, "https://app.example.com/api/auth"); + + expect(routes).toContain("/.well-known/oauth-protected-resource"); + expect(routes).toContain("/.well-known/oauth-protected-resource/api/auth"); + }); +}); diff --git a/packages/better-auth/src/plugins/mcp/client/index.ts b/packages/mcp/src/client/index.ts similarity index 68% rename from packages/better-auth/src/plugins/mcp/client/index.ts rename to packages/mcp/src/client/index.ts index 789735743a..9821f3dc79 100644 --- a/packages/better-auth/src/plugins/mcp/client/index.ts +++ b/packages/mcp/src/client/index.ts @@ -1,18 +1,17 @@ -export interface McpAuthClientOptions { +import type { JWTPayload } from "jose"; +import { createRemoteJWKSet, jwtVerify } from "jose"; + +export interface McpResourceClientOptions { authURL: string; resource?: string; allowedOrigin?: string; fetch?: typeof globalThis.fetch; } -export interface McpSession { - accessToken: string; - refreshToken: string; - accessTokenExpiresAt: string; - refreshTokenExpiresAt: string; - clientId: string; - userId: string; - scopes: string; +export interface McpSession extends JWTPayload { + sub?: string; + scope?: string; + client_id?: string; } interface OAuthDiscoveryMetadata { @@ -46,7 +45,10 @@ interface NodeLikeResponse { end?: (body: string) => void; } -export interface McpAuthClient { +const PROTECTED_RESOURCE_METADATA_PATH = + "/.well-known/oauth-protected-resource"; + +export interface McpResourceClient { verifyToken: (token: string) => Promise; handler: ( fn: (req: Request, session: McpSession) => Response | Promise, @@ -85,10 +87,22 @@ function buildCorsHeaders( }; } +function getProtectedResourceMetadataURL(resource: string): string { + const resourceUrl = new URL(resource); + if (resourceUrl.origin === "null") { + throw new Error( + "MCP resource_metadata requires an origin-based resource URL", + ); + } + const resourcePath = + resourceUrl.pathname === "/" ? "" : resourceUrl.pathname.replace(/\/$/, ""); + return `${resourceUrl.origin}${PROTECTED_RESOURCE_METADATA_PATH}${resourcePath}${resourceUrl.search}`; +} + function makeWWWAuthenticate(authURL: string, resource?: string): string { - const resourceMetadataURL = resource - ? `${resource}/.well-known/oauth-protected-resource` - : `${authURL}/.well-known/oauth-protected-resource`; + const resourceMetadataURL = getProtectedResourceMetadataURL( + resource ?? authURL, + ); return `Bearer resource_metadata="${resourceMetadataURL}"`; } @@ -136,49 +150,52 @@ function send401Node( } } -export function createMcpAuthClient( - options: McpAuthClientOptions, -): McpAuthClient { +export function createMcpResourceClient( + options: McpResourceClientOptions, +): McpResourceClient { const authURL = options.authURL.endsWith("/") ? options.authURL.slice(0, -1) : options.authURL; const fetchFn = options.fetch ?? globalThis.fetch; const corsHeaders = buildCorsHeaders(authURL, options.allowedOrigin); + const audience = options.resource ?? authURL; + + let discovery: { issuer: string; jwks_uri: string } | null = null; + let jwks: ReturnType | null = null; + + const loadVerifier = async () => { + if (discovery && jwks) { + return { discovery, jwks }; + } + const response = await fetchFn( + `${authURL}/.well-known/oauth-authorization-server`, + ); + if (!response.ok) { + throw new Error("Failed to fetch discovery metadata"); + } + const metadata = (await response.json()) as OAuthDiscoveryMetadata; + if (!metadata.jwks_uri || !metadata.issuer) { + throw new Error("Discovery metadata missing jwks_uri or issuer"); + } + discovery = { issuer: metadata.issuer, jwks_uri: metadata.jwks_uri }; + jwks = createRemoteJWKSet(new URL(metadata.jwks_uri)); + return { discovery, jwks }; + }; const verifyToken = async (token: string): Promise => { try { - const response = await fetchFn(`${authURL}/mcp/get-session`, { - method: "GET", - headers: { - Authorization: `Bearer ${token}`, - }, + const { discovery: meta, jwks: keySet } = await loadVerifier(); + const { payload } = await jwtVerify(token, keySet, { + issuer: meta.issuer, + audience, }); - - if (!response.ok) { - return null; - } - - const data = await response.json(); - if (!data || !data.userId) { - return null; - } - // Defense in depth: the auth server gates expiry, but never authorize a - // session whose access token has already expired, in case an older or - // misconfigured server returns one. - if ( - data.accessTokenExpiresAt && - new Date(data.accessTokenExpiresAt).getTime() < Date.now() - ) { - return null; - } - - return data as McpSession; + return payload as McpSession; } catch { return null; } }; - const handler: McpAuthClient["handler"] = (fn) => { + const handler: McpResourceClient["handler"] = (fn) => { return async (req: Request) => { if (req.method === "OPTIONS") { return new Response(null, { status: 204, headers: corsHeaders }); @@ -199,7 +216,7 @@ export function createMcpAuthClient( }; }; - const discoveryHandler: McpAuthClient["discoveryHandler"] = () => { + const discoveryHandler: McpResourceClient["discoveryHandler"] = () => { let cachedMetadata: OAuthDiscoveryMetadata | null = null; let cacheTime = 0; const CACHE_TTL = 60_000; @@ -232,23 +249,21 @@ export function createMcpAuthClient( }; }; - const protectedResourceHandler: McpAuthClient["protectedResourceHandler"] = ( - serverURL: string, - ) => { - const resource = options.resource ?? new URL(serverURL).origin; - const metadata = { - resource, - authorization_servers: [authURL], - bearer_methods_supported: ["header"], - scopes_supported: ["openid", "profile", "email", "offline_access"], + const protectedResourceHandler: McpResourceClient["protectedResourceHandler"] = + (serverURL: string) => { + const resource = options.resource ?? new URL(serverURL).origin; + const metadata = { + resource, + authorization_servers: [authURL], + bearer_methods_supported: ["header"], + }; + + return async (_req: Request) => { + return Response.json(metadata, { headers: corsHeaders }); + }; }; - return async (_req: Request) => { - return Response.json(metadata, { headers: corsHeaders }); - }; - }; - - const middleware: McpAuthClient["middleware"] = () => { + const middleware: McpResourceClient["middleware"] = () => { return async ( req: NodeLikeRequest, res: NodeLikeResponse, diff --git a/packages/mcp/src/handler.ts b/packages/mcp/src/handler.ts new file mode 100644 index 0000000000..73251f59ba --- /dev/null +++ b/packages/mcp/src/handler.ts @@ -0,0 +1,60 @@ +import type { Awaitable } from "@better-auth/core"; +import { raiseResourceServerChallenge } from "@better-auth/oauth-provider"; +import { verifyAccessToken } from "better-auth/oauth2"; +import { APIError } from "better-call"; +import type { JWTPayload } from "jose"; + +/** + * A request middleware handler that verifies an MCP access token and responds + * with an RFC 9728 `WWW-Authenticate` header for unauthenticated requests. + * + * @external + */ +export const mcpHandler = ( + /** Resource is the same url as the audience */ + verifyOptions: Parameters[1], + handler: (req: Request, jwt: JWTPayload) => Awaitable, + opts?: { + /** Maps non-url (ie urn, client) resources to resource_metadata */ + resourceMetadataMappings: Record; + }, +) => { + return async (req: Request) => { + const authorization = req.headers?.get("authorization") ?? undefined; + const accessToken = authorization?.startsWith("Bearer ") + ? authorization.replace("Bearer ", "") + : authorization; + try { + if (!accessToken?.length) { + throw new APIError("UNAUTHORIZED", { + message: "missing authorization header", + }); + } + const token = await verifyAccessToken(accessToken, verifyOptions); + return handler(req, token); + } catch (error) { + try { + raiseResourceServerChallenge( + error, + verifyOptions.verifyOptions.audience, + opts, + ); + } catch (err) { + if (err instanceof APIError) { + const headers = new Headers(err.headers as HeadersInit); + headers.set("Content-Type", "application/json"); + return new Response( + JSON.stringify({ + jsonrpc: "2.0", + error: { code: -32000, message: err.message }, + id: null, + }), + { status: err.statusCode, headers }, + ); + } + throw new Error(String(err)); + } + throw new Error(String(error)); + } + }; +}; diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts new file mode 100644 index 0000000000..5ff9493a7c --- /dev/null +++ b/packages/mcp/src/index.ts @@ -0,0 +1,3 @@ +export { mcpHandler } from "./handler"; +export { type McpOptions, mcp } from "./plugin"; +export { requireMcpAuth } from "./require-mcp-auth"; diff --git a/packages/oauth-provider/src/mcp.test.ts b/packages/mcp/src/mcp.test.ts similarity index 95% rename from packages/oauth-provider/src/mcp.test.ts rename to packages/mcp/src/mcp.test.ts index 8a97fb4060..230767bdd8 100644 --- a/packages/oauth-provider/src/mcp.test.ts +++ b/packages/mcp/src/mcp.test.ts @@ -1,5 +1,12 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { logger } from "@better-auth/core/env"; +import type { OAuthClient } from "@better-auth/oauth-provider"; +import { + oauthProvider, + raiseResourceServerChallenge, +} from "@better-auth/oauth-provider"; +import { oauthProviderClient } from "@better-auth/oauth-provider/client"; +import { oauthProviderResourceClient } from "@better-auth/oauth-provider/resource-client"; import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; @@ -20,11 +27,7 @@ import { decodeJwt } from "jose"; import type { Listener } from "listhen"; import { listen } from "listhen"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { oauthProviderClient } from "./client"; -import { oauthProviderResourceClient } from "./client-resource"; -import { handleMcpErrors, mcpHandler } from "./mcp"; -import { oauthProvider } from "./oauth"; -import type { OAuthClient } from "./types/oauth"; +import { mcpHandler } from "./index"; describe("mcp", async () => { const authServerUrl = `http://localhost:3000`; @@ -35,6 +38,9 @@ describe("mcp", async () => { baseURL: apiServerBaseUrl, }); + /** + * @see https://github.com/better-auth/better-auth/pull/9992 + */ it.for([ { resource: apiServerBaseUrl, @@ -44,6 +50,10 @@ describe("mcp", async () => { resource: `${apiServerBaseUrl}/resource1`, expected: `Bearer resource_metadata="${apiServerBaseUrl}/.well-known/oauth-protected-resource/resource1"`, }, + { + resource: `${apiServerBaseUrl}/resource1?tenant=a`, + expected: `Bearer resource_metadata="${apiServerBaseUrl}/.well-known/oauth-protected-resource/resource1?tenant=a"`, + }, { resource: [apiServerBaseUrl, `${apiServerBaseUrl}/resource1`], expected: `Bearer resource_metadata="${apiServerBaseUrl}/.well-known/oauth-protected-resource", Bearer resource_metadata="${apiServerBaseUrl}/.well-known/oauth-protected-resource/resource1"`, @@ -280,7 +290,7 @@ describe("mcp - server-client flows", async () => { } satisfies AuthInfo; } catch (err) { try { - handleMcpErrors(err, mcpServerUrl); + raiseResourceServerChallenge(err, mcpServerUrl); } catch (error) { res.setHeader("Content-Type", "application/json"); if (error instanceof APIError) { diff --git a/packages/mcp/src/plugin.test.ts b/packages/mcp/src/plugin.test.ts new file mode 100644 index 0000000000..4b4ea81431 --- /dev/null +++ b/packages/mcp/src/plugin.test.ts @@ -0,0 +1,609 @@ +import { oauthProviderClient } from "@better-auth/oauth-provider/client"; +import { createAuthClient } from "better-auth/client"; +import { generateRandomString } from "better-auth/crypto"; +import { + authorizationCodeRequest, + createAuthorizationURL, + refreshAccessTokenRequest, +} from "better-auth/oauth2"; +import { jwt } from "better-auth/plugins/jwt"; +import { getTestInstance } from "better-auth/test"; +import { beforeAll, describe, expect, it, onTestFinished, vi } from "vitest"; +import { mcp, requireMcpAuth } from "./index"; + +describe("mcp plugin", async () => { + const authServerBaseUrl = "http://localhost:3000"; + const rpBaseUrl = "http://localhost:5000"; + const baseURL = `${authServerBaseUrl}/api/auth`; + + // No custom jwt.issuer here, so discovery documents are served under the + // `/api/auth` base path (issuer == baseURL), matching the public well-known + // URLs an MCP client derives from the issuer. + const { auth, signInWithTestUser, customFetchImpl } = await getTestInstance({ + baseURL: authServerBaseUrl, + plugins: [ + jwt(), + mcp({ + loginPage: "/login", + consentPage: "/consent", + silenceWarnings: { + oauthAuthServerConfig: true, + openidConfig: true, + }, + }), + ], + }); + + const { headers } = await signInWithTestUser(); + const serverClient = createAuthClient({ + plugins: [oauthProviderClient()], + baseURL: authServerBaseUrl, + fetchOptions: { + customFetchImpl, + headers, + }, + }); + const unauthenticatedClient = createAuthClient({ + plugins: [oauthProviderClient()], + baseURL: authServerBaseUrl, + fetchOptions: { + customFetchImpl, + }, + }); + + const providerId = "test"; + const redirectUri = `${rpBaseUrl}/api/auth/callback/${providerId}`; + + describe("dynamic client registration", () => { + it("registers a public client without a client_secret", async () => { + const response = await unauthenticatedClient.oauth2.register({ + client_name: "test-public-client", + redirect_uris: [redirectUri], + token_endpoint_auth_method: "none", + }); + + expect(response.data?.client_id).toBeDefined(); + expect(response.data?.token_endpoint_auth_method).toBe("none"); + expect(response.data).not.toHaveProperty("client_secret"); + expect(response.data).toMatchObject({ + grant_types: ["authorization_code"], + response_types: ["code"], + }); + }); + + it("registers a confidential client with a client_secret", async () => { + const response = await serverClient.oauth2.register({ + client_name: "test-confidential-client", + redirect_uris: [redirectUri], + token_endpoint_auth_method: "client_secret_basic", + }); + + expect(response.data?.client_id).toBeDefined(); + expect(response.data?.client_secret).toEqual(expect.any(String)); + expect(response.data?.token_endpoint_auth_method).toBe( + "client_secret_basic", + ); + }); + }); + + describe("discovery metadata", () => { + /** + * The authorization server metadata must never advertise the unsecured + * "none" id_token signing algorithm, which would let a client accept an + * unsigned ID token. + * + * @see https://github.com/better-auth/better-auth/security/advisories/GHSA-9h47-pqcx-hjr4 + */ + it("advertises the /oauth2/* endpoints and never alg=none", async () => { + const response = await customFetchImpl( + `${baseURL}/.well-known/oauth-authorization-server`, + { method: "GET" }, + ); + expect(response.status).toBe(200); + const metadata = (await response.json()) as { + issuer: string; + authorization_endpoint: string; + token_endpoint: string; + userinfo_endpoint: string; + registration_endpoint: string; + scopes_supported: string[]; + id_token_signing_alg_values_supported: string[]; + }; + + expect(metadata).toMatchObject({ + issuer: baseURL, + authorization_endpoint: `${baseURL}/oauth2/authorize`, + token_endpoint: `${baseURL}/oauth2/token`, + userinfo_endpoint: `${baseURL}/oauth2/userinfo`, + registration_endpoint: `${baseURL}/oauth2/register`, + }); + expect(metadata.scopes_supported).toContain("offline_access"); + expect(metadata.id_token_signing_alg_values_supported).not.toContain( + "none", + ); + }); + }); + + describe("protected resource metadata", () => { + /** + * @see https://github.com/better-auth/better-auth/pull/9992 + */ + it("returns RFC 9728 protected resource metadata", async () => { + const response = await customFetchImpl( + `${authServerBaseUrl}/.well-known/oauth-protected-resource`, + { method: "GET" }, + ); + expect(response.status).toBe(200); + const metadata = (await response.json()) as { + resource: string; + authorization_servers: string[]; + scopes_supported?: string[]; + bearer_methods_supported: string[]; + }; + + expect(metadata).toMatchObject({ + resource: baseURL, + authorization_servers: [baseURL], + bearer_methods_supported: ["header"], + }); + expect(metadata.scopes_supported).toBeUndefined(); + }); + + /** + * @see https://github.com/better-auth/better-auth/pull/9992 + */ + it("advertises resource scopes without authorization-server-only scopes", async () => { + const resourceServerBaseUrl = "http://localhost:3010"; + const resourceBaseURL = `${resourceServerBaseUrl}/api/auth`; + const { customFetchImpl: resourceFetch } = await getTestInstance({ + baseURL: resourceServerBaseUrl, + plugins: [ + jwt(), + mcp({ + loginPage: "/login", + consentPage: "/consent", + scopes: ["openid", "offline_access", "mcp:read"], + silenceWarnings: { + oauthAuthServerConfig: true, + openidConfig: true, + }, + }), + ], + }); + + const response = await resourceFetch( + `${resourceServerBaseUrl}/.well-known/oauth-protected-resource`, + { method: "GET" }, + ); + expect(response.status).toBe(200); + const metadata = (await response.json()) as { + resource: string; + authorization_servers: string[]; + scopes_supported?: string[]; + bearer_methods_supported: string[]; + }; + + expect(metadata).toMatchObject({ + resource: resourceBaseURL, + authorization_servers: [resourceBaseURL], + scopes_supported: ["mcp:read"], + bearer_methods_supported: ["header"], + }); + }); + + it("answers HEAD with metadata headers and an empty body", async () => { + const response = await customFetchImpl( + `${authServerBaseUrl}/.well-known/oauth-protected-resource`, + { method: "HEAD" }, + ); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain( + "application/json", + ); + expect(await response.text()).toBe(""); + }); + + it("rejects non-GET/HEAD methods with 405", async () => { + const response = await customFetchImpl( + `${authServerBaseUrl}/.well-known/oauth-protected-resource`, + { method: "POST" }, + ); + expect(response.status).toBe(405); + expect(response.headers.get("allow")).toBe("GET, HEAD"); + }); + + it("rejects a resource identifier that contains a URI fragment", () => { + expect(() => + mcp({ + loginPage: "/login", + consentPage: "/consent", + resource: "https://api.example.com/mcp#fragment", + }), + ).toThrow(); + }); + }); + + describe("requireMcpAuth", () => { + it("returns 401 with the resource_metadata header when unauthenticated", async () => { + const response = await requireMcpAuth(auth, async () => { + // Never reached: the request carries no bearer token. + return new Response("unreachable"); + })(new Request(`${authServerBaseUrl}/mcp`)); + + expect(response.status).toBe(401); + expect(response.headers.get("WWW-Authenticate")).toBe( + `Bearer resource_metadata="${authServerBaseUrl}/.well-known/oauth-protected-resource/api/auth"`, + ); + }); + + it("returns 401 for an invalid bearer token", async () => { + const response = await requireMcpAuth(auth, async () => { + return new Response("unreachable"); + })( + new Request(`${authServerBaseUrl}/mcp`, { + headers: { Authorization: "Bearer invalid-token" }, + }), + ); + + expect(response.status).toBe(401); + expect(response.headers.get("WWW-Authenticate")).toBe( + `Bearer resource_metadata="${authServerBaseUrl}/.well-known/oauth-protected-resource/api/auth"`, + ); + }); + }); + + describe("authorization code + PKCE flow", () => { + let publicClientId: string; + const state = "mcp-pkce-state"; + + beforeAll(async () => { + const reg = await unauthenticatedClient.oauth2.register({ + client_name: "test-pkce-client", + redirect_uris: [redirectUri], + token_endpoint_auth_method: "none", + }); + if (!reg.data?.client_id) throw new Error("registration failed"); + publicClientId = reg.data.client_id; + }); + + it("mints an access token through authorize + consent + token exchange", async () => { + const codeVerifier = generateRandomString(64); + const { url: authUrl } = await createAuthorizationURL({ + id: providerId, + options: { + clientId: publicClientId, + redirectURI: redirectUri, + }, + redirectURI: "", + authorizationEndpoint: `${baseURL}/oauth2/authorize`, + state, + scopes: ["openid", "offline_access"], + codeVerifier, + }); + + let consentRedirectUrl = ""; + await serverClient.$fetch(authUrl.toString(), { + method: "GET", + onError(context) { + consentRedirectUrl = context.response.headers.get("Location") || ""; + }, + }); + // Untrusted self-registered clients must pass through consent. + expect(consentRedirectUrl).toContain("/consent"); + + // The consent call re-signs the authorization query the server stashed + // in the redirect search; the client reads it from window.location. + vi.stubGlobal("window", { + location: { + search: new URL(consentRedirectUrl, authServerBaseUrl).search, + }, + }); + onTestFinished(() => { + vi.unstubAllGlobals(); + }); + + const consentRes = await serverClient.oauth2.consent( + { accept: true }, + { headers, throw: true }, + ); + expect(consentRes.url).toContain("code="); + expect(consentRes.url).toContain(`state=${state}`); + + const code = new URL(consentRes.url).searchParams.get("code")!; + const { body, headers: tokenHeaders } = await authorizationCodeRequest({ + code, + codeVerifier, + redirectURI: redirectUri, + options: { + clientId: publicClientId, + redirectURI: redirectUri, + }, + }); + + // Exchange directly through customFetchImpl: the form-encoded token + // request must not pass through the client hook that injects the + // signed authorization query into the body. + const tokenResponse = await customFetchImpl(`${baseURL}/oauth2/token`, { + method: "POST", + body: body.toString(), + headers: tokenHeaders, + }); + const tokens = (await tokenResponse.json()) as { + access_token?: string; + id_token?: string; + refresh_token?: string; + token_type?: string; + scope?: string; + }; + + expect(tokens.access_token).toBeDefined(); + expect(tokens.id_token).toBeDefined(); + expect(tokens.refresh_token).toBeDefined(); + expect(tokens.token_type?.toLowerCase()).toBe("bearer"); + expect(tokens.scope).toBe("openid offline_access"); + }); + }); +}); + +/** + * The refresh_token grant on a confidential MCP client must authenticate the + * client. A confidential client cannot refresh without its secret, with the + * wrong secret, or while disabled; it must succeed when the secret is supplied + * via `Authorization: Basic`. + * + * @see https://github.com/better-auth/better-auth/security/advisories/GHSA-pw9m-5jxm-xr6h + */ +describe("mcp refresh_token grant client authentication", async () => { + const authServerBaseUrl = "http://localhost:3000"; + const rpBaseUrl = "http://localhost:5000"; + const providerId = "test"; + const redirectUri = `${rpBaseUrl}/api/auth/callback/${providerId}`; + const state = "mcp-refresh-state"; + + const { auth, signInWithTestUser, customFetchImpl } = await getTestInstance({ + baseURL: authServerBaseUrl, + plugins: [ + jwt(), + mcp({ + loginPage: "/login", + consentPage: "/consent", + silenceWarnings: { + oauthAuthServerConfig: true, + openidConfig: true, + }, + }), + ], + }); + + const { headers } = await signInWithTestUser(); + const client = createAuthClient({ + plugins: [oauthProviderClient()], + baseURL: authServerBaseUrl, + fetchOptions: { + customFetchImpl, + headers, + }, + }); + + let oauthClient: { client_id: string; client_secret: string }; + + beforeAll(async () => { + // A confidential client registered through DCR. Its default + // authorization_code grant implicitly allows refresh_token. + const created = await client.oauth2.register({ + client_name: "test-refresh-confidential-client", + redirect_uris: [redirectUri], + token_endpoint_auth_method: "client_secret_basic", + }); + if (!created.data?.client_id || !created.data.client_secret) { + throw new Error("client registration failed"); + } + oauthClient = { + client_id: created.data.client_id, + client_secret: created.data.client_secret, + }; + }); + + /** Runs the real authorize + consent + token exchange to mint a refresh token. */ + async function mintRefreshToken(): Promise { + const codeVerifier = generateRandomString(64); + const { url: authUrl } = await createAuthorizationURL({ + id: providerId, + options: { + clientId: oauthClient.client_id, + clientSecret: oauthClient.client_secret, + redirectURI: redirectUri, + }, + redirectURI: "", + authorizationEndpoint: `${authServerBaseUrl}/api/auth/oauth2/authorize`, + state, + scopes: ["openid", "offline_access"], + codeVerifier, + }); + + let redirectUrl = ""; + await client.$fetch(authUrl.toString(), { + onError(context) { + redirectUrl = context.response.headers.get("Location") || ""; + }, + }); + + // A prior grant for this client+user may let the authorize step skip + // consent and return the code directly; only run consent when redirected. + let codeRedirectUrl = redirectUrl; + if (redirectUrl.includes("/consent")) { + vi.stubGlobal("window", { + location: { + search: new URL(redirectUrl, authServerBaseUrl).search, + }, + }); + const consentRes = await client.oauth2.consent( + { accept: true }, + { headers, throw: true }, + ); + vi.unstubAllGlobals(); + codeRedirectUrl = consentRes.url; + } + + const code = new URL(codeRedirectUrl, authServerBaseUrl).searchParams.get( + "code", + ); + if (!code) throw new Error("missing authorization code"); + + const { body, headers: tokenHeaders } = await authorizationCodeRequest({ + code, + codeVerifier, + redirectURI: redirectUri, + options: { + clientId: oauthClient.client_id, + clientSecret: oauthClient.client_secret, + redirectURI: redirectUri, + }, + }); + const tokenResponse = await customFetchImpl( + `${authServerBaseUrl}/api/auth/oauth2/token`, + { + method: "POST", + body: body.toString(), + headers: tokenHeaders, + }, + ); + const tokens = (await tokenResponse.json()) as { refresh_token?: string }; + if (!tokens.refresh_token) { + throw new Error("token exchange did not return a refresh token"); + } + return tokens.refresh_token; + } + + it("rejects refresh_token grant on a confidential client without client_secret", async () => { + const refreshToken = await mintRefreshToken(); + const response = await customFetchImpl( + `${authServerBaseUrl}/api/auth/oauth2/token`, + { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: oauthClient.client_id, + }).toString(), + }, + ); + const body = await response.json().catch(() => null); + + // A confidential client that omits its secret is rejected before any + // token is issued (400, the secret is a required request parameter). + expect(response.status).toBe(400); + expect(body?.error).toBe("invalid_client"); + expect(body?.access_token).toBeUndefined(); + }); + + it("rejects refresh_token grant on a confidential client with the wrong client_secret", async () => { + const refreshToken = await mintRefreshToken(); + const response = await customFetchImpl( + `${authServerBaseUrl}/api/auth/oauth2/token`, + { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: oauthClient.client_id, + client_secret: "wrong-secret", + }).toString(), + }, + ); + const body = await response.json().catch(() => null); + + expect(response.status).toBe(401); + expect(body?.error).toBe("invalid_client"); + expect(body?.access_token).toBeUndefined(); + }); + + it("accepts refresh_token grant when client_secret comes via Authorization: Basic", async () => { + const refreshToken = await mintRefreshToken(); + const basic = `Basic ${Buffer.from( + `${oauthClient.client_id}:${oauthClient.client_secret}`, + ).toString("base64")}`; + const response = await customFetchImpl( + `${authServerBaseUrl}/api/auth/oauth2/token`, + { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + authorization: basic, + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + }).toString(), + }, + ); + const body = await response.json().catch(() => null); + + expect(response.status).toBe(200); + expect(body?.access_token).toBeDefined(); + expect(body?.refresh_token).toBeDefined(); + }); + + it("rejects refresh_token grant when the confidential client is disabled", async () => { + const refreshToken = await mintRefreshToken(); + const context = await auth.$context; + await context.adapter.update({ + model: "oauthClient", + where: [{ field: "clientId", value: oauthClient.client_id }], + update: { disabled: true }, + }); + + const response = await customFetchImpl( + `${authServerBaseUrl}/api/auth/oauth2/token`, + { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: oauthClient.client_id, + client_secret: oauthClient.client_secret, + }).toString(), + }, + ); + const body = await response.json().catch(() => null); + + // Re-enable so any later cases in this suite still operate on a live client. + await context.adapter.update({ + model: "oauthClient", + where: [{ field: "clientId", value: oauthClient.client_id }], + update: { disabled: false }, + }); + + // A disabled client is rejected outright (400) before token issuance. + expect(response.status).toBe(400); + expect(body?.error).toBe("invalid_client"); + expect(body?.access_token).toBeUndefined(); + }); + + it("rotates the refresh token on a successful confidential refresh", async () => { + const refreshToken = await mintRefreshToken(); + const { body, headers: refreshHeaders } = await refreshAccessTokenRequest({ + refreshToken, + options: { + clientId: oauthClient.client_id, + clientSecret: oauthClient.client_secret, + redirectURI: redirectUri, + }, + }); + const refreshed = await client.$fetch<{ + access_token?: string; + refresh_token?: string; + }>("/oauth2/token", { + method: "POST", + body, + headers: refreshHeaders, + }); + + expect(refreshed.data?.access_token).toBeDefined(); + expect(refreshed.data?.refresh_token).toBeDefined(); + expect(refreshed.data?.refresh_token).not.toEqual(refreshToken); + }); +}); diff --git a/packages/mcp/src/plugin.ts b/packages/mcp/src/plugin.ts new file mode 100644 index 0000000000..a09a69a0f9 --- /dev/null +++ b/packages/mcp/src/plugin.ts @@ -0,0 +1,170 @@ +import type { GenericEndpointContext } from "@better-auth/core"; +import type { + OAuthOptions, + ResourceServerMetadata, + Scope, +} from "@better-auth/oauth-provider"; +import { + getIssuer, + metadataResponse, + oauthProvider, + ResourceUriSchema, +} from "@better-auth/oauth-provider"; + +const PROTECTED_RESOURCE_METADATA_PATH = + "/.well-known/oauth-protected-resource"; +const AUTHORIZATION_SERVER_ONLY_SCOPES = new Set([ + "openid", + "profile", + "email", + "phone", + "address", + "offline_access", +]); + +/** + * Options for the {@link mcp} plugin: the full OAuth provider configuration plus + * the MCP resource identifier. + */ +export interface McpOptions extends OAuthOptions { + /** + * The protected resource identifier (RFC 8707 / RFC 9728) that access tokens + * are bound to. Published as `resource` in the protected resource metadata, + * added to `validAudiences`, and verified as the token audience. + * + * @default the resolved base URL + */ + resource?: string; +} + +/** + * Build the RFC 9728 Protected Resource Metadata document. The MCP server is the + * resource server, and its authorization server is this same provider, so + * `authorization_servers` reuses the provider issuer. Resource metadata only + * advertises scopes that apply to the protected resource itself; OIDC identity + * scopes and refresh-token scopes stay authorization-server metadata. + */ +const buildResourceServerMetadata = ( + ctx: GenericEndpointContext, + providerOptions: OAuthOptions, + resource: string | undefined, +): ResourceServerMetadata => { + const scopes = + providerOptions.advertisedMetadata?.scopes_supported ?? + providerOptions.scopes ?? + []; + const resourceScopes = scopes.filter( + (scope) => !AUTHORIZATION_SERVER_ONLY_SCOPES.has(scope), + ); + const metadata: ResourceServerMetadata = { + resource: resource ?? ctx.context.baseURL, + authorization_servers: [getIssuer(ctx, providerOptions)], + bearer_methods_supported: ["header"], + }; + if (resourceScopes.length) { + metadata.scopes_supported = [...resourceScopes]; + } + return metadata; +}; + +/** + * Model Context Protocol authorization server. + * + * `mcp()` is the OAuth 2.1 / OIDC provider ({@link oauthProvider}) configured for + * MCP: it enables dynamic client registration, binds issued tokens to the MCP + * `resource`, and, as the resource server, serves the RFC 9728 protected resource + * metadata so MCP clients discover and use it through standard OAuth discovery. + * Because it is the OAuth provider, it cannot be combined with a separate + * {@link oauthProvider}. + * + * @example + * ```ts + * import { betterAuth } from "better-auth"; + * import { jwt } from "better-auth/plugins"; + * import { mcp } from "@better-auth/mcp"; + * + * export const auth = betterAuth({ + * plugins: [jwt(), mcp({ loginPage: "/login", consentPage: "/consent" })], + * }); + * ``` + */ +export const mcp = (options: McpOptions): ReturnType => { + const { resource, ...oauthOptions } = options; + if (resource !== undefined) { + // RFC 8707: reject an invalid or fragment-containing resource before it is + // published in the protected resource metadata. + ResourceUriSchema.parse(resource); + } + const provider = oauthProvider({ + // MCP clients self-register; public clients use PKCE without a secret. + allowDynamicClientRegistration: true, + allowUnauthenticatedClientRegistration: true, + ...oauthOptions, + // RFC 8707: bind issued tokens to the MCP resource so the resource server + // can validate the audience. + validAudiences: resource + ? [...(oauthOptions.validAudiences ?? []), resource] + : oauthOptions.validAudiences, + }); + + // The MCP server is the OAuth resource server, so it serves the RFC 9728 + // protected resource metadata. The provider's discovery hook runs first + // (authorization-server and OpenID metadata); this serves the protected + // resource document at the well-known root and the resource-path-inserted + // alias, on the paths the provider leaves unhandled. + const serveProviderDiscovery = provider.onRequest; + return { + ...provider, + onRequest: async (request, ctx) => { + const handledByProvider = await serveProviderDiscovery?.(request, ctx); + if (handledByProvider) { + return handledByProvider; + } + + const pathname = new URL(request.url).pathname; + const requestPath = ctx.options.advanced?.skipTrailingSlashes + ? pathname.replace(/\/+$/, "") || "/" + : pathname; + let resourcePath = ""; + try { + resourcePath = new URL(resource ?? ctx.baseURL).pathname.replace( + /\/$/, + "", + ); + } catch { + resourcePath = ""; + } + const servedPaths = new Set([ + PROTECTED_RESOURCE_METADATA_PATH, + `${PROTECTED_RESOURCE_METADATA_PATH}${resourcePath}`, + ]); + if (!servedPaths.has(requestPath)) { + return; + } + if (request.method !== "GET" && request.method !== "HEAD") { + return { + response: new Response(null, { + status: 405, + headers: { Allow: "GET, HEAD" }, + }), + }; + } + const response = metadataResponse( + buildResourceServerMetadata( + { context: ctx } as GenericEndpointContext, + oauthOptions, + resource, + ), + ); + if (request.method === "HEAD") { + return { + response: new Response(null, { + status: response.status, + headers: response.headers, + }), + }; + } + return { response }; + }, + }; +}; diff --git a/packages/mcp/src/require-mcp-auth.test.ts b/packages/mcp/src/require-mcp-auth.test.ts new file mode 100644 index 0000000000..b367c14a7f --- /dev/null +++ b/packages/mcp/src/require-mcp-auth.test.ts @@ -0,0 +1,113 @@ +import type { JWTPayload } from "jose"; +import { describe, expect, it, vi } from "vitest"; +import { requireMcpAuth } from "./require-mcp-auth"; + +const { verifyAccessToken } = vi.hoisted(() => ({ + verifyAccessToken: vi.fn(), +})); + +vi.mock("better-auth/oauth2", () => ({ verifyAccessToken })); + +const authWith = (baseURL: string, resolvedBaseURL: string) => ({ + options: { baseURL }, + $context: Promise.resolve({ baseURL: resolvedBaseURL }), +}); + +describe("requireMcpAuth", () => { + it("verifies against the provider's resolved base URL, not the bare origin", async () => { + // Regression: the access token `iss`/`aud` are the provider's resolved + // base URL (which includes the base path). Verifying against the origin + // rejected every valid token whenever a base path was configured. + verifyAccessToken.mockResolvedValue({ sub: "user-1" } satisfies JWTPayload); + const auth = authWith( + "https://app.example.com", + "https://app.example.com/api/auth", + ); + + let verifiedSub: string | undefined; + const response = await requireMcpAuth(auth, async (_req, jwt) => { + verifiedSub = jwt.sub; + return Response.json({ ok: true }); + })( + new Request("https://app.example.com/mcp", { + headers: { Authorization: "Bearer access-token" }, + }), + ); + + expect(response.status).toBe(200); + expect(verifiedSub).toBe("user-1"); + expect(verifyAccessToken).toHaveBeenCalledWith( + "access-token", + expect.objectContaining({ + verifyOptions: expect.objectContaining({ + issuer: "https://app.example.com/api/auth", + audience: "https://app.example.com/api/auth", + }), + jwksUrl: "https://app.example.com/api/auth/jwks", + }), + ); + }); + + it("challenges with the served resource_metadata URL when no token is present", async () => { + const response = await requireMcpAuth( + authWith("https://app.example.com", "https://app.example.com/api/auth"), + async () => new Response("unreachable"), + )(new Request("https://app.example.com/mcp")); + + expect(response.status).toBe(401); + expect(response.headers.get("WWW-Authenticate")).toBe( + `Bearer resource_metadata="https://app.example.com/.well-known/oauth-protected-resource/api/auth"`, + ); + }); + + it("verifies against an explicit resource override", async () => { + verifyAccessToken.mockResolvedValue({ sub: "user-2" } satisfies JWTPayload); + await requireMcpAuth( + authWith("https://app.example.com", "https://app.example.com/api/auth"), + async () => Response.json({ ok: true }), + { resource: "https://mcp.example.com/mcp" }, + )( + new Request("https://mcp.example.com/mcp", { + headers: { Authorization: "Bearer access-token" }, + }), + ); + + expect(verifyAccessToken).toHaveBeenCalledWith( + "access-token", + expect.objectContaining({ + verifyOptions: expect.objectContaining({ + audience: "https://mcp.example.com/mcp", + }), + }), + ); + }); + + it("advertises a scope hint in the challenge when configured", async () => { + const response = await requireMcpAuth( + authWith("https://app.example.com", "https://app.example.com/api/auth"), + async () => new Response("unreachable"), + { scope: "openid profile" }, + )(new Request("https://app.example.com/mcp")); + + expect(response.status).toBe(401); + expect(response.headers.get("WWW-Authenticate")).toBe( + `Bearer resource_metadata="https://app.example.com/.well-known/oauth-protected-resource/api/auth", scope="openid profile"`, + ); + }); + + /** + * @see https://github.com/better-auth/better-auth/pull/9992 + */ + it("preserves a resource query in the metadata URL", async () => { + const response = await requireMcpAuth( + authWith("https://app.example.com", "https://app.example.com/api/auth"), + async () => new Response("unreachable"), + { resource: "https://mcp.example.com/mcp?tenant=a" }, + )(new Request("https://mcp.example.com/mcp")); + + expect(response.status).toBe(401); + expect(response.headers.get("WWW-Authenticate")).toBe( + `Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp?tenant=a"`, + ); + }); +}); diff --git a/packages/mcp/src/require-mcp-auth.ts b/packages/mcp/src/require-mcp-auth.ts new file mode 100644 index 0000000000..1b95023fea --- /dev/null +++ b/packages/mcp/src/require-mcp-auth.ts @@ -0,0 +1,132 @@ +import type { Awaitable } from "@better-auth/core"; +import { ResourceUriSchema } from "@better-auth/oauth-provider"; +import { verifyAccessToken } from "better-auth/oauth2"; +import type { BetterAuthOptions } from "better-auth/types"; +import type { JWTPayload } from "jose"; + +interface RequireMcpAuthOptions { + /** + * The protected resource identifier the access token must be audience-bound + * to. Defaults to the server's resolved base URL. + */ + resource?: string; + /** + * Expected token issuer. Defaults to the server's resolved base URL. Override + * when the JWT plugin is configured with a custom `jwt.issuer`. + */ + issuer?: string; + /** + * URL of the authorization server's JWKS. Defaults to `/jwks` under the + * server's resolved base URL. + */ + jwksUrl?: string; + /** + * Space-delimited scopes to advertise in the `WWW-Authenticate` challenge + * (RFC 6750), hinting which scopes the client should request. + */ + scope?: string; +} + +const unauthorized = ( + message: string, + resourceMetadata: string, + scope?: string, +): Response => { + let challenge = `Bearer resource_metadata="${resourceMetadata}"`; + if (scope) { + challenge += `, scope="${scope}"`; + } + return new Response( + JSON.stringify({ + jsonrpc: "2.0", + error: { code: -32000, message }, + id: null, + }), + { + status: 401, + headers: { + "Content-Type": "application/json", + "WWW-Authenticate": challenge, + }, + }, + ); +}; + +/** + * Protects an MCP server route handler. Verifies the bearer access token + * against the authorization server's JWKS (checking signature, issuer, + * audience, and expiry) and forwards the verified JWT payload to the handler. + * Unauthenticated requests receive a JSON-RPC 401 with the RFC 9728 + * `WWW-Authenticate` header so MCP clients can start the authorization flow. + * + * For a resource server that runs separately from the authorization server, or + * a server using a dynamic `baseURL`, use {@link mcpHandler} with explicit + * verification options instead. + * + * @external + */ +export const requireMcpAuth = < + Auth extends { + options: BetterAuthOptions; + $context: Promise<{ baseURL: string }>; + }, +>( + auth: Auth, + handler: (req: Request, jwt: JWTPayload) => Awaitable, + opts?: RequireMcpAuthOptions, +) => { + if (opts?.resource !== undefined) { + // RFC 8707 / RFC 9728: reject a non-absolute or fragment-containing + // resource up front, so it never reaches the metadata URL or the audience. + ResourceUriSchema.parse(opts.resource); + } + return async (req: Request): Promise => { + // The provider stamps tokens with, and binds their audience to, its + // resolved base URL (which includes the base path). Read that value from + // the auth context so the verified issuer and audience match what the + // provider issued. Override via `opts` for a custom `jwt.issuer`, a + // distinct resource, or a non-default JWKS location. + const { baseURL } = await auth.$context; + if (!baseURL) { + throw new Error( + "requireMcpAuth requires a resolvable base URL. For dynamic base URLs use `mcpHandler` with explicit verify options.", + ); + } + const issuer = opts?.issuer ?? baseURL; + const resource = opts?.resource ?? baseURL; + const jwksUrl = opts?.jwksUrl ?? `${baseURL}/jwks`; + // RFC 9728: insert the resource path after the well-known segment. The + // provider serves the document at this root-mounted location. + const resourceUrl = new URL(resource); + const resourcePath = + resourceUrl.pathname === "/" + ? "" + : resourceUrl.pathname.replace(/\/$/, ""); + const resourceMetadata = `${resourceUrl.origin}/.well-known/oauth-protected-resource${resourcePath}${resourceUrl.search}`; + + const authorization = req.headers?.get("authorization") ?? undefined; + const accessToken = authorization?.startsWith("Bearer ") + ? authorization.slice("Bearer ".length) + : authorization; + if (!accessToken?.length) { + return unauthorized( + "Unauthorized: Authentication required", + resourceMetadata, + opts?.scope, + ); + } + try { + const jwt = await verifyAccessToken(accessToken, { + verifyOptions: { issuer, audience: resource }, + jwksUrl, + }); + return handler(req, jwt); + } catch { + return unauthorized( + "Unauthorized: invalid token", + resourceMetadata, + opts?.scope, + ); + } + }; +}; diff --git a/packages/mcp/tsconfig.json b/packages/mcp/tsconfig.json new file mode 100644 index 0000000000..3edd32fff3 --- /dev/null +++ b/packages/mcp/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["./src", "./package.json"], + "compilerOptions": { + "lib": ["esnext", "dom", "dom.iterable"] + }, + "references": [ + { + "path": "../better-auth/tsconfig.json" + }, + { + "path": "../core/tsconfig.json" + }, + { + "path": "../oauth-provider/tsconfig.json" + } + ] +} diff --git a/packages/mcp/tsdown.config.ts b/packages/mcp/tsdown.config.ts new file mode 100644 index 0000000000..93e4bdf068 --- /dev/null +++ b/packages/mcp/tsdown.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + dts: { build: true, incremental: true }, + format: ["esm"], + entry: [ + "./src/index.ts", + "./src/client/index.ts", + "./src/client/adapters.ts", + ], + treeshake: true, + clean: true, +}); diff --git a/packages/mcp/vitest.config.ts b/packages/mcp/vitest.config.ts new file mode 100644 index 0000000000..bbb5405668 --- /dev/null +++ b/packages/mcp/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineProject } from "vitest/config"; + +export default defineProject({ + test: { + clearMocks: true, + restoreMocks: true, + testTimeout: 10_000, + }, +}); diff --git a/packages/oauth-provider/package.json b/packages/oauth-provider/package.json index 55df8701dc..c5012d5cf5 100644 --- a/packages/oauth-provider/package.json +++ b/packages/oauth-provider/package.json @@ -71,7 +71,6 @@ }, "devDependencies": { "@better-auth/core": "workspace:*", - "@modelcontextprotocol/sdk": "^1.27.1", "better-auth": "workspace:*", "listhen": "^1.9.0", "tsdown": "catalog:" diff --git a/packages/oauth-provider/src/client-resource.ts b/packages/oauth-provider/src/client-resource.ts index 8220de2346..1b40ff37d4 100644 --- a/packages/oauth-provider/src/client-resource.ts +++ b/packages/oauth-provider/src/client-resource.ts @@ -7,7 +7,7 @@ import type { } from "better-auth/types"; import { APIError } from "better-call"; import type { JWTPayload, JWTVerifyOptions } from "jose"; -import { handleMcpErrors } from "./mcp"; +import { raiseResourceServerChallenge } from "./resource-challenge"; import type { ResourceServerMetadata } from "./types/oauth"; import { getJwtPlugin, getOAuthProviderPlugin } from "./utils"; import { PACKAGE_VERSION } from "./version"; @@ -134,7 +134,7 @@ export const oauthProviderResourceClient = < : undefined, }); } catch (error) { - throw handleMcpErrors(error, audience, { + raiseResourceServerChallenge(error, audience, { resourceMetadataMappings: opts?.resourceMetadataMappings, }); } diff --git a/packages/oauth-provider/src/index.ts b/packages/oauth-provider/src/index.ts index 277b36dc10..d233d52bc6 100644 --- a/packages/oauth-provider/src/index.ts +++ b/packages/oauth-provider/src/index.ts @@ -1,11 +1,16 @@ -export { mcpHandler } from "./mcp"; +export { getIssuer } from "./authorize"; export { authServerMetadata, + metadataResponse, oauthProviderAuthServerMetadata, oauthProviderOpenIdConfigMetadata, oidcServerMetadata, } from "./metadata"; -export { getOAuthProviderState, oauthProvider } from "./oauth"; +export { + DEFAULT_OAUTH_SCOPES, + getOAuthProviderState, + oauthProvider, +} from "./oauth"; export type { OAuthEndpointErrorResult, OAuthEndpointRedirectContext, @@ -15,4 +20,7 @@ export type { OAuthRedirectOnError, } from "./oauth-endpoint"; export { checkOAuthClient, oauthToSchema } from "./register"; +export { raiseResourceServerChallenge } from "./resource-challenge"; export type * from "./types"; +export type { OAuthClient, ResourceServerMetadata } from "./types/oauth"; +export { ResourceUriSchema } from "./types/zod"; diff --git a/packages/oauth-provider/src/mcp.ts b/packages/oauth-provider/src/mcp.ts deleted file mode 100644 index f429ced0c8..0000000000 --- a/packages/oauth-provider/src/mcp.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { isAPIError } from "better-auth/api"; -import { verifyAccessToken } from "better-auth/oauth2"; -import { APIError } from "better-call"; -import type { JWTPayload } from "jose"; -import type { Awaitable } from "./types/helpers"; - -/** - * A request middleware handler that checks and responds with - * a WWW-Authenticate header for unauthenticated responses. - * - * @external - */ -export const mcpHandler = ( - /** Resource is the same url as the audience */ - verifyOptions: Parameters[1], - handler: (req: Request, jwt: JWTPayload) => Awaitable, - opts?: { - /** Maps non-url (ie urn, client) resources to resource_metadata */ - resourceMetadataMappings: Record; - }, -) => { - return async (req: Request) => { - const authorization = req.headers?.get("authorization") ?? undefined; - const accessToken = authorization?.startsWith("Bearer ") - ? authorization.replace("Bearer ", "") - : authorization; - try { - if (!accessToken?.length) { - throw new APIError("UNAUTHORIZED", { - message: "missing authorization header", - }); - } - const token = await verifyAccessToken(accessToken, verifyOptions); - return handler(req, token); - } catch (error) { - try { - handleMcpErrors(error, verifyOptions.verifyOptions.audience, opts); - } catch (err) { - if (err instanceof APIError) { - return new Response(err.message, { - ...err, - status: err.statusCode, - }); - } - throw new Error(String(err)); - } - throw new Error(String(error)); - } - }; -}; - -/** - * The following handles all MCP errors and API errors - * - * @internal - */ -export function handleMcpErrors( - error: unknown, - resource: string | string[], - opts?: { - /** Maps non-url (ie urn, client) resources to resource_metadata */ - resourceMetadataMappings?: Record; - }, -) { - if (isAPIError(error) && error.status === "UNAUTHORIZED") { - const _resources = Array.isArray(resource) ? resource : [resource]; - const wwwAuthenticateValue = _resources - .map((v) => { - let audiencePath: string; - if (URL.canParse?.(v)) { - const url = new URL(v); - audiencePath = url.pathname.endsWith("/") - ? url.pathname.slice(0, -1) - : url.pathname; - return `Bearer resource_metadata="${url.origin}/.well-known/oauth-protected-resource${ - audiencePath - }"`; - } else { - const resourceMetadata = opts?.resourceMetadataMappings?.[v]; - if (!resourceMetadata) { - throw new APIError("INTERNAL_SERVER_ERROR", { - message: `missing resource_metadata mapping for ${v}`, - }); - } - return `Bearer resource_metadata=${resourceMetadata}`; - } - }) - .join(", "); - throw new APIError( - "UNAUTHORIZED", - { - message: error.message, - }, - { - "WWW-Authenticate": wwwAuthenticateValue, - }, - ); - } else if (error instanceof Error) { - throw error; - } else { - throw new Error(error as unknown as string); - } -} diff --git a/packages/oauth-provider/src/oauth.ts b/packages/oauth-provider/src/oauth.ts index 67c57cca4f..4ed657890f 100644 --- a/packages/oauth-provider/src/oauth.ts +++ b/packages/oauth-provider/src/oauth.ts @@ -60,6 +60,18 @@ import { } from "./utils"; import { PACKAGE_VERSION } from "./version"; +/** + * Default scopes advertised and accepted when a configuration sets none. Shared + * with the MCP preset so the resource metadata it serves matches what the + * authorization-server metadata advertises. + */ +export const DEFAULT_OAUTH_SCOPES = [ + "openid", + "profile", + "email", + "offline_access", +] as const; + declare module "@better-auth/core" { interface BetterAuthPluginRegistry { "oauth-provider": { @@ -110,9 +122,7 @@ export const oauthProvider = >(options: O) => { // Validate scopes const scopes = new Set( - (options.scopes ?? ["openid", "profile", "email", "offline_access"]).filter( - (val) => val.length, - ), + (options.scopes ?? DEFAULT_OAUTH_SCOPES).filter((val) => val.length), ); if (clientRegistrationAllowedScopes) { for (const sc of clientRegistrationAllowedScopes) { diff --git a/packages/oauth-provider/src/resource-challenge.ts b/packages/oauth-provider/src/resource-challenge.ts new file mode 100644 index 0000000000..d714f0f6bb --- /dev/null +++ b/packages/oauth-provider/src/resource-challenge.ts @@ -0,0 +1,57 @@ +import { isAPIError } from "better-auth/api"; +import { APIError } from "better-call"; + +/** + * Raise the RFC 6750 `WWW-Authenticate: Bearer` challenge for a resource server. + * + * Given an error thrown while verifying a bearer token, this rethrows an + * unauthorized error as a `401` carrying the RFC 9728 `resource_metadata` + * pointer for each audience, and rethrows any other error unchanged. Non-URL + * audiences (for example a `urn:` or a client id) resolve their metadata URL + * through `resourceMetadataMappings`. + * + * @internal + */ +export function raiseResourceServerChallenge( + error: unknown, + resource: string | string[], + opts?: { + /** Maps non-URL (urn, client) resources to their resource_metadata URL. */ + resourceMetadataMappings?: Record; + }, +): never { + if (isAPIError(error) && error.status === "UNAUTHORIZED") { + const resources = Array.isArray(resource) ? resource : [resource]; + const wwwAuthenticateValue = resources + .map((value) => { + // Opaque absolute URIs (for example `urn:`) parse with an + // `origin` of "null", so only origin-based URLs derive the + // well-known metadata URL; everything else needs an explicit + // mapping. + const url = URL.canParse?.(value) ? new URL(value) : null; + if (url && url.origin !== "null") { + const audiencePath = url.pathname.endsWith("/") + ? url.pathname.slice(0, -1) + : url.pathname; + return `Bearer resource_metadata="${url.origin}/.well-known/oauth-protected-resource${audiencePath}${url.search}"`; + } + const resourceMetadata = opts?.resourceMetadataMappings?.[value]; + if (!resourceMetadata) { + throw new APIError("INTERNAL_SERVER_ERROR", { + message: `missing resource_metadata mapping for ${value}`, + }); + } + return `Bearer resource_metadata="${resourceMetadata}"`; + }) + .join(", "); + throw new APIError( + "UNAUTHORIZED", + { message: error.message }, + { "WWW-Authenticate": wwwAuthenticateValue }, + ); + } + if (error instanceof Error) { + throw error; + } + throw new Error(error as unknown as string); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c79e246dec..b490a1300f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1307,6 +1307,40 @@ importers: specifier: 'catalog:' version: 5.9.3 + packages/mcp: + dependencies: + '@better-auth/oauth-provider': + specifier: workspace:^ + version: link:../oauth-provider + '@better-auth/utils': + specifier: 'catalog:' + version: 0.4.1 + '@better-fetch/fetch': + specifier: 'catalog:' + version: 1.2.2 + jose: + specifier: ^6.1.3 + version: 6.1.3 + devDependencies: + '@better-auth/core': + specifier: workspace:* + version: link:../core + '@modelcontextprotocol/sdk': + specifier: ^1.27.1 + version: 1.27.1(zod@4.3.6) + better-auth: + specifier: workspace:* + version: link:../better-auth + better-call: + specifier: 'catalog:' + version: 1.3.6(zod@4.3.6) + listhen: + specifier: 1.9.0 + version: 1.9.0 + tsdown: + specifier: 'catalog:' + version: 0.21.1(@arethetypeswrong/core@0.18.2)(oxc-resolver@11.19.0)(publint@0.3.17)(typescript@5.9.3) + packages/memory-adapter: devDependencies: '@better-auth/core': @@ -1361,9 +1395,6 @@ importers: '@better-auth/core': specifier: workspace:* version: link:../core - '@modelcontextprotocol/sdk': - specifier: ^1.27.1 - version: 1.27.1(zod@4.3.6) better-auth: specifier: workspace:* version: link:../better-auth