diff --git a/README.md b/README.md index c8936c027..628e6240d 100644 --- a/README.md +++ b/README.md @@ -796,6 +796,30 @@ await server.connect(transport); To test your server, you can use the [MCP Inspector](https://github.com/modelcontextprotocol/inspector). See its README for more information. +### Node.js Web Crypto (globalThis.crypto) compatibility + +Some parts of the SDK (for example, JWT-based client authentication in `auth-extensions.ts` via `jose`) rely on the Web Crypto API exposed as `globalThis.crypto`. + +- **Node.js v19.0.0 and later**: `globalThis.crypto` is available by default. +- **Node.js v18.x**: `globalThis.crypto` may not be defined by default; in this repository we polyfill it for tests (see `vitest.setup.ts`), and you should do the same in your app if it is missing - or alternatively, run Node with `--experimental-global-webcrypto` as per your + Node version documentation. (See https://nodejs.org/dist/latest-v18.x/docs/api/globals.html#crypto ) + +If you run tests or applications on Node.js versions where `globalThis.crypto` is missing, you can polyfill it using the built-in `node:crypto` module, similar to the SDK's own `vitest.setup.ts`: + +```typescript +import { webcrypto } from 'node:crypto'; + +if (typeof globalThis.crypto === 'undefined') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).crypto = webcrypto as unknown as Crypto; +} +``` + +For production use, you can either: + +- Run on a Node.js version where `globalThis.crypto` is available by default (recommended), or +- Apply a similar polyfill early in your application's startup code when targeting older Node.js runtimes. + ## Examples ### Echo Server @@ -1636,6 +1660,64 @@ const result = await client.callTool({ }); ``` +### OAuth client authentication helpers + +For OAuth-secured MCP servers, the client `auth` module exposes a generic `OAuthClientProvider` interface, and `src/client/auth-extensions.ts` provides ready-to-use implementations for common machine-to-machine authentication flows: + +- **ClientCredentialsProvider**: Uses the `client_credentials` grant with `client_secret_basic` authentication. +- **PrivateKeyJwtProvider**: Uses the `client_credentials` grant with `private_key_jwt` client authentication, signing a JWT assertion on each token request. +- **StaticPrivateKeyJwtProvider**: Similar to `PrivateKeyJwtProvider`, but accepts a pre-built JWT assertion string via `jwtBearerAssertion` and reuses it for token requests. + +You can use these providers with the `StreamableHTTPClientTransport` and the high-level `auth()` helper: + +```typescript +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { ClientCredentialsProvider, PrivateKeyJwtProvider, StaticPrivateKeyJwtProvider } from '@modelcontextprotocol/sdk/client/auth-extensions.js'; +import { auth } from '@modelcontextprotocol/sdk/client/auth.js'; + +const serverUrl = new URL('https://mcp.example.com/'); + +// Example: client_credentials with client_secret_basic +const basicProvider = new ClientCredentialsProvider({ + clientId: process.env.CLIENT_ID!, + clientSecret: process.env.CLIENT_SECRET!, + clientName: 'example-basic-client' +}); + +// Example: client_credentials with private_key_jwt (JWT signed locally) +const privateKeyJwtProvider = new PrivateKeyJwtProvider({ + clientId: process.env.CLIENT_ID!, + privateKey: process.env.CLIENT_PRIVATE_KEY_PEM!, + algorithm: 'RS256', + clientName: 'example-private-key-jwt-client', + jwtLifetimeSeconds: 300 +}); + +// Example: client_credentials with a pre-built JWT assertion +const staticJwtProvider = new StaticPrivateKeyJwtProvider({ + clientId: process.env.CLIENT_ID!, + jwtBearerAssertion: process.env.CLIENT_ASSERTION!, + clientName: 'example-static-private-key-jwt-client' +}); + +const transport = new StreamableHTTPClientTransport(serverUrl, { + authProvider: privateKeyJwtProvider +}); + +const client = new Client({ + name: 'example-client', + version: '1.0.0' +}); + +// Perform the OAuth flow (including dynamic client registration if needed) +await auth(privateKeyJwtProvider, { serverUrl, fetchFn: transport.fetch }); + +await client.connect(transport); +``` + +If you need lower-level control, you can also use `createPrivateKeyJwtAuth()` directly to implement `addClientAuthentication` on a custom `OAuthClientProvider`. + ### Proxy Authorization Requests Upstream You can proxy OAuth requests to an external authorization provider: diff --git a/package-lock.json b/package-lock.json index 25bb172cd..bb5518e84 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", + "jose": "^6.1.1", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", @@ -3228,6 +3229,15 @@ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==" }, + "node_modules/jose": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.1.tgz", + "integrity": "sha512-GWSqjfOPf4cWOkBzw5THBjtGPhXKqYnfRBzh4Ni+ArTrQQ9unvmsA3oFLqaYKoKe5sjWmGu5wVKg9Ft1i+LQfg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", diff --git a/package.json b/package.json index cb4c74cbe..545994806 100644 --- a/package.json +++ b/package.json @@ -95,6 +95,7 @@ "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", + "jose": "^6.1.1", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", diff --git a/src/client/auth-extensions.test.ts b/src/client/auth-extensions.test.ts new file mode 100644 index 000000000..0592c28e4 --- /dev/null +++ b/src/client/auth-extensions.test.ts @@ -0,0 +1,373 @@ +import { describe, it, expect } from 'vitest'; +import { auth } from './auth.js'; +import { + ClientCredentialsProvider, + PrivateKeyJwtProvider, + StaticPrivateKeyJwtProvider, + createPrivateKeyJwtAuth +} from './auth-extensions.js'; +import type { FetchLike } from '../shared/transport.js'; + +const RESOURCE_SERVER_URL = 'https://resource.example.com/'; +const AUTH_SERVER_URL = 'https://auth.example.com'; + +function createMockFetch(onTokenRequest?: (url: URL, init: RequestInit | undefined) => void | Promise): FetchLike { + return async (input: string | URL, init?: RequestInit): Promise => { + const url = input instanceof URL ? input : new URL(input); + + // Protected resource metadata discovery + if (url.origin === RESOURCE_SERVER_URL.slice(0, -1) && url.pathname === '/.well-known/oauth-protected-resource') { + return new Response( + JSON.stringify({ + resource: RESOURCE_SERVER_URL, + authorization_servers: [AUTH_SERVER_URL] + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' } + } + ); + } + + // Authorization server metadata discovery + if (url.origin === AUTH_SERVER_URL && url.pathname === '/.well-known/oauth-authorization-server') { + return new Response( + JSON.stringify({ + issuer: AUTH_SERVER_URL, + authorization_endpoint: `${AUTH_SERVER_URL}/authorize`, + token_endpoint: `${AUTH_SERVER_URL}/token`, + response_types_supported: ['code'], + token_endpoint_auth_methods_supported: ['client_secret_basic', 'private_key_jwt'] + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' } + } + ); + } + + // Token endpoint + if (url.origin === AUTH_SERVER_URL && url.pathname === '/token') { + if (onTokenRequest) { + await onTokenRequest(url, init); + } + + return new Response( + JSON.stringify({ + access_token: 'test-access-token', + token_type: 'Bearer' + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' } + } + ); + } + + throw new Error(`Unexpected URL in mock fetch: ${url.toString()}`); + }; +} + +describe('auth-extensions providers (end-to-end with auth())', () => { + it('authenticates using ClientCredentialsProvider with client_secret_basic', async () => { + const provider = new ClientCredentialsProvider({ + clientId: 'my-client', + clientSecret: 'my-secret', + clientName: 'test-client' + }); + + const fetchMock = createMockFetch(async (_url, init) => { + const params = init?.body as URLSearchParams; + expect(params).toBeInstanceOf(URLSearchParams); + expect(params.get('grant_type')).toBe('client_credentials'); + expect(params.get('resource')).toBe(RESOURCE_SERVER_URL); + expect(params.get('client_assertion')).toBeNull(); + + const headers = new Headers(init?.headers); + const authHeader = headers.get('Authorization'); + expect(authHeader).toBeTruthy(); + + const expectedCredentials = Buffer.from('my-client:my-secret').toString('base64'); + expect(authHeader).toBe(`Basic ${expectedCredentials}`); + }); + + const result = await auth(provider, { + serverUrl: RESOURCE_SERVER_URL, + fetchFn: fetchMock + }); + + expect(result).toBe('AUTHORIZED'); + const tokens = provider.tokens(); + expect(tokens).toBeTruthy(); + expect(tokens?.access_token).toBe('test-access-token'); + }); + + it('authenticates using PrivateKeyJwtProvider with private_key_jwt', async () => { + const provider = new PrivateKeyJwtProvider({ + clientId: 'client-id', + privateKey: 'a-string-secret-at-least-256-bits-long', + algorithm: 'HS256', + clientName: 'private-key-jwt-client' + }); + + let assertionFromRequest: string | null = null; + + const fetchMock = createMockFetch(async (_url, init) => { + const params = init?.body as URLSearchParams; + expect(params).toBeInstanceOf(URLSearchParams); + expect(params.get('grant_type')).toBe('client_credentials'); + expect(params.get('resource')).toBe(RESOURCE_SERVER_URL); + + assertionFromRequest = params.get('client_assertion'); + expect(assertionFromRequest).toBeTruthy(); + expect(params.get('client_assertion_type')).toBe('urn:ietf:params:oauth:client-assertion-type:jwt-bearer'); + + const parts = assertionFromRequest!.split('.'); + expect(parts).toHaveLength(3); + + const headers = new Headers(init?.headers); + expect(headers.get('Authorization')).toBeNull(); + }); + + const result = await auth(provider, { + serverUrl: RESOURCE_SERVER_URL, + fetchFn: fetchMock + }); + + expect(result).toBe('AUTHORIZED'); + const tokens = provider.tokens(); + expect(tokens).toBeTruthy(); + expect(tokens?.access_token).toBe('test-access-token'); + expect(assertionFromRequest).toBeTruthy(); + }); + + it('fails when PrivateKeyJwtProvider is configured with an unsupported algorithm', async () => { + const provider = new PrivateKeyJwtProvider({ + clientId: 'client-id', + privateKey: 'a-string-secret-at-least-256-bits-long', + algorithm: 'none', + clientName: 'private-key-jwt-client' + }); + + const fetchMock = createMockFetch(); + + await expect( + auth(provider, { + serverUrl: RESOURCE_SERVER_URL, + fetchFn: fetchMock + }) + ).rejects.toThrow('Unsupported algorithm none'); + }); + + it('authenticates using StaticPrivateKeyJwtProvider with static client assertion', async () => { + const staticAssertion = 'header.payload.signature'; + + const provider = new StaticPrivateKeyJwtProvider({ + clientId: 'static-client', + jwtBearerAssertion: staticAssertion, + clientName: 'static-private-key-jwt-client' + }); + + const fetchMock = createMockFetch(async (_url, init) => { + const params = init?.body as URLSearchParams; + expect(params).toBeInstanceOf(URLSearchParams); + expect(params.get('grant_type')).toBe('client_credentials'); + expect(params.get('resource')).toBe(RESOURCE_SERVER_URL); + + expect(params.get('client_assertion')).toBe(staticAssertion); + expect(params.get('client_assertion_type')).toBe('urn:ietf:params:oauth:client-assertion-type:jwt-bearer'); + + const headers = new Headers(init?.headers); + expect(headers.get('Authorization')).toBeNull(); + }); + + const result = await auth(provider, { + serverUrl: RESOURCE_SERVER_URL, + fetchFn: fetchMock + }); + + expect(result).toBe('AUTHORIZED'); + const tokens = provider.tokens(); + expect(tokens).toBeTruthy(); + expect(tokens?.access_token).toBe('test-access-token'); + }); +}); + +describe('createPrivateKeyJwtAuth', () => { + const baseOptions = { + issuer: 'client-id', + subject: 'client-id', + privateKey: 'a-string-secret-at-least-256-bits-long', + alg: 'HS256' + }; + + it('creates an addClientAuthentication function that sets JWT assertion params', async () => { + const addClientAuth = createPrivateKeyJwtAuth(baseOptions); + + const headers = new Headers(); + const params = new URLSearchParams(); + + await addClientAuth(headers, params, 'https://auth.example.com/token', undefined); + + expect(params.get('client_assertion')).toBeTruthy(); + expect(params.get('client_assertion_type')).toBe('urn:ietf:params:oauth:client-assertion-type:jwt-bearer'); + + // Verify JWT structure (three dot-separated segments) + const assertion = params.get('client_assertion')!; + const parts = assertion.split('.'); + expect(parts).toHaveLength(3); + }); + + it('throws when globalThis.crypto is not available', async () => { + // Temporarily remove globalThis.crypto to simulate older Node.js runtimes + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const globalAny = globalThis as any; + const originalCrypto = globalAny.crypto; + // Use delete so that typeof globalThis.crypto === 'undefined' + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete globalAny.crypto; + + try { + const addClientAuth = createPrivateKeyJwtAuth(baseOptions); + const params = new URLSearchParams(); + + await expect(addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined)).rejects.toThrow( + 'crypto is not available, please ensure you add have Web Crypto API support for older Node.js versions' + ); + } finally { + // Restore original crypto to avoid affecting other tests + globalAny.crypto = originalCrypto; + } + }); + + it('creates a signed JWT when using a Uint8Array HMAC key', async () => { + const secret = new TextEncoder().encode('a-string-secret-at-least-256-bits-long'); + + const addClientAuth = createPrivateKeyJwtAuth({ + issuer: 'client-id', + subject: 'client-id', + privateKey: secret, + alg: 'HS256' + }); + + const params = new URLSearchParams(); + await addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined); + + const assertion = params.get('client_assertion')!; + const parts = assertion.split('.'); + expect(parts).toHaveLength(3); + }); + + it('creates a signed JWT when using a symmetric JWK key', async () => { + const jwk: Record = { + kty: 'oct', + // "a-string-secret-at-least-256-bits-long" base64url-encoded + k: 'YS1zdHJpbmctc2VjcmV0LWF0LWxlYXN0LTI1Ni1iaXRzLWxvbmc', + alg: 'HS256' + }; + + const addClientAuth = createPrivateKeyJwtAuth({ + issuer: 'client-id', + subject: 'client-id', + privateKey: jwk, + alg: 'HS256' + }); + + const params = new URLSearchParams(); + await addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined); + + const assertion = params.get('client_assertion')!; + const parts = assertion.split('.'); + expect(parts).toHaveLength(3); + }); + + it('creates a signed JWT when using an RSA PEM private key', async () => { + // Generate an RSA key pair on the fly + const jose = await import('jose'); + const { privateKey } = await jose.generateKeyPair('RS256', { extractable: true }); + const pem = await jose.exportPKCS8(privateKey); + + const addClientAuth = createPrivateKeyJwtAuth({ + issuer: 'client-id', + subject: 'client-id', + privateKey: pem, + alg: 'RS256' + }); + + const params = new URLSearchParams(); + await addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined); + + const assertion = params.get('client_assertion')!; + const parts = assertion.split('.'); + expect(parts).toHaveLength(3); + }); + + it('uses metadata.issuer as audience when available', async () => { + const addClientAuth = createPrivateKeyJwtAuth(baseOptions); + + const params = new URLSearchParams(); + await addClientAuth(new Headers(), params, 'https://auth.example.com/token', { + issuer: 'https://issuer.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'] + }); + + const assertion = params.get('client_assertion')!; + // Decode the payload to verify audience + const [, payloadB64] = assertion.split('.'); + const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString()); + expect(payload.aud).toBe('https://issuer.example.com'); + }); + + it('throws when using an unsupported algorithm', async () => { + const addClientAuth = createPrivateKeyJwtAuth({ + issuer: 'client-id', + subject: 'client-id', + privateKey: 'a-string-secret-at-least-256-bits-long', + alg: 'none' + }); + + const params = new URLSearchParams(); + await expect(addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined)).rejects.toThrow( + 'Unsupported algorithm none' + ); + }); + + it('throws when jose cannot import an invalid RSA PEM key', async () => { + const badPem = '-----BEGIN PRIVATE KEY-----\nnot-a-valid-key\n-----END PRIVATE KEY-----'; + + const addClientAuth = createPrivateKeyJwtAuth({ + issuer: 'client-id', + subject: 'client-id', + privateKey: badPem, + alg: 'RS256' + }); + + const params = new URLSearchParams(); + await expect(addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined)).rejects.toThrow( + /Invalid character/ + ); + }); + + it('throws when jose cannot import a mismatched JWK key', async () => { + const jwk: Record = { + kty: 'oct', + k: 'c2VjcmV0LWtleQ', // "secret-key" base64url + alg: 'HS256' + }; + + const addClientAuth = createPrivateKeyJwtAuth({ + issuer: 'client-id', + subject: 'client-id', + privateKey: jwk, + // Ask for an RSA algorithm with an octet key, which should cause jose.importJWK to fail + alg: 'RS256' + }); + + const params = new URLSearchParams(); + await expect(addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined)).rejects.toThrow( + /Key for the RS256 algorithm must be one of type CryptoKey, KeyObject, or JSON Web Key/ + ); + }); +}); diff --git a/src/client/auth-extensions.ts b/src/client/auth-extensions.ts new file mode 100644 index 000000000..f3908d2c2 --- /dev/null +++ b/src/client/auth-extensions.ts @@ -0,0 +1,401 @@ +/** + * OAuth provider extensions for specialized authentication flows. + * + * This module provides ready-to-use OAuthClientProvider implementations + * for common machine-to-machine authentication scenarios. + */ + +import type { JWK } from 'jose'; +import { OAuthClientInformation, OAuthClientMetadata, OAuthTokens } from '../shared/auth.js'; +import { AddClientAuthentication, OAuthClientProvider } from './auth.js'; + +/** + * Helper to produce a private_key_jwt client authentication function. + * + * Usage: + * const addClientAuth = createPrivateKeyJwtAuth({ issuer, subject, privateKey, alg, audience? }); + * // pass addClientAuth as provider.addClientAuthentication implementation + */ +export function createPrivateKeyJwtAuth(options: { + issuer: string; + subject: string; + privateKey: string | Uint8Array | Record; + alg: string; + audience?: string | URL; + lifetimeSeconds?: number; + claims?: Record; +}): AddClientAuthentication { + return async (_headers, params, url, metadata) => { + // Lazy import to avoid heavy dependency unless used + if (typeof globalThis.crypto === 'undefined') { + throw new TypeError( + 'crypto is not available, please ensure you add have Web Crypto API support for older Node.js versions (see https://github.com/modelcontextprotocol/typescript-sdk#nodejs-web-crypto-globalthiscrypto-compatibility)' + ); + } + + const jose = await import('jose'); + + const audience = String(options.audience ?? metadata?.issuer ?? url); + const lifetimeSeconds = options.lifetimeSeconds ?? 300; + + const now = Math.floor(Date.now() / 1000); + const jti = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + + const baseClaims = { + iss: options.issuer, + sub: options.subject, + aud: audience, + exp: now + lifetimeSeconds, + iat: now, + jti + }; + const claims = options.claims ? { ...baseClaims, ...options.claims } : baseClaims; + + // Import key for the requested algorithm + const alg = options.alg; + let key: unknown; + if (typeof options.privateKey === 'string') { + if (alg.startsWith('RS') || alg.startsWith('ES') || alg.startsWith('PS')) { + key = await jose.importPKCS8(options.privateKey, alg); + } else if (alg.startsWith('HS')) { + key = new TextEncoder().encode(options.privateKey); + } else { + throw new Error(`Unsupported algorithm ${alg}`); + } + } else if (options.privateKey instanceof Uint8Array) { + if (alg.startsWith('HS')) { + key = options.privateKey; + } else { + // Assume PKCS#8 DER in Uint8Array for asymmetric algorithms + key = await jose.importPKCS8(new TextDecoder().decode(options.privateKey), alg); + } + } else { + // Treat as JWK + key = await jose.importJWK(options.privateKey as JWK, alg); + } + + // Sign JWT + const assertion = await new jose.SignJWT(claims) + .setProtectedHeader({ alg, typ: 'JWT' }) + .setIssuer(options.issuer) + .setSubject(options.subject) + .setAudience(audience) + .setIssuedAt(now) + .setExpirationTime(now + lifetimeSeconds) + .setJti(jti) + .sign(key as unknown as Uint8Array | CryptoKey); + + params.set('client_assertion', assertion); + params.set('client_assertion_type', 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'); + }; +} + +/** + * Options for creating a ClientCredentialsProvider. + */ +export interface ClientCredentialsProviderOptions { + /** + * The client_id for this OAuth client. + */ + clientId: string; + + /** + * The client_secret for client_secret_basic authentication. + */ + clientSecret: string; + + /** + * Optional client name for metadata. + */ + clientName?: string; +} + +/** + * OAuth provider for client_credentials grant with client_secret_basic authentication. + * + * This provider is designed for machine-to-machine authentication where + * the client authenticates using a client_id and client_secret. + * + * @example + * const provider = new ClientCredentialsProvider({ + * clientId: 'my-client', + * clientSecret: 'my-secret' + * }); + * + * const transport = new StreamableHTTPClientTransport(serverUrl, { + * authProvider: provider + * }); + */ +export class ClientCredentialsProvider implements OAuthClientProvider { + private _tokens?: OAuthTokens; + private _clientInfo: OAuthClientInformation; + private _clientMetadata: OAuthClientMetadata; + + constructor(options: ClientCredentialsProviderOptions) { + this._clientInfo = { + client_id: options.clientId, + client_secret: options.clientSecret + }; + this._clientMetadata = { + client_name: options.clientName ?? 'client-credentials-client', + redirect_uris: [], + grant_types: ['client_credentials'], + token_endpoint_auth_method: 'client_secret_basic' + }; + } + + get redirectUrl(): undefined { + return undefined; + } + + get clientMetadata(): OAuthClientMetadata { + return this._clientMetadata; + } + + clientInformation(): OAuthClientInformation { + return this._clientInfo; + } + + saveClientInformation(info: OAuthClientInformation): void { + this._clientInfo = info; + } + + tokens(): OAuthTokens | undefined { + return this._tokens; + } + + saveTokens(tokens: OAuthTokens): void { + this._tokens = tokens; + } + + redirectToAuthorization(): void { + throw new Error('redirectToAuthorization is not used for client_credentials flow'); + } + + saveCodeVerifier(): void { + // Not used for client_credentials + } + + codeVerifier(): string { + throw new Error('codeVerifier is not used for client_credentials flow'); + } + + prepareTokenRequest(scope?: string): URLSearchParams { + const params = new URLSearchParams({ grant_type: 'client_credentials' }); + if (scope) params.set('scope', scope); + return params; + } +} + +/** + * Options for creating a PrivateKeyJwtProvider. + */ +export interface PrivateKeyJwtProviderOptions { + /** + * The client_id for this OAuth client. + */ + clientId: string; + + /** + * The private key for signing JWT assertions. + * Can be a PEM string, Uint8Array, or JWK object. + */ + privateKey: string | Uint8Array | Record; + + /** + * The algorithm to use for signing (e.g., 'RS256', 'ES256'). + */ + algorithm: string; + + /** + * Optional client name for metadata. + */ + clientName?: string; + + /** + * Optional JWT lifetime in seconds (default: 300). + */ + jwtLifetimeSeconds?: number; +} + +/** + * OAuth provider for client_credentials grant with private_key_jwt authentication. + * + * This provider is designed for machine-to-machine authentication where + * the client authenticates using a signed JWT assertion (RFC 7523 Section 2.2). + * + * @example + * const provider = new PrivateKeyJwtProvider({ + * clientId: 'my-client', + * privateKey: pemEncodedPrivateKey, + * algorithm: 'RS256' + * }); + * + * const transport = new StreamableHTTPClientTransport(serverUrl, { + * authProvider: provider + * }); + */ +export class PrivateKeyJwtProvider implements OAuthClientProvider { + private _tokens?: OAuthTokens; + private _clientInfo: OAuthClientInformation; + private _clientMetadata: OAuthClientMetadata; + addClientAuthentication: AddClientAuthentication; + + constructor(options: PrivateKeyJwtProviderOptions) { + this._clientInfo = { + client_id: options.clientId + }; + this._clientMetadata = { + client_name: options.clientName ?? 'private-key-jwt-client', + redirect_uris: [], + grant_types: ['client_credentials'], + token_endpoint_auth_method: 'private_key_jwt' + }; + this.addClientAuthentication = createPrivateKeyJwtAuth({ + issuer: options.clientId, + subject: options.clientId, + privateKey: options.privateKey, + alg: options.algorithm, + lifetimeSeconds: options.jwtLifetimeSeconds + }); + } + + get redirectUrl(): undefined { + return undefined; + } + + get clientMetadata(): OAuthClientMetadata { + return this._clientMetadata; + } + + clientInformation(): OAuthClientInformation { + return this._clientInfo; + } + + saveClientInformation(info: OAuthClientInformation): void { + this._clientInfo = info; + } + + tokens(): OAuthTokens | undefined { + return this._tokens; + } + + saveTokens(tokens: OAuthTokens): void { + this._tokens = tokens; + } + + redirectToAuthorization(): void { + throw new Error('redirectToAuthorization is not used for client_credentials flow'); + } + + saveCodeVerifier(): void { + // Not used for client_credentials + } + + codeVerifier(): string { + throw new Error('codeVerifier is not used for client_credentials flow'); + } + + prepareTokenRequest(scope?: string): URLSearchParams { + const params = new URLSearchParams({ grant_type: 'client_credentials' }); + if (scope) params.set('scope', scope); + return params; + } +} + +/** + * Options for creating a StaticPrivateKeyJwtProvider. + */ +export interface StaticPrivateKeyJwtProviderOptions { + /** + * The client_id for this OAuth client. + */ + clientId: string; + + /** + * A pre-built JWT client assertion to use for authentication. + * + * This token should already contain the appropriate claims + * (iss, sub, aud, exp, etc.) and be signed by the client's key. + */ + jwtBearerAssertion: string; + + /** + * Optional client name for metadata. + */ + clientName?: string; +} + +/** + * OAuth provider for client_credentials grant with a static private_key_jwt assertion. + * + * This provider mirrors {@link PrivateKeyJwtProvider} but instead of constructing and + * signing a JWT on each request, it accepts a pre-built JWT assertion string and + * uses it directly for authentication. + */ +export class StaticPrivateKeyJwtProvider implements OAuthClientProvider { + private _tokens?: OAuthTokens; + private _clientInfo: OAuthClientInformation; + private _clientMetadata: OAuthClientMetadata; + addClientAuthentication: AddClientAuthentication; + + constructor(options: StaticPrivateKeyJwtProviderOptions) { + this._clientInfo = { + client_id: options.clientId + }; + this._clientMetadata = { + client_name: options.clientName ?? 'static-private-key-jwt-client', + redirect_uris: [], + grant_types: ['client_credentials'], + token_endpoint_auth_method: 'private_key_jwt' + }; + + const assertion = options.jwtBearerAssertion; + this.addClientAuthentication = async (_headers, params) => { + params.set('client_assertion', assertion); + params.set('client_assertion_type', 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'); + }; + } + + get redirectUrl(): undefined { + return undefined; + } + + get clientMetadata(): OAuthClientMetadata { + return this._clientMetadata; + } + + clientInformation(): OAuthClientInformation { + return this._clientInfo; + } + + saveClientInformation(info: OAuthClientInformation): void { + this._clientInfo = info; + } + + tokens(): OAuthTokens | undefined { + return this._tokens; + } + + saveTokens(tokens: OAuthTokens): void { + this._tokens = tokens; + } + + redirectToAuthorization(): void { + throw new Error('redirectToAuthorization is not used for client_credentials flow'); + } + + saveCodeVerifier(): void { + // Not used for client_credentials + } + + codeVerifier(): string { + throw new Error('codeVerifier is not used for client_credentials flow'); + } + + prepareTokenRequest(scope?: string): URLSearchParams { + const params = new URLSearchParams({ grant_type: 'client_credentials' }); + if (scope) params.set('scope', scope); + return params; + } +} diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 38f04630a..3cd717614 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -14,6 +14,7 @@ import { selectClientAuthMethod, isHttpsUrl } from './auth.js'; +import { createPrivateKeyJwtAuth } from './auth-extensions.js'; import { InvalidClientMetadataError, ServerError } from '../server/auth/errors.js'; import { AuthorizationServerMetadata, OAuthTokens } from '../shared/auth.js'; import { expect, vi, type Mock } from 'vitest'; @@ -1209,11 +1210,11 @@ describe('OAuth Authorization', () => { headers: Headers, params: URLSearchParams, url: string | URL, - metadata: AuthorizationServerMetadata + metadata?: AuthorizationServerMetadata ) => { headers.set('Authorization', 'Basic ' + btoa(validClientInfo.client_id + ':' + validClientInfo.client_secret)); params.set('example_url', typeof url === 'string' ? url : url.toString()); - params.set('example_metadata', metadata.authorization_endpoint); + params.set('example_metadata', metadata?.authorization_endpoint ?? ''); params.set('example_param', 'example_value'); } }); @@ -1237,7 +1238,7 @@ describe('OAuth Authorization', () => { expect(body.get('code_verifier')).toBe('verifier123'); expect(body.get('client_id')).toBeNull(); expect(body.get('redirect_uri')).toBe('http://localhost:3000/callback'); - expect(body.get('example_url')).toBe('https://auth.example.com'); + expect(body.get('example_url')).toBe('https://auth.example.com/token'); expect(body.get('example_metadata')).toBe('https://auth.example.com/authorize'); expect(body.get('example_param')).toBe('example_value'); expect(body.get('client_secret')).toBeNull(); @@ -1361,13 +1362,12 @@ describe('OAuth Authorization', () => { href: 'https://auth.example.com/token' }), expect.objectContaining({ - method: 'POST', - headers: new Headers({ - 'Content-Type': 'application/x-www-form-urlencoded' - }) + method: 'POST' }) ); + const headers = mockFetch.mock.calls[0][1].headers as Headers; + expect(headers.get('Content-Type')).toBe('application/x-www-form-urlencoded'); const body = mockFetch.mock.calls[0][1].body as URLSearchParams; expect(body.get('grant_type')).toBe('refresh_token'); expect(body.get('refresh_token')).toBe('refresh123'); @@ -1417,7 +1417,7 @@ describe('OAuth Authorization', () => { expect(body.get('grant_type')).toBe('refresh_token'); expect(body.get('refresh_token')).toBe('refresh123'); expect(body.get('client_id')).toBeNull(); - expect(body.get('example_url')).toBe('https://auth.example.com'); + expect(body.get('example_url')).toBe('https://auth.example.com/token'); expect(body.get('example_metadata')).toBe('https://auth.example.com/authorize'); expect(body.get('example_param')).toBe('example_value'); expect(body.get('client_secret')).toBeNull(); @@ -1578,6 +1578,103 @@ describe('OAuth Authorization', () => { vi.clearAllMocks(); }); + it('performs client_credentials with private_key_jwt when provider has addClientAuthentication', async () => { + // Arrange: metadata discovery for PRM and AS + mockFetch.mockImplementation(url => { + const urlString = url.toString(); + + if (urlString.includes('/.well-known/oauth-protected-resource')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + resource: 'https://api.example.com/mcp-server', + authorization_servers: ['https://auth.example.com'] + }) + }); + } + + if (urlString.includes('/.well-known/oauth-authorization-server')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'] + }) + }); + } + + if (urlString.includes('/token')) { + return Promise.resolve({ + ok: true, + status: 200, + json: async () => ({ + access_token: 'cc_jwt_token', + token_type: 'bearer', + expires_in: 3600 + }) + }); + } + + return Promise.reject(new Error(`Unexpected fetch call: ${urlString}`)); + }); + + // Create a provider with client_credentials grant and addClientAuthentication + // redirectUrl returns undefined to indicate non-interactive flow + const ccProvider: OAuthClientProvider = { + get redirectUrl() { + return undefined; + }, + get clientMetadata() { + return { + redirect_uris: [], + client_name: 'Test Client', + grant_types: ['client_credentials'] + }; + }, + clientInformation: vi.fn().mockResolvedValue({ + client_id: 'client-id' + }), + tokens: vi.fn().mockResolvedValue(undefined), + saveTokens: vi.fn().mockResolvedValue(undefined), + redirectToAuthorization: vi.fn(), + saveCodeVerifier: vi.fn(), + codeVerifier: vi.fn(), + prepareTokenRequest: () => new URLSearchParams({ grant_type: 'client_credentials' }), + addClientAuthentication: createPrivateKeyJwtAuth({ + issuer: 'client-id', + subject: 'client-id', + privateKey: 'a-string-secret-at-least-256-bits-long', + alg: 'HS256' + }) + }; + + const result = await auth(ccProvider, { + serverUrl: 'https://api.example.com/mcp-server' + }); + + expect(result).toBe('AUTHORIZED'); + + // Find the token request + const tokenCall = mockFetch.mock.calls.find(call => call[0].toString().includes('/token')); + expect(tokenCall).toBeDefined(); + + const [, init] = tokenCall!; + const body = init.body as URLSearchParams; + + // grant_type MUST be client_credentials, not the JWT-bearer grant + expect(body.get('grant_type')).toBe('client_credentials'); + // private_key_jwt client authentication parameters + expect(body.get('client_assertion_type')).toBe('urn:ietf:params:oauth:client-assertion-type:jwt-bearer'); + expect(body.get('client_assertion')).toBeTruthy(); + // resource parameter included based on PRM + expect(body.get('resource')).toBe('https://api.example.com/mcp-server'); + }); + it('falls back to /.well-known/oauth-authorization-server when no protected-resource-metadata', async () => { // Setup: First call to protected resource metadata fails (404) // Second call to auth server metadata succeeds diff --git a/src/client/auth.ts b/src/client/auth.ts index ae47261eb..4c82b5114 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -30,6 +30,16 @@ import { } from '../server/auth/errors.js'; import { FetchLike } from '../shared/transport.js'; +/** + * Function type for adding client authentication to token requests. + */ +export type AddClientAuthentication = ( + headers: Headers, + params: URLSearchParams, + url: string | URL, + metadata?: AuthorizationServerMetadata +) => void | Promise; + /** * Implements an end-to-end OAuth client to be used with one MCP server. * @@ -40,8 +50,10 @@ import { FetchLike } from '../shared/transport.js'; export interface OAuthClientProvider { /** * The URL to redirect the user agent to after authorization. + * Return undefined for non-interactive flows that don't require user interaction + * (e.g., client_credentials, jwt-bearer). */ - get redirectUrl(): string | URL; + get redirectUrl(): string | URL | undefined; /** * External URL the server should use to fetch client metadata document @@ -122,12 +134,7 @@ export interface OAuthClientProvider { * @param url - The token endpoint URL being called * @param metadata - Optional OAuth metadata for the server, which may include supported authentication methods */ - addClientAuthentication?( - headers: Headers, - params: URLSearchParams, - url: string | URL, - metadata?: AuthorizationServerMetadata - ): void | Promise; + addClientAuthentication?: AddClientAuthentication; /** * If defined, overrides the selection and validation of the @@ -144,6 +151,44 @@ export interface OAuthClientProvider { * This avoids requiring the user to intervene manually. */ invalidateCredentials?(scope: 'all' | 'client' | 'tokens' | 'verifier'): void | Promise; + + /** + * Prepares grant-specific parameters for a token request. + * + * This optional method allows providers to customize the token request based on + * the grant type they support. When implemented, it returns the grant type and + * any grant-specific parameters needed for the token exchange. + * + * If not implemented, the default behavior depends on the flow: + * - For authorization code flow: uses code, code_verifier, and redirect_uri + * - For client_credentials: detected via grant_types in clientMetadata + * + * @param scope - Optional scope to request + * @returns Grant type and parameters, or undefined to use default behavior + * + * @example + * // For client_credentials grant: + * prepareTokenRequest(scope) { + * return { + * grantType: 'client_credentials', + * params: scope ? { scope } : {} + * }; + * } + * + * @example + * // For authorization_code grant (default behavior): + * async prepareTokenRequest() { + * return { + * grantType: 'authorization_code', + * params: { + * code: this.authorizationCode, + * code_verifier: await this.codeVerifier(), + * redirect_uri: String(this.redirectUrl) + * } + * }; + * } + */ + prepareTokenRequest?(scope?: string): URLSearchParams | Promise | undefined; } export type AuthResult = 'AUTHORIZED' | 'REDIRECT'; @@ -419,18 +464,16 @@ async function authInternal( } } - // Exchange authorization code for tokens - if (authorizationCode !== undefined) { - const codeVerifier = await provider.codeVerifier(); - const tokens = await exchangeAuthorization(authorizationServerUrl, { + // Non-interactive flows (e.g., client_credentials, jwt-bearer) don't need a redirect URL + const nonInteractiveFlow = !provider.redirectUrl; + + // Exchange authorization code for tokens, or fetch tokens directly for non-interactive flows + if (authorizationCode !== undefined || nonInteractiveFlow) { + const tokens = await fetchToken(provider, authorizationServerUrl, { metadata, - clientInformation, - authorizationCode, - codeVerifier, - redirectUri: provider.redirectUrl, resource, - addClientAuthentication: provider.addClientAuthentication, - fetchFn: fetchFn + authorizationCode, + fetchFn }); await provider.saveTokens(tokens); @@ -967,77 +1010,74 @@ export async function startAuthorization( } /** - * Exchanges an authorization code for an access token with the given server. + * Prepares token request parameters for an authorization code exchange. * - * Supports multiple client authentication methods as specified in OAuth 2.1: - * - Automatically selects the best authentication method based on server support - * - Falls back to appropriate defaults when server metadata is unavailable + * This is the default implementation used by fetchToken when the provider + * doesn't implement prepareTokenRequest. * - * @param authorizationServerUrl - The authorization server's base URL - * @param options - Configuration object containing client info, auth code, etc. - * @returns Promise resolving to OAuth tokens - * @throws {Error} When token exchange fails or authentication is invalid + * @param authorizationCode - The authorization code received from the authorization endpoint + * @param codeVerifier - The PKCE code verifier + * @param redirectUri - The redirect URI used in the authorization request + * @returns URLSearchParams for the authorization_code grant */ -export async function exchangeAuthorization( +export function prepareAuthorizationCodeRequest( + authorizationCode: string, + codeVerifier: string, + redirectUri: string | URL +): URLSearchParams { + return new URLSearchParams({ + grant_type: 'authorization_code', + code: authorizationCode, + code_verifier: codeVerifier, + redirect_uri: String(redirectUri) + }); +} + +/** + * Internal helper to execute a token request with the given parameters. + * Used by exchangeAuthorization, refreshAuthorization, and fetchToken. + */ +async function executeTokenRequest( authorizationServerUrl: string | URL, { metadata, + tokenRequestParams, clientInformation, - authorizationCode, - codeVerifier, - redirectUri, - resource, addClientAuthentication, + resource, fetchFn }: { metadata?: AuthorizationServerMetadata; - clientInformation: OAuthClientInformationMixed; - authorizationCode: string; - codeVerifier: string; - redirectUri: string | URL; - resource?: URL; + tokenRequestParams: URLSearchParams; + clientInformation?: OAuthClientInformationMixed; addClientAuthentication?: OAuthClientProvider['addClientAuthentication']; + resource?: URL; fetchFn?: FetchLike; } ): Promise { - const grantType = 'authorization_code'; - const tokenUrl = metadata?.token_endpoint ? new URL(metadata.token_endpoint) : new URL('/token', authorizationServerUrl); - if (metadata?.grant_types_supported && !metadata.grant_types_supported.includes(grantType)) { - throw new Error(`Incompatible auth server: does not support grant type ${grantType}`); - } - - // Exchange code for tokens const headers = new Headers({ 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }); - const params = new URLSearchParams({ - grant_type: grantType, - code: authorizationCode, - code_verifier: codeVerifier, - redirect_uri: String(redirectUri) - }); + + if (resource) { + tokenRequestParams.set('resource', resource.href); + } if (addClientAuthentication) { - addClientAuthentication(headers, params, authorizationServerUrl, metadata); - } else { - // Determine and apply client authentication method + await addClientAuthentication(headers, tokenRequestParams, tokenUrl, metadata); + } else if (clientInformation) { const supportedMethods = metadata?.token_endpoint_auth_methods_supported ?? []; const authMethod = selectClientAuthMethod(clientInformation, supportedMethods); - - applyClientAuthentication(authMethod, clientInformation, headers, params); - } - - if (resource) { - params.set('resource', resource.href); + applyClientAuthentication(authMethod, clientInformation as OAuthClientInformation, headers, tokenRequestParams); } const response = await (fetchFn ?? fetch)(tokenUrl, { method: 'POST', headers, - body: params + body: tokenRequestParams }); if (!response.ok) { @@ -1047,6 +1087,52 @@ export async function exchangeAuthorization( return OAuthTokensSchema.parse(await response.json()); } +/** + * Exchanges an authorization code for an access token with the given server. + * + * Supports multiple client authentication methods as specified in OAuth 2.1: + * - Automatically selects the best authentication method based on server support + * - Falls back to appropriate defaults when server metadata is unavailable + * + * @param authorizationServerUrl - The authorization server's base URL + * @param options - Configuration object containing client info, auth code, etc. + * @returns Promise resolving to OAuth tokens + * @throws {Error} When token exchange fails or authentication is invalid + */ +export async function exchangeAuthorization( + authorizationServerUrl: string | URL, + { + metadata, + clientInformation, + authorizationCode, + codeVerifier, + redirectUri, + resource, + addClientAuthentication, + fetchFn + }: { + metadata?: AuthorizationServerMetadata; + clientInformation: OAuthClientInformationMixed; + authorizationCode: string; + codeVerifier: string; + redirectUri: string | URL; + resource?: URL; + addClientAuthentication?: OAuthClientProvider['addClientAuthentication']; + fetchFn?: FetchLike; + } +): Promise { + const tokenRequestParams = prepareAuthorizationCodeRequest(authorizationCode, codeVerifier, redirectUri); + + return executeTokenRequest(authorizationServerUrl, { + metadata, + tokenRequestParams, + clientInformation, + addClientAuthentication, + resource, + fetchFn + }); +} + /** * Exchange a refresh token for an updated access token. * @@ -1077,52 +1163,96 @@ export async function refreshAuthorization( fetchFn?: FetchLike; } ): Promise { - const grantType = 'refresh_token'; - - let tokenUrl: URL; - if (metadata) { - tokenUrl = new URL(metadata.token_endpoint); - - if (metadata.grant_types_supported && !metadata.grant_types_supported.includes(grantType)) { - throw new Error(`Incompatible auth server: does not support grant type ${grantType}`); - } - } else { - tokenUrl = new URL('/token', authorizationServerUrl); - } - - // Exchange refresh token - const headers = new Headers({ - 'Content-Type': 'application/x-www-form-urlencoded' - }); - const params = new URLSearchParams({ - grant_type: grantType, + const tokenRequestParams = new URLSearchParams({ + grant_type: 'refresh_token', refresh_token: refreshToken }); - if (addClientAuthentication) { - addClientAuthentication(headers, params, authorizationServerUrl, metadata); - } else { - // Determine and apply client authentication method - const supportedMethods = metadata?.token_endpoint_auth_methods_supported ?? []; - const authMethod = selectClientAuthMethod(clientInformation, supportedMethods); + const tokens = await executeTokenRequest(authorizationServerUrl, { + metadata, + tokenRequestParams, + clientInformation, + addClientAuthentication, + resource, + fetchFn + }); - applyClientAuthentication(authMethod, clientInformation, headers, params); - } + // Preserve original refresh token if server didn't return a new one + return { refresh_token: refreshToken, ...tokens }; +} - if (resource) { - params.set('resource', resource.href); +/** + * Unified token fetching that works with any grant type via provider.prepareTokenRequest(). + * + * This function provides a single entry point for obtaining tokens regardless of the + * OAuth grant type. The provider's prepareTokenRequest() method determines which grant + * to use and supplies the grant-specific parameters. + * + * @param provider - OAuth client provider that implements prepareTokenRequest() + * @param authorizationServerUrl - The authorization server's base URL + * @param options - Configuration for the token request + * @returns Promise resolving to OAuth tokens + * @throws {Error} When provider doesn't implement prepareTokenRequest or token fetch fails + * + * @example + * // Provider for client_credentials: + * class MyProvider implements OAuthClientProvider { + * prepareTokenRequest(scope) { + * const params = new URLSearchParams({ grant_type: 'client_credentials' }); + * if (scope) params.set('scope', scope); + * return params; + * } + * // ... other methods + * } + * + * const tokens = await fetchToken(provider, authServerUrl, { metadata }); + */ +export async function fetchToken( + provider: OAuthClientProvider, + authorizationServerUrl: string | URL, + { + metadata, + resource, + authorizationCode, + fetchFn + }: { + metadata?: AuthorizationServerMetadata; + resource?: URL; + /** Authorization code for the default authorization_code grant flow */ + authorizationCode?: string; + fetchFn?: FetchLike; + } = {} +): Promise { + const scope = provider.clientMetadata.scope; + + // Use provider's prepareTokenRequest if available, otherwise fall back to authorization_code + let tokenRequestParams: URLSearchParams | undefined; + if (provider.prepareTokenRequest) { + tokenRequestParams = await provider.prepareTokenRequest(scope); } - const response = await (fetchFn ?? fetch)(tokenUrl, { - method: 'POST', - headers, - body: params - }); - if (!response.ok) { - throw await parseErrorResponse(response); + // Default to authorization_code grant if no custom prepareTokenRequest + if (!tokenRequestParams) { + if (!authorizationCode) { + throw new Error('Either provider.prepareTokenRequest() or authorizationCode is required'); + } + if (!provider.redirectUrl) { + throw new Error('redirectUrl is required for authorization_code flow'); + } + const codeVerifier = await provider.codeVerifier(); + tokenRequestParams = prepareAuthorizationCodeRequest(authorizationCode, codeVerifier, provider.redirectUrl); } - return OAuthTokensSchema.parse({ refresh_token: refreshToken, ...(await response.json()) }); + const clientInformation = await provider.clientInformation(); + + return executeTokenRequest(authorizationServerUrl, { + metadata, + tokenRequestParams, + clientInformation: clientInformation ?? undefined, + addClientAuthentication: provider.addClientAuthentication, + resource, + fetchFn + }); } /** diff --git a/src/examples/README.md b/src/examples/README.md index 0dc6867ff..f46ae83e3 100644 --- a/src/examples/README.md +++ b/src/examples/README.md @@ -42,6 +42,12 @@ Example client with OAuth: npx tsx src/examples/client/simpleOAuthClient.ts ``` +Client credentials (machine-to-machine) example: + +```bash +npx tsx src/examples/client/simpleClientCredentials.ts +``` + ### Backwards Compatible Client A client that implements backwards compatibility according to the [MCP specification](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#backwards-compatibility), allowing it to work with both new and legacy servers. This client demonstrates: diff --git a/src/examples/client/simpleClientCredentials.ts b/src/examples/client/simpleClientCredentials.ts new file mode 100644 index 000000000..7defcc41f --- /dev/null +++ b/src/examples/client/simpleClientCredentials.ts @@ -0,0 +1,82 @@ +#!/usr/bin/env node + +/** + * Example demonstrating client_credentials grant for machine-to-machine authentication. + * + * Supports two authentication methods based on environment variables: + * + * 1. client_secret_basic (default): + * MCP_CLIENT_ID - OAuth client ID (required) + * MCP_CLIENT_SECRET - OAuth client secret (required) + * + * 2. private_key_jwt (when MCP_CLIENT_PRIVATE_KEY_PEM is set): + * MCP_CLIENT_ID - OAuth client ID (required) + * MCP_CLIENT_PRIVATE_KEY_PEM - PEM-encoded private key for JWT signing (required) + * MCP_CLIENT_ALGORITHM - Signing algorithm (default: RS256) + * + * Common: + * MCP_SERVER_URL - Server URL (default: http://localhost:3000/mcp) + */ + +import { Client } from '../../client/index.js'; +import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; +import { ClientCredentialsProvider, PrivateKeyJwtProvider } from '../../client/auth-extensions.js'; +import { OAuthClientProvider } from '../../client/auth.js'; + +const DEFAULT_SERVER_URL = process.env.MCP_SERVER_URL || 'http://localhost:3000/mcp'; + +function createProvider(): OAuthClientProvider { + const clientId = process.env.MCP_CLIENT_ID; + if (!clientId) { + console.error('MCP_CLIENT_ID environment variable is required'); + process.exit(1); + } + + // If private key is provided, use private_key_jwt authentication + const privateKeyPem = process.env.MCP_CLIENT_PRIVATE_KEY_PEM; + if (privateKeyPem) { + const algorithm = process.env.MCP_CLIENT_ALGORITHM || 'RS256'; + console.log('Using private_key_jwt authentication'); + return new PrivateKeyJwtProvider({ + clientId, + privateKey: privateKeyPem, + algorithm + }); + } + + // Otherwise, use client_secret_basic authentication + const clientSecret = process.env.MCP_CLIENT_SECRET; + if (!clientSecret) { + console.error('MCP_CLIENT_SECRET or MCP_CLIENT_PRIVATE_KEY_PEM environment variable is required'); + process.exit(1); + } + + console.log('Using client_secret_basic authentication'); + return new ClientCredentialsProvider({ + clientId, + clientSecret + }); +} + +async function main() { + const provider = createProvider(); + + const client = new Client({ name: 'client-credentials-example', version: '1.0.0' }, { capabilities: {} }); + + const transport = new StreamableHTTPClientTransport(new URL(DEFAULT_SERVER_URL), { + authProvider: provider + }); + + await client.connect(transport); + console.log('Connected successfully.'); + + const tools = await client.listTools(); + console.log('Available tools:', tools.tools.map(t => t.name).join(', ') || '(none)'); + + await transport.close(); +} + +main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/src/server/auth/handlers/revoke.test.ts b/src/server/auth/handlers/revoke.test.ts index 35fad72fd..6e60e905b 100644 --- a/src/server/auth/handlers/revoke.test.ts +++ b/src/server/auth/handlers/revoke.test.ts @@ -114,6 +114,7 @@ describe('Revocation Handler', () => { } throw new InvalidTokenError('Token is invalid or expired'); } + // No revokeToken method }; diff --git a/src/server/auth/handlers/token.ts b/src/server/auth/handlers/token.ts index 75a20329d..4cc4e8ab8 100644 --- a/src/server/auth/handlers/token.ts +++ b/src/server/auth/handlers/token.ts @@ -135,10 +135,8 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand res.status(200).json(tokens); break; } - - // Not supported right now - //case "client_credentials": - + // Additional auth methods will not be added on the server side of the SDK. + case 'client_credentials': default: throw new UnsupportedGrantTypeError('The grant type is not supported by this authorization server.'); } diff --git a/src/server/auth/middleware/clientAuth.ts b/src/server/auth/middleware/clientAuth.ts index 52611a660..6cc6a1923 100644 --- a/src/server/auth/middleware/clientAuth.ts +++ b/src/server/auth/middleware/clientAuth.ts @@ -32,26 +32,18 @@ export function authenticateClient({ clientsStore }: ClientAuthenticationMiddlew if (!result.success) { throw new InvalidRequestError(String(result.error)); } - const { client_id, client_secret } = result.data; const client = await clientsStore.getClient(client_id); if (!client) { throw new InvalidClientError('Invalid client_id'); } - - // If client has a secret, validate it if (client.client_secret) { - // Check if client_secret is required but not provided if (!client_secret) { throw new InvalidClientError('Client secret is required'); } - - // Check if client_secret matches if (client.client_secret !== client_secret) { throw new InvalidClientError('Invalid client_secret'); } - - // Check if client_secret has expired if (client.client_secret_expires_at && client.client_secret_expires_at < Math.floor(Date.now() / 1000)) { throw new InvalidClientError('Client secret has expired'); } diff --git a/vitest.config.ts b/vitest.config.ts index 2af7cfb6c..35997ee0f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -3,6 +3,7 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { globals: true, - environment: 'node' + environment: 'node', + setupFiles: ['./vitest.setup.ts'] } }); diff --git a/vitest.setup.ts b/vitest.setup.ts new file mode 100644 index 000000000..820dcbd89 --- /dev/null +++ b/vitest.setup.ts @@ -0,0 +1,8 @@ +import { webcrypto } from 'node:crypto'; + +// Polyfill globalThis.crypto for environments (e.g. Node 18) where it is not defined. +// This is necessary for the tests to run in Node 18, specifically for the jose library, which relies on the globalThis.crypto object. +if (typeof globalThis.crypto === 'undefined') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).crypto = webcrypto as unknown as Crypto; +}