diff --git a/src/server/auth/handlers/metadata.ts b/src/server/auth/handlers/metadata.ts index 048a4d4a..444b8505 100644 --- a/src/server/auth/handlers/metadata.ts +++ b/src/server/auth/handlers/metadata.ts @@ -1,9 +1,9 @@ import express, { RequestHandler } from "express"; -import { OAuthMetadata } from "../../../shared/auth.js"; +import { OAuthMetadata, OAuthProtectedResourceMetadata } from "../../../shared/auth.js"; import cors from 'cors'; import { allowedMethods } from "../middleware/allowedMethods.js"; -export function metadataHandler(metadata: OAuthMetadata): RequestHandler { +export function metadataHandler(metadata: OAuthMetadata | OAuthProtectedResourceMetadata): RequestHandler { // Nested router so we can configure middleware and restrict HTTP method const router = express.Router(); @@ -16,4 +16,4 @@ export function metadataHandler(metadata: OAuthMetadata): RequestHandler { }); return router; -} \ No newline at end of file +} diff --git a/src/server/auth/router.test.ts b/src/server/auth/router.test.ts index 86eda221..8ab091bb 100644 --- a/src/server/auth/router.test.ts +++ b/src/server/auth/router.test.ts @@ -245,6 +245,39 @@ describe('MCP Auth Router', () => { expect(response.body.revocation_endpoint).toBeUndefined(); expect(response.body.revocation_endpoint_auth_methods_supported).toBeUndefined(); expect(response.body.service_documentation).toBeUndefined(); + + // Verify no oauth-protected-resource endpoint added + const response2 = await supertest(minimalApp) + .get('/.well-known/oauth-protected-resource'); + + expect(response2.status).toBe(404); + }); + + it('provides protected resource metadata when protocol version is draft', async () => { + // Setup router with draft protocol version + const draftApp = express(); + const options: AuthRouterOptions = { + provider: mockProvider, + issuerUrl: new URL('https://auth.example.com'), + protocolVersion: 'DRAFT-2025-v2', + protectedResourceOptions: { + serverUrl: new URL('https://api.example.com'), + scopesSupported: ['read', 'write'], + resourceName: 'Test API' + } + }; + draftApp.use(mcpAuthRouter(options)); + + const response = await supertest(draftApp) + .get('/.well-known/oauth-protected-resource'); + + expect(response.status).toBe(200); + + // Verify protected resource metadata + expect(response.body.resource).toBe('https://api.example.com/'); + expect(response.body.authorization_servers).toContain('https://auth.example.com/'); + expect(response.body.scopes_supported).toEqual(['read', 'write']); + expect(response.body.resource_name).toBe('Test API'); }); }); @@ -358,4 +391,4 @@ describe('MCP Auth Router', () => { expect(revokeResponse.status).toBe(404); }); }); -}); \ No newline at end of file +}); diff --git a/src/server/auth/router.ts b/src/server/auth/router.ts index 49d451c2..a488ff0c 100644 --- a/src/server/auth/router.ts +++ b/src/server/auth/router.ts @@ -5,6 +5,8 @@ import { authorizationHandler, AuthorizationHandlerOptions } from "./handlers/au import { revocationHandler, RevocationHandlerOptions } from "./handlers/revoke.js"; import { metadataHandler } from "./handlers/metadata.js"; import { OAuthServerProvider } from "./provider.js"; +import { OAuthProtectedResourceMetadata } from "../../shared/auth.js"; +import { DRAFT_PROTOCOL_VERSION, LATEST_PROTOCOL_VERSION, ProtocolVersion } from "../../types.js"; export type AuthRouterOptions = { /** @@ -19,7 +21,7 @@ export type AuthRouterOptions = { /** * The base URL of the authorization server to use for the metadata endpoints. - * + * * If not provided, the issuer URL will be used as the base URL. */ baseUrl?: URL; @@ -29,37 +31,48 @@ export type AuthRouterOptions = { */ serviceDocumentationUrl?: URL; + /** + * The MCP protocol version being used, will default to the latest non-draft version. + */ + protocolVersion?: ProtocolVersion; + // Individual options per route authorizationOptions?: Omit; clientRegistrationOptions?: Omit; revocationOptions?: Omit; tokenOptions?: Omit; + protectedResourceOptions?: Omit; }; +const checkIssuerUrl = (issuer: URL): void => { + // Technically RFC 8414 does not permit a localhost HTTPS exemption, but this will be necessary for ease of testing + if (issuer.protocol !== "https:" && issuer.hostname !== "localhost" && issuer.hostname !== "127.0.0.1") { + throw new Error("Issuer URL must be HTTPS"); + } + if (issuer.hash) { + throw new Error(`Issuer URL must not have a fragment: ${issuer}`); + } + if (issuer.search) { + throw new Error(`Issuer URL must not have a query string: ${issuer}`); + } +} + /** * Installs standard MCP authorization endpoints, including dynamic client registration and token revocation (if supported). Also advertises standard authorization server metadata, for easier discovery of supported configurations by clients. - * + * * By default, rate limiting is applied to all endpoints to prevent abuse. - * + * * This router MUST be installed at the application root, like so: - * + * * const app = express(); * app.use(mcpAuthRouter(...)); */ export function mcpAuthRouter(options: AuthRouterOptions): RequestHandler { const issuer = options.issuerUrl; const baseUrl = options.baseUrl; + const mcpProtocolVersion = options.protocolVersion || LATEST_PROTOCOL_VERSION; - // Technically RFC 8414 does not permit a localhost HTTPS exemption, but this will be necessary for ease of testing - if (issuer.protocol !== "https:" && issuer.hostname !== "localhost" && issuer.hostname !== "127.0.0.1") { - throw new Error("Issuer URL must be HTTPS"); - } - if (issuer.hash) { - throw new Error("Issuer URL must not have a fragment"); - } - if (issuer.search) { - throw new Error("Issuer URL must not have a query string"); - } + checkIssuerUrl(issuer); const authorization_endpoint = "/authorize"; const token_endpoint = "/token"; @@ -84,6 +97,7 @@ export function mcpAuthRouter(options: AuthRouterOptions): RequestHandler { registration_endpoint: registration_endpoint ? new URL(registration_endpoint, baseUrl || issuer).href : undefined, }; + const router = express.Router(); router.use( @@ -98,6 +112,17 @@ export function mcpAuthRouter(options: AuthRouterOptions): RequestHandler { router.use("/.well-known/oauth-authorization-server", metadataHandler(metadata)); + if (mcpProtocolVersion === DRAFT_PROTOCOL_VERSION) { + if (!options.protectedResourceOptions) { + throw new Error(`Must specify protectedResourceOptions if using ${DRAFT_PROTOCOL_VERSION}`); + } + router.use(mcpProtectedResourceRouter({ + issuerUrl: issuer, + serviceDocumentationUrl: options.serviceDocumentationUrl, + ...options.protectedResourceOptions + })) + } + if (registration_endpoint) { router.use( registration_endpoint, @@ -116,4 +141,57 @@ export function mcpAuthRouter(options: AuthRouterOptions): RequestHandler { } return router; -} \ No newline at end of file +} + + +export type ProtectedResourceRouterOptions = { + /** + * The authorization server's issuer identifier, which is a URL that uses the "https" scheme and has no query or fragment components. + */ + issuerUrl: URL; + + /** + * The MCP server URL that is proteted. + * + */ + serverUrl: URL; + + /** + * An optional URL of a page containing human-readable information that developers might want or need to know when using the authorization server. + */ + serviceDocumentationUrl?: URL; + + /** + * A list of valid scopes for the resource. + */ + scopesSupported?: Array; + + /** + * A human readable resource name for the MCP server + */ + resourceName?: string; +}; + + +export function mcpProtectedResourceRouter(options: ProtectedResourceRouterOptions) { + const issuer = options.issuerUrl; + checkIssuerUrl(issuer); + + const router = express.Router(); + + const protectedResourceMetadata: OAuthProtectedResourceMetadata = { + resource: options.serverUrl.href, + + authorization_servers: [ + issuer.href + ], + + scopes_supported: options.scopesSupported, + resource_name: options.resourceName, + resource_documentation: options.serviceDocumentationUrl?.href, + }; + + router.use("/.well-known/oauth-protected-resource", metadataHandler(protectedResourceMetadata)); + + return router; +} diff --git a/src/shared/auth.ts b/src/shared/auth.ts index 60a28b80..d28cfa9d 100644 --- a/src/shared/auth.ts +++ b/src/shared/auth.ts @@ -109,6 +109,44 @@ export const OAuthTokenRevocationRequestSchema = z.object({ token_type_hint: z.string().optional(), }).strip(); +/** + * RFC 9728 OAuth Protected Resource Metadata + */ + export const OAuthProtectedResourceMetadataSchema = z.object({ + // REQUIRED fields + resource: z.string().url(), + + // OPTIONAL fields + authorization_servers: z.array(z.string().url()).optional(), + + jwks_uri: z.string().url().optional(), + + scopes_supported: z.array(z.string()).optional(), + + bearer_methods_supported: z.array(z.string()).optional(), + + resource_signing_alg_values_supported: z.array(z.string()).optional(), + + resource_name: z.string().optional(), + + resource_documentation: z.string().url().optional(), + + resource_policy_uri: z.string().url().optional(), + + resource_tos_uri: z.string().url().optional(), + + tls_client_certificate_bound_access_tokens: z.boolean().optional(), + + authorization_details_types_supported: z.array(z.string()).optional(), + + dpop_signing_alg_values_supported: z.array(z.string()).optional(), + + dpop_bound_access_tokens_required: z.boolean().optional(), + + // Signed metadata JWT + signed_metadata: z.string().optional() + }).strict(); + export type OAuthMetadata = z.infer; export type OAuthTokens = z.infer; export type OAuthErrorResponse = z.infer; @@ -116,4 +154,5 @@ export type OAuthClientMetadata = z.infer; export type OAuthClientInformation = z.infer; export type OAuthClientInformationFull = z.infer; export type OAuthClientRegistrationError = z.infer; -export type OAuthTokenRevocationRequest = z.infer; \ No newline at end of file +export type OAuthTokenRevocationRequest = z.infer; +export type OAuthProtectedResourceMetadata = z.infer; diff --git a/src/types.ts b/src/types.ts index 2ee0f752..9e4d2871 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,10 +1,18 @@ import { z, ZodTypeAny } from "zod"; -export const LATEST_PROTOCOL_VERSION = "2025-03-26"; +export type ProtocolVersion = + "2025-03-26" | + "2024-11-05" | + "2024-10-07" | + "DRAFT-2025-v2"; + +export const LATEST_PROTOCOL_VERSION: ProtocolVersion = "2025-03-26"; +export const DRAFT_PROTOCOL_VERSION: ProtocolVersion = "DRAFT-2025-v2"; export const SUPPORTED_PROTOCOL_VERSIONS = [ LATEST_PROTOCOL_VERSION, "2024-11-05", "2024-10-07", + DRAFT_PROTOCOL_VERSION, ]; /* JSON-RPC types */