diff --git a/package-lock.json b/package-lock.json index b6720b978..aefa421f9 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", @@ -3132,6 +3133,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 9aa77ff2e..953b2c6b1 100644 --- a/package.json +++ b/package.json @@ -87,6 +87,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.test.ts b/src/client/auth.test.ts index 0e3a544a2..450890b2a 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -1199,7 +1199,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(); @@ -1379,7 +1379,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(); @@ -1540,6 +1540,86 @@ describe('OAuth Authorization', () => { vi.clearAllMocks(); }); + it('performs client_credentials with private_key_jwt when jwtBearerOptions are provided', 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}`)); + }); + + // Provider: no existing client info or tokens + (mockProvider.clientInformation as Mock).mockResolvedValue({ + client_id: 'client-id' + }); + (mockProvider.tokens as Mock).mockResolvedValue(undefined); + (mockProvider.saveTokens as Mock).mockResolvedValue(undefined); + + const result = await auth(mockProvider, { + serverUrl: 'https://api.example.com/mcp-server', + jwtBearerOptions: { + issuer: 'client-id', + subject: 'client-id', + privateKey: 'a-string-secret-at-least-256-bits-long', + alg: 'HS256' + } + }); + + 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 536ff6859..8a02b04ae 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -10,7 +10,9 @@ import { OAuthProtectedResourceMetadata, OAuthErrorResponseSchema, AuthorizationServerMetadata, - OpenIdProviderDiscoveryMetadataSchema + OpenIdProviderDiscoveryMetadataSchema, + JwtAssertionOptions, + isJwtPrebuiltAssertion } from '../shared/auth.js'; import { OAuthClientInformationFullSchema, @@ -29,6 +31,7 @@ import { UnauthorizedClientError } from '../server/auth/errors.js'; import { FetchLike } from '../shared/transport.js'; +import type { JWK } from 'jose'; /** * Implements an end-to-end OAuth client to be used with one MCP server. @@ -317,6 +320,15 @@ export async function auth( scope?: string; resourceMetadataUrl?: URL; fetchFn?: FetchLike; + /** + * Optional JWT assertion options for performing an RFC 7523 Section 2.2 private_key_jwt + * client authentication with a client_credentials grant. + * + * When provided, and no valid/refreshable tokens are available, auth() will + * attempt a client_credentials grant with private_key_jwt client authentication + * before falling back to other flows. + */ + jwtBearerOptions?: JwtAssertionOptions; } ): Promise { try { @@ -343,13 +355,15 @@ async function authInternal( authorizationCode, scope, resourceMetadataUrl, - fetchFn + fetchFn, + jwtBearerOptions }: { serverUrl: string | URL; authorizationCode?: string; scope?: string; resourceMetadataUrl?: URL; fetchFn?: FetchLike; + jwtBearerOptions?: JwtAssertionOptions; } ): Promise { let resourceMetadata: OAuthProtectedResourceMetadata | undefined; @@ -465,6 +479,50 @@ async function authInternal( } } + // Attempt client_credentials grant with private_key_jwt client authentication for M2M + // when explicitly configured via jwtBearerOptions (RFC 7523 Section 2.2). + if (jwtBearerOptions) { + const jwtTokens = await exchangeClientCredentials(authorizationServerUrl, { + metadata, + clientInformation, + scope: scope || provider.clientMetadata.scope, + resource, + addClientAuthentication: async (_headers, params, url, md) => { + const assertion = await createJwtBearerAssertion(url, md, jwtBearerOptions); + params.set('client_assertion', assertion); + params.set('client_assertion_type', 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'); + }, + fetchFn + }); + await provider.saveTokens(jwtTokens); + return 'AUTHORIZED'; + } + + // Attempt client_credentials grant for M2M if supported by client configuration + { + const requestedGrantTypes = provider.clientMetadata.grant_types ?? []; + const registeredGrantTypes = + 'grant_types' in (clientInformation as OAuthClientInformationFull) && + (clientInformation as Partial).grant_types + ? (clientInformation as OAuthClientInformationFull).grant_types! + : []; + const supportsClientCredentials = + requestedGrantTypes.includes('client_credentials') || registeredGrantTypes.includes('client_credentials'); + + if (supportsClientCredentials) { + const ccTokens = await exchangeClientCredentials(authorizationServerUrl, { + metadata, + clientInformation, + scope: scope || provider.clientMetadata.scope, + resource, + addClientAuthentication: provider.addClientAuthentication, + fetchFn + }); + await provider.saveTokens(ccTokens); + return 'AUTHORIZED'; + } + } + const state = provider.state ? await provider.state() : undefined; // Start new authorization flow @@ -1016,7 +1074,7 @@ export async function exchangeAuthorization( }); if (addClientAuthentication) { - addClientAuthentication(headers, params, authorizationServerUrl, metadata); + await addClientAuthentication(headers, params, tokenUrl, metadata); } else { // Determine and apply client authentication method const supportedMethods = metadata?.token_endpoint_auth_methods_supported ?? []; @@ -1095,7 +1153,7 @@ export async function refreshAuthorization( }); if (addClientAuthentication) { - addClientAuthentication(headers, params, authorizationServerUrl, metadata); + await addClientAuthentication(headers, params, tokenUrl, metadata); } else { // Determine and apply client authentication method const supportedMethods = metadata?.token_endpoint_auth_methods_supported ?? []; @@ -1120,6 +1178,149 @@ export async function refreshAuthorization( return OAuthTokensSchema.parse({ refresh_token: refreshToken, ...(await response.json()) }); } +/** + * Exchange client credentials for an access token (client_credentials grant). + * + * Applies client authentication based on server metadata and client configuration: + * - Uses provider.addClientAuthentication when provided (e.g., private_key_jwt) + * - Otherwise selects between client_secret_basic and client_secret_post + * + * Includes RFC 8707 resource parameter only when a protected resource was discovered. + */ +export async function exchangeClientCredentials( + authorizationServerUrl: string | URL, + { + metadata, + clientInformation, + scope, + resource, + addClientAuthentication, + fetchFn + }: { + metadata?: AuthorizationServerMetadata; + clientInformation: OAuthClientInformationMixed; + scope?: string; + resource?: URL; + addClientAuthentication?: OAuthClientProvider['addClientAuthentication']; + fetchFn?: FetchLike; + } +): Promise { + const grantType = 'client_credentials'; + + 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}`); + } + + const headers = new Headers({ + 'Content-Type': 'application/x-www-form-urlencoded' + }); + const params = new URLSearchParams({ + grant_type: grantType + }); + + if (scope) { + params.set('scope', scope); + } + + if (resource) { + params.set('resource', resource.href); + } + + if (addClientAuthentication) { + await addClientAuthentication(headers, params, tokenUrl, metadata); + } else { + const supportedMethods = metadata?.token_endpoint_auth_methods_supported ?? []; + const authMethod = selectClientAuthMethod(clientInformation, supportedMethods); + applyClientAuthentication(authMethod, clientInformation as OAuthClientInformation, headers, params); + } + + const response = await (fetchFn ?? fetch)(tokenUrl, { + method: 'POST', + headers, + body: params + }); + if (!response.ok) { + throw await parseErrorResponse(response); + } + + return OAuthTokensSchema.parse(await response.json()); +} + +/** + * Creates a JWT assertion suitable for RFC 7523 Section 2.2 private_key_jwt + * client authentication. + * + * If `options.assertion` is provided, it is returned as-is without signing. + */ +export async function createJwtBearerAssertion( + authorizationServerUrl: string | URL, + metadata: AuthorizationServerMetadata | undefined, + options: JwtAssertionOptions +): Promise { + if (isJwtPrebuiltAssertion(options)) { + return options.assertion; + } + + // Lazy import to avoid heavy dependency unless used + const jose = await import('jose'); + + const audience = String(options.audience ?? metadata?.issuer ?? authorizationServerUrl); + 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; + + 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); + } + + return 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); +} + +/** + * Exchange a JWT assertion for an access token using the RFC 7523 JWT-bearer grant. + * + * This is a lower-level helper that can be used by higher-level clients for M2M + * scenarios where no interactive user authorization is required. + */ /** * Performs OAuth 2.0 Dynamic Client Registration according to RFC 7591. */ @@ -1161,3 +1362,78 @@ export async function registerClient( return OAuthClientInformationFullSchema.parse(await response.json()); } + +/** + * 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; +}): OAuthClientProvider['addClientAuthentication'] { + return async (_headers, params, url, metadata) => { + // Lazy import to avoid heavy dependency unless used + 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'); + }; +} diff --git a/src/client/clientCredentials.test.ts b/src/client/clientCredentials.test.ts new file mode 100644 index 000000000..6c15f837a --- /dev/null +++ b/src/client/clientCredentials.test.ts @@ -0,0 +1,89 @@ +import { exchangeClientCredentials } from './auth.js'; +import type { AuthorizationServerMetadata, OAuthClientInformation } from '../shared/auth.js'; + +describe('exchangeClientCredentials', () => { + it('posts client_credentials with client_secret_post and scope/resource', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + access_token: 'cc_token', + token_type: 'bearer', + expires_in: 3600 + }) + }); + + const metadata: AuthorizationServerMetadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + response_types_supported: ['code'] + }; + + const clientInformation: OAuthClientInformation = { + client_id: 'c1', + client_secret: 's1' + }; + + const tokens = await exchangeClientCredentials('https://auth.example.com', { + metadata, + clientInformation, + scope: 'read write', + resource: new URL('https://api.example.com/mcp'), + fetchFn: mockFetch + }); + + expect(tokens.access_token).toBe('cc_token'); + expect(mockFetch).toHaveBeenCalledTimes(1); + const [url, init] = mockFetch.mock.calls[0]; + expect(String(url)).toBe('https://auth.example.com/token'); + const body = String((init as RequestInit).body); + expect(body).toContain('grant_type=client_credentials'); + expect(body).toContain('scope=read+write'); + expect(body).toContain('resource=' + encodeURIComponent('https://api.example.com/mcp')); + // client_secret_post default when no methods specified by AS + expect(body).toContain('client_id=c1'); + expect(body).toContain('client_secret=s1'); + }); + + it('uses addClientAuthentication for private_key_jwt', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + access_token: 'cc_token', + token_type: 'bearer', + expires_in: 3600 + }) + }); + + const metadata: AuthorizationServerMetadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + token_endpoint_auth_methods_supported: ['private_key_jwt'], + response_types_supported: ['code'] + }; + + const clientInformation: OAuthClientInformation = { + client_id: 'c1' + }; + + const addClientAuthentication = async (_headers: Headers, params: URLSearchParams) => { + params.set('client_assertion', 'fake.jwt.value'); + params.set('client_assertion_type', 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'); + }; + + await exchangeClientCredentials('https://auth.example.com', { + metadata, + clientInformation, + scope: 'mcp:read', + addClientAuthentication, + fetchFn: mockFetch + }); + + const [, init] = mockFetch.mock.calls[0]; + const body = String((init as RequestInit).body); + expect(body).toContain('grant_type=client_credentials'); + expect(body).toContain('client_assertion=fake.jwt.value'); + expect(body).toContain('client_assertion_type=' + encodeURIComponent('urn:ietf:params:oauth:client-assertion-type:jwt-bearer')); + }); +}); diff --git a/src/client/jwtBearer.test.ts b/src/client/jwtBearer.test.ts new file mode 100644 index 000000000..91c25678e --- /dev/null +++ b/src/client/jwtBearer.test.ts @@ -0,0 +1,157 @@ +import { describe, it, expect } from 'vitest'; +import { createJwtBearerAssertion } from './auth.js'; +import type { JwtAssertionOptions } from '../shared/auth.js'; + +describe('createJwtBearerAssertion', () => { + const baseOptions: JwtAssertionOptions = { + issuer: 'client-id', + subject: 'client-id', + privateKey: 'a-string-secret-at-least-256-bits-long', + alg: 'HS256' + }; + + it('returns pre-built assertion when provided', async () => { + const assertion = await createJwtBearerAssertion('https://auth.example.com', undefined, { + ...baseOptions, + assertion: 'pre.built.jwt' + }); + + expect(assertion).toBe('pre.built.jwt'); + }); + + it('creates a signed JWT when no pre-built assertion is provided', async () => { + const assertion = await createJwtBearerAssertion('https://auth.example.com', undefined, baseOptions); + + // Basic shape check for JWT: three segments separated by dots + const parts = assertion.split('.'); + expect(parts).toHaveLength(3); + }); + + 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 assertion = await createJwtBearerAssertion('https://auth.example.com', undefined, { + issuer: 'client-id', + subject: 'client-id', + privateKey: secret, + alg: 'HS256' + }); + + 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 assertion = await createJwtBearerAssertion('https://auth.example.com', undefined, { + issuer: 'client-id', + subject: 'client-id', + privateKey: jwk, + alg: 'HS256' + }); + + const parts = assertion.split('.'); + expect(parts).toHaveLength(3); + }); + + it('creates a signed JWT when using an RSA PEM private key', async () => { + /** + * Generated by the following command: + 1) Generate an RSA private key (PKCS#1 format) + ```bash + openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out rsa-key.pem + ``` + 2) Convert it to PKCS#8 (what `jose.importPKCS8` expects) + ```bash + openssl pkcs8 -topk8 -nocrypt -in rsa-key.pem -out rsa-key-pkcs8.pem + ``` + */ + const pem = `-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCruEwXtZ4MXsYl +ONRMtvBnOZNtnYWlO1KJs93gROCxzRzHz8I5dSzNBYgk5fwncd4L/ZJn3Ue8DsZL +0KzF1W9wweq/EsVYwhTxkLsfkaVVJld4DuYlAATCMiQYN7f4LfdmXaz1o+2kB5Ug +Ae9DqcrSXWcO7gbt1ABJdomPuwFurD9bZKANB/zM+MsAohXGVDoN8o7QH6hWFT/4 +7x3ANoH2oT2mvF58F9Fh6DkGcE9BG3+Ze3TOoCx2DhqdjK8/artxIKigiVXUxLwx +4FB/cSmdh3KpldC1UkmPpyzwMGKe4BlsghXxssyuNBMEi3J1+peiMzN0c4YU5B6A +9jFLMsQXAgMBAAECggEAOxnnNpHPn7pOwCjbCLw96YkrcKKyiLfuJG6/gpyyKP/L +VAnxcw0dKkMpJGnzazAJmF7hsNW8BsGfBiEAFebrwAc94B15xp6lzq5dePQLz06u +9CdMlpd3C89uFNe4fbZ0W8sJ6FFPTRE/BhEkZElgAR8chUrvH5PDtYUSu2FFkO0u +8/RFEPj0urL93kzwaWzff91px82Tn4sak5rK6NfeeoLabyUAoc+E+vDvB8RZW3/1 +sdQ/zp09XZqsw45WS8oHJmDlV/eB2tICha/bC/FygkZY+SmkX4L9a6rz2f+mjlHc +afSYMBLa93/Q4HUzeZcP5HOKr7vM/uC06aYqwXfJAQKBgQDiKBh8LJPeLkrzML7J +160vY/T5b92qt4B8odR6jgySDg/4YbW5Ie6Lydim9G+yu4xodRpJZ0tm9r02Sieq +gvYSzPrdbuiU7jnwCBmeDsoaSsxy+zKNE5TBRBDbwmuaQHlkdDKp9Bd90itp8qMm +YGBu1Rqn7A/xCWkmZA16TaGwtwKBgQDCYT/Hv/iSbzIgbzQTESP3f2WSaUye0MKu +kASLo3IsWgwyrdtLEZ0BYMoibasRj0351wtk2FxOy1t8+Wz80BBi57MTaZgbcYHi +XcaB0imBl+hQinK6PnY/LJN2ZPp7fMSPJ8kE4kmxcAAH0A56UDpOn5Fnle9PMS/W +cjj5Xd9noQKBgEM9QpJgupH7V4NYgdEHE9GcOXCUBubD6iqj/sV1SF2AWtUxT9M8 +OG1NVOHGmRMd2dAQyQD7+hohz/29LG/wwfKzCP8fA32MGqO39M3efc41YPXqo4v4 +P2j6sLx14IIbGzx3o7yN+xIIk6nLXyCA1Qr+xw8YC2FRt/aXFr6/KAyfAoGAThZz +YPOmEG3LXWxPJznDkTIExAS5WzPSgf4pVU+cFmU2cUWWy1mQEXWovpwAFVXUpYHW +efTRYHYhkttBBW8wpgsezbWl/aBj5WR20sBzHDTCh1iXLmrZZheqRe3bErDU5g29 +m9CsejPcT0cuCcUhJ2TDLTH2qYHBDg1lBgjILwECgYB7J5HgEl2pgg+RxuiQd11x +ERSttiQtJ91cm+rOS0DAoviTDd1lvvrKlSxw9eMKO1UX/nLkFeEAnxxc7RPlsMb/ +wZs2jVskGA6OxU/II0nCh9C+hp1LV4vl5Hy1mM3Lkqa/I/AC4kJdvTwi45lXpM9o +btHHccicX+r3BsSv5adOxQ== +-----END PRIVATE KEY-----`; + + const assertion = await createJwtBearerAssertion('https://auth.example.com', undefined, { + issuer: 'client-id', + subject: 'client-id', + privateKey: pem, + alg: 'RS256' + }); + + const parts = assertion.split('.'); + expect(parts).toHaveLength(3); + }); + + it('throws when using an unsupported algorithm', async () => { + await expect( + createJwtBearerAssertion('https://auth.example.com', undefined, { + issuer: 'client-id', + subject: 'client-id', + privateKey: 'a-string-secret-at-least-256-bits-long', + // This will hit the explicit Unsupported algorithm branch in createJwtBearerAssertion + alg: 'none' as unknown as string + }) + ).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-----'; + + await expect( + createJwtBearerAssertion('https://auth.example.com', undefined, { + issuer: 'client-id', + subject: 'client-id', + privateKey: badPem, + alg: 'RS256' + }) + ).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' + }; + + await expect( + createJwtBearerAssertion('https://auth.example.com', undefined, { + 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' + }) + ).rejects.toThrow(/Key for the RS256 algorithm must be one of type CryptoKey, KeyObject, or JSON Web Key/); + }); +}); 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..e8de01138 --- /dev/null +++ b/src/examples/client/simpleClientCredentials.ts @@ -0,0 +1,78 @@ +#!/usr/bin/env node + +import { Client } from '../../client/index.js'; +import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; +import { OAuthClientInformationMixed, OAuthClientMetadata, OAuthTokens } from '../../shared/auth.js'; +import { OAuthClientProvider } from '../../client/auth.js'; + +const DEFAULT_SERVER_URL = process.env.MCP_SERVER_URL || 'http://localhost:3000/mcp'; + +class InMemoryOAuthClientProvider implements OAuthClientProvider { + constructor( + private readonly _clientMetadata: OAuthClientMetadata, + private readonly addAuth?: OAuthClientProvider['addClientAuthentication'] + ) {} + + private _tokens?: OAuthTokens; + private _client?: OAuthClientInformationMixed; + + get redirectUrl(): string | URL { + return 'http://localhost/void'; + } + get clientMetadata(): OAuthClientMetadata { + return this._clientMetadata; + } + clientInformation(): OAuthClientInformationMixed | undefined { + return this._client; + } + saveClientInformation(info: OAuthClientInformationMixed): void { + this._client = info; + } + tokens(): OAuthTokens | undefined { + return this._tokens; + } + saveTokens(tokens: OAuthTokens): void { + this._tokens = tokens; + } + redirectToAuthorization(): void { + // Not used for client_credentials + } + saveCodeVerifier(): void { + // Not used for client_credentials + } + codeVerifier(): string { + throw new Error('Not used for client_credentials'); + } + addClientAuthentication = this.addAuth; +} + +async function main() { + // Option A: client_secret_post + const clientMetadata: OAuthClientMetadata = { + client_name: 'Client-Credentials Demo', + redirect_uris: ['http://localhost/void'], + grant_types: ['client_credentials'], + token_endpoint_auth_method: 'client_secret_post', + scope: 'mcp:tools' + }; + + // Option B: private_key_jwt (uncomment and configure to test) + // const addAuth = createPrivateKeyJwtAuth({ + // issuer: 'your-client-id', + // subject: 'your-client-id', + // privateKey: process.env.PRIVATE_KEY_PEM as string, + // alg: 'RS256' + // }); + + const provider = new InMemoryOAuthClientProvider(clientMetadata /*, addAuth*/); + const client = new Client({ name: 'cc-client', version: '1.0.0' }, { capabilities: {} }); + const transport = new StreamableHTTPClientTransport(new URL(DEFAULT_SERVER_URL), { authProvider: provider }); + + await client.connect(transport); + console.log('Connected with client_credentials token.'); +} + +main().catch(err => { + console.error(err); + process.exit(1); +}); diff --git a/src/examples/client/simpleJwtBearer.ts b/src/examples/client/simpleJwtBearer.ts new file mode 100644 index 000000000..eaee3f3e6 --- /dev/null +++ b/src/examples/client/simpleJwtBearer.ts @@ -0,0 +1,115 @@ +#!/usr/bin/env node + +import { Client } from '../../client/index.js'; +import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; +import { JwtAssertionSigningOptions, OAuthClientInformationMixed, OAuthClientMetadata, OAuthTokens } from '../../shared/auth.js'; +import { OAuthClientProvider, auth } from '../../client/auth.js'; + +const DEFAULT_SERVER_URL = process.env.MCP_SERVER_URL || 'http://localhost:3000/mcp'; + +class InMemoryJwtBearerProvider implements OAuthClientProvider { + constructor( + private readonly _clientMetadata: OAuthClientMetadata, + private readonly _jwtSigningOptions: JwtAssertionSigningOptions + ) {} + + private _tokens?: OAuthTokens; + private _client?: OAuthClientInformationMixed; + + get redirectUrl(): string | URL { + // Not used for JWT-bearer grant + return 'http://localhost/void'; + } + + get clientMetadata(): OAuthClientMetadata { + return this._clientMetadata; + } + + clientMetadataUrl?: string | undefined; + + clientInformation(): OAuthClientInformationMixed | undefined { + return this._client; + } + + saveClientInformation(info: OAuthClientInformationMixed): void { + this._client = info; + } + + tokens(): OAuthTokens | undefined { + return this._tokens; + } + + saveTokens(tokens: OAuthTokens): void { + this._tokens = tokens; + } + + // The following methods are part of the interface but are not used for JWT-bearer M2M flows. + redirectToAuthorization(): void { + throw new Error('redirectToAuthorization is not used for JWT-bearer grant'); + } + + saveCodeVerifier(): void { + // Not used for JWT-bearer + } + + codeVerifier(): string { + throw new Error('codeVerifier is not used for JWT-bearer grant'); + } + + async state(): Promise { + // Not used in this example + return ''; + } + + /** + * Simple helper to perform a JWT-bearer token exchange when needed. + * This can be called by consumers before connecting, or wired into a higher-level helper. + */ + async ensureJwtBearerTokens(serverUrl: URL): Promise { + if (this._tokens?.access_token) { + return; + } + + // Use the high-level auth() API with jwtBearerOptions, which now performs a + // client_credentials grant with private_key_jwt client authentication. + const result = await auth(this, { + serverUrl, + jwtBearerOptions: this._jwtSigningOptions + }); + + if (result !== 'AUTHORIZED') { + throw new Error('Failed to obtain JWT-bearer access token'); + } + } +} + +async function main() { + const clientMetadata: OAuthClientMetadata = { + client_name: 'JWT-Bearer Demo', + redirect_uris: ['http://localhost/void'], + grant_types: ['urn:ietf:params:oauth:grant-type:jwt-bearer'], + scope: 'mcp:tools' + }; + + const jwtSigningOptions: JwtAssertionSigningOptions = { + issuer: process.env.MCP_CLIENT_ID || 'your-client-id', + subject: process.env.MCP_CLIENT_ID || 'your-client-id', + privateKey: process.env.MCP_CLIENT_PRIVATE_KEY_PEM as string, + alg: 'RS256' + }; + + const provider = new InMemoryJwtBearerProvider(clientMetadata, jwtSigningOptions); + + await provider.ensureJwtBearerTokens(new URL(DEFAULT_SERVER_URL)); + + const client = new Client({ name: 'jwt-bearer-client', version: '1.0.0' }, { capabilities: {} }); + const transport = new StreamableHTTPClientTransport(new URL(DEFAULT_SERVER_URL), { authProvider: provider }); + + await client.connect(transport); + console.log('Connected with JWT-bearer access token.'); +} + +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/src/shared/auth.ts b/src/shared/auth.ts index b37a4c70c..a5338f995 100644 --- a/src/shared/auth.ts +++ b/src/shared/auth.ts @@ -213,6 +213,49 @@ export const OAuthTokenRevocationRequestSchema = z }) .strip(); +/** + * Schema for JWT assertion signing options + */ +export const JwtAssertionSigningOptionsSchema = z + .object({ + issuer: z.string().min(1).describe('The issuer of the JWT assertion.'), + subject: z.string().min(1).describe('The subject of the JWT assertion.'), + privateKey: z + .string() + .min(1) + .describe('The string of the private key.') + .or(z.instanceof(Uint8Array).describe('The Uint8Array of the private key.')) + .or(z.record(z.string(), z.unknown()).describe('The JWK object of the JWT assertion.')) + .describe('The private key of the JWT assertion - string, Uint8Array, or JWK object.'), + alg: z.string().min(1).describe('The algorithm of the JWT assertion.'), + audience: z.string().min(1).optional().describe('The audience of the JWT assertion.'), + lifetimeSeconds: z.number().optional().describe('The lifetime of the JWT assertion in seconds.'), + claims: z.record(z.string(), z.any()).optional().describe('The claims of the JWT assertion.') + }) + .strip(); + +/** + * Schema for JWT assertion pre-built options + */ +export const JwtAssertionPrebuiltOptionsSchema = z + .object({ + assertion: z.string().min(1).describe('The pre-built JWT assertion.') + }) + .strip(); + +/** + * Options for creating a JWT assertion for JWT-bearer grant or private_key_jwt. + */ +export const JwtAssertionOptionsSchema = z.union([JwtAssertionSigningOptionsSchema, JwtAssertionPrebuiltOptionsSchema]); + +export type JwtAssertionSigningOptions = z.infer; +export type JwtAssertionPrebuiltOptions = z.infer; +export type JwtAssertionOptions = JwtAssertionSigningOptions | JwtAssertionPrebuiltOptions; + +export const isJwtPrebuiltAssertion = (options: JwtAssertionOptions): options is JwtAssertionPrebuiltOptions => { + return 'assertion' in options; +}; + export type OAuthMetadata = z.infer; export type OpenIdProviderMetadata = z.infer; export type OpenIdProviderDiscoveryMetadata = z.infer; 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; +}