From b4246ea044673113740e68dfa1be1a4dc8f10d2c Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Fri, 14 Nov 2025 09:04:41 +0200 Subject: [PATCH 01/13] save commit --- package-lock.json | 10 + package.json | 1 + src/client/auth.ts | 175 +++++++++++++++++- src/client/clientCredentials.test.ts | 93 ++++++++++ src/examples/README.md | 6 + .../client/simpleClientCredentials.ts | 77 ++++++++ .../server/demoInMemoryOAuthProvider.ts | 28 +++ src/server/auth/handlers/token.test.ts | 32 ++++ src/server/auth/handlers/token.ts | 22 ++- src/server/auth/middleware/clientAuth.test.ts | 42 +++++ src/server/auth/middleware/clientAuth.ts | 102 +++++++++- src/server/auth/provider.ts | 5 + src/server/auth/router.ts | 5 +- 13 files changed, 584 insertions(+), 14 deletions(-) create mode 100644 src/client/clientCredentials.test.ts create mode 100644 src/examples/client/simpleClientCredentials.ts diff --git a/package-lock.json b/package-lock.json index b29ef11fd..cd155a5dc 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.23.8", @@ -5089,6 +5090,15 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "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-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/package.json b/package.json index 5c595515d..a3f36939e 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,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.23.8", diff --git a/src/client/auth.ts b/src/client/auth.ts index 6d4ede84b..efc40780d 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -28,6 +28,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. @@ -438,6 +439,31 @@ async function authInternal( } } + // 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 @@ -953,7 +979,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 ?? []; @@ -1032,7 +1058,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 ?? []; @@ -1057,6 +1083,76 @@ 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()); +} + /** * Performs OAuth 2.0 Dynamic Client Registration according to RFC 7591. */ @@ -1098,3 +1194,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..3338bb240 --- /dev/null +++ b/src/client/clientCredentials.test.ts @@ -0,0 +1,93 @@ +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 = jest.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(encodeURIComponent('resource=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 = jest.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( + encodeURIComponent('client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer') + ); + }); +}); + + diff --git a/src/examples/README.md b/src/examples/README.md index 1c30b8dde..83695a45a 100644 --- a/src/examples/README.md +++ b/src/examples/README.md @@ -39,6 +39,12 @@ Example client with OAuth: npx tsx src/examples/client/simpleOAuthClient.js ``` +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..30d016d66 --- /dev/null +++ b/src/examples/client/simpleClientCredentials.ts @@ -0,0 +1,77 @@ +#!/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, createPrivateKeyJwtAuth } 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/server/demoInMemoryOAuthProvider.ts b/src/examples/server/demoInMemoryOAuthProvider.ts index ba1d3a468..0e671c282 100644 --- a/src/examples/server/demoInMemoryOAuthProvider.ts +++ b/src/examples/server/demoInMemoryOAuthProvider.ts @@ -126,6 +126,34 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { throw new Error('Not implemented for example demo'); } + async issueClientCredentialsToken( + client: OAuthClientInformationFull, + scopes?: string[], + resource?: URL + ): Promise { + if (this.validateResource && !this.validateResource(resource)) { + throw new Error(`Invalid resource: ${resource}`); + } + + const token = randomUUID(); + const tokenData = { + token, + clientId: client.client_id, + scopes: scopes || [], + expiresAt: Date.now() + 3600000, // 1 hour + resource, + type: 'access' + }; + this.tokens.set(token, tokenData); + + return { + access_token: token, + token_type: 'bearer', + expires_in: 3600, + scope: (scopes || []).join(' ') + }; + } + async verifyAccessToken(token: string): Promise { const tokenData = this.tokens.get(token); if (!tokenData || !tokenData.expiresAt || tokenData.expiresAt < Date.now()) { diff --git a/src/server/auth/handlers/token.test.ts b/src/server/auth/handlers/token.test.ts index e0338f030..457b446b5 100644 --- a/src/server/auth/handlers/token.test.ts +++ b/src/server/auth/handlers/token.test.ts @@ -462,6 +462,38 @@ describe('Token Handler', () => { }); }); + describe('Client credentials grant', () => { + it('issues token for client_credentials with basic auth', async () => { + // Extend mock provider with client_credentials handler + (mockProvider as any).issueClientCredentialsToken = async ( + client: OAuthClientInformationFull, + scopes?: string[] + ): Promise => { + return { + access_token: 'cc_access', + token_type: 'bearer', + expires_in: 3600, + scope: (scopes || []).join(' ') + }; + }; + + const basic = Buffer.from('valid-client:valid-secret').toString('base64'); + const response = await supertest(app) + .post('/token') + .set('Authorization', `Basic ${basic}`) + .type('form') + .send({ + grant_type: 'client_credentials', + scope: 'read write' + }); + + expect(response.status).toBe(200); + expect(response.body.access_token).toBe('cc_access'); + expect(response.body.token_type).toBe('bearer'); + expect(response.body.scope).toBe('read write'); + }); + }); + describe('CORS support', () => { it('includes CORS headers in response', async () => { const response = await supertest(app).post('/token').type('form').set('Origin', 'https://example.com').send({ diff --git a/src/server/auth/handlers/token.ts b/src/server/auth/handlers/token.ts index c387ff7bf..28f5659f5 100644 --- a/src/server/auth/handlers/token.ts +++ b/src/server/auth/handlers/token.ts @@ -41,6 +41,11 @@ const RefreshTokenGrantSchema = z.object({ resource: z.string().url().optional() }); +const ClientCredentialsGrantSchema = z.object({ + scope: z.string().optional(), + resource: z.string().url().optional() +}); + export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHandlerOptions): RequestHandler { // Nested router so we can configure middleware and restrict HTTP method const router = express.Router(); @@ -136,8 +141,21 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand break; } - // Not supported right now - //case "client_credentials": + case 'client_credentials': { + const parseResult = ClientCredentialsGrantSchema.safeParse(req.body); + if (!parseResult.success) { + throw new InvalidRequestError(parseResult.error.message); + } + const { scope, resource } = parseResult.data; + const scopes = scope?.split(' '); + const tokens = await provider.issueClientCredentialsToken( + client, + scopes, + resource ? new URL(resource) : undefined + ); + res.status(200).json(tokens); + break; + } default: throw new UnsupportedGrantTypeError('The grant type is not supported by this authorization server.'); diff --git a/src/server/auth/middleware/clientAuth.test.ts b/src/server/auth/middleware/clientAuth.test.ts index 5ad6f301f..794657934 100644 --- a/src/server/auth/middleware/clientAuth.test.ts +++ b/src/server/auth/middleware/clientAuth.test.ts @@ -28,6 +28,13 @@ describe('clientAuth middleware', () => { client_secret_expires_at: Math.floor(Date.now() / 1000) - 3600, // Expired 1 hour ago redirect_uris: ['https://example.com/callback'] }; + } else if (clientId === 'hs-client') { + // Symmetric key client for HS* private_key_jwt verification + return { + client_id: 'hs-client', + client_secret: 'hs-secret', + redirect_uris: ['https://example.com/callback'] + }; } return undefined; } @@ -51,6 +58,41 @@ describe('clientAuth middleware', () => { }); }); + it('authenticates with client_secret_basic header', async () => { + const basic = Buffer.from('valid-client:valid-secret').toString('base64'); + const response = await supertest(app).post('/protected').set('Authorization', `Basic ${basic}`).send(); + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.client.client_id).toBe('valid-client'); + }); + + it('authenticates with private_key_jwt (HS256)', async () => { + const jose = await import('jose'); + const secret = new TextEncoder().encode('hs-secret'); + + const now = Math.floor(Date.now() / 1000); + const assertion = await new jose.SignJWT({}) + .setProtectedHeader({ alg: 'HS256', typ: 'JWT' }) + .setIssuer('hs-client') + .setSubject('hs-client') + .setAudience('http://auth.example.com/protected') + .setIssuedAt(now) + .setExpirationTime(now + 300) + .sign(secret); + + const response = await supertest(app) + .post('/protected') + .set('Host', 'auth.example.com') + .send({ + client_id: 'hs-client', + client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', + client_assertion: assertion + }); + + expect(response.status).toBe(200); + expect(response.body.success).toBe(true); + expect(response.body.client.client_id).toBe('hs-client'); + }); it('authenticates valid client credentials', async () => { const response = await supertest(app).post('/protected').send({ client_id: 'valid-client', diff --git a/src/server/auth/middleware/clientAuth.ts b/src/server/auth/middleware/clientAuth.ts index 9969b8724..4a7bf4aa4 100644 --- a/src/server/auth/middleware/clientAuth.ts +++ b/src/server/auth/middleware/clientAuth.ts @@ -3,6 +3,7 @@ import { RequestHandler } from 'express'; import { OAuthRegisteredClientsStore } from '../clients.js'; import { OAuthClientInformationFull } from '../../../shared/auth.js'; import { InvalidRequestError, InvalidClientError, ServerError, OAuthError } from '../errors.js'; +import { createLocalJWKSet, createRemoteJWKSet, decodeProtectedHeader, jwtVerify } from 'jose'; export type ClientAuthenticationMiddlewareOptions = { /** @@ -28,30 +29,115 @@ declare module 'express-serve-static-core' { export function authenticateClient({ clientsStore }: ClientAuthenticationMiddlewareOptions): RequestHandler { return async (req, res, next) => { try { + // 1) HTTP Basic (client_secret_basic) + const authHeader = req.headers.authorization; + if (authHeader && authHeader.toLowerCase().startsWith('basic ')) { + const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf8'); + const sep = decoded.indexOf(':'); + const basicClientId = decoded.slice(0, sep); + const basicClientSecret = decoded.slice(sep + 1); + + const client = await clientsStore.getClient(basicClientId); + if (!client) { + throw new InvalidClientError('Invalid client_id'); + } + if (!client.client_secret) { + throw new InvalidClientError('Client not configured for client_secret authentication'); + } + if (client.client_secret !== basicClientSecret) { + throw new InvalidClientError('Invalid client_secret'); + } + if (client.client_secret_expires_at && client.client_secret_expires_at < Math.floor(Date.now() / 1000)) { + throw new InvalidClientError('Client secret has expired'); + } + + req.client = client; + return next(); + } + + // 2) private_key_jwt via client_assertion + const assertionType = typeof req.body?.client_assertion_type === 'string' ? req.body.client_assertion_type : undefined; + const assertion = typeof req.body?.client_assertion === 'string' ? req.body.client_assertion : undefined; + if (assertionType === 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' && assertion) { + // Decode header to check alg + const protectedHeader = decodeProtectedHeader(assertion); + const alg = protectedHeader.alg || ''; + + // Determine expected audience (token endpoint URL) + const expectedAudience = `${req.protocol}://${req.get('host')}${req.originalUrl}`; + + // We need a client_id to fetch client metadata; per RFC 7523, sub identifies the client + // Verify using JWKS (embedded or remote), or HMAC secret for HS* algorithms + // First, parse without verification to extract sub/iss would require more deps; instead verify + // against all potential keys we can derive from request client_id if provided, otherwise defer to failure + let candidateClientId = typeof req.body?.client_id === 'string' ? (req.body.client_id as string) : undefined; + + // If no client_id provided in body, attempt to verify against all known clients is not feasible. + // Require client_id in body for now, or rely on iss/sub matching after verification. + if (!candidateClientId) { + // We can still verify then read payload, but we need a key set. + // Without client hint, we cannot pick a key. Treat as invalid request. + throw new InvalidRequestError('client_id is required when using private_key_jwt'); + } + + const client = await clientsStore.getClient(candidateClientId); + if (!client) { + throw new InvalidClientError('Invalid client_id'); + } + + // Build key for verification + let keyOrGetKey: + | ReturnType + | ReturnType + | Uint8Array + | undefined; + + if (client.jwks) { + keyOrGetKey = createLocalJWKSet({ keys: client.jwks.keys ?? client.jwks }); + } else if (client.jwks_uri) { + try { + const jwksUrl = new URL(client.jwks_uri); + keyOrGetKey = createRemoteJWKSet(jwksUrl); + } catch { + throw new InvalidClientError('Invalid jwks_uri in client registration'); + } + } else if (alg && alg.startsWith('HS') && client.client_secret) { + keyOrGetKey = new TextEncoder().encode(client.client_secret); + } else { + throw new InvalidClientError('No verification key available for private_key_jwt'); + } + + const { payload } = await jwtVerify(assertion, keyOrGetKey as any, { + audience: expectedAudience, + issuer: client.client_id + }); + + // Validate sub and iss + if (payload.sub !== client.client_id) { + throw new InvalidClientError('Invalid client_assertion: subject does not match client_id'); + } + + req.client = client; + return next(); + } + + // 3) client_secret_post (body params) const result = ClientAuthenticatedRequestSchema.safeParse(req.body); 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/server/auth/provider.ts b/src/server/auth/provider.ts index cf1c306de..1b2b77619 100644 --- a/src/server/auth/provider.ts +++ b/src/server/auth/provider.ts @@ -55,6 +55,11 @@ export interface OAuthServerProvider { */ verifyAccessToken(token: string): Promise; + /** + * Issues an access token for the client_credentials grant. + */ + issueClientCredentialsToken(client: OAuthClientInformationFull, scopes?: string[], resource?: URL): Promise; + /** * Revokes an access or refresh token. If unimplemented, token revocation is not supported (not recommended). * diff --git a/src/server/auth/router.ts b/src/server/auth/router.ts index dc0a85a33..2071c27fe 100644 --- a/src/server/auth/router.ts +++ b/src/server/auth/router.ts @@ -92,8 +92,9 @@ export const createOAuthMetadata = (options: { code_challenge_methods_supported: ['S256'], token_endpoint: new URL(token_endpoint, baseUrl || issuer).href, - token_endpoint_auth_methods_supported: ['client_secret_post'], - grant_types_supported: ['authorization_code', 'refresh_token'], + token_endpoint_auth_methods_supported: ['client_secret_post', 'client_secret_basic', 'private_key_jwt'], + token_endpoint_auth_signing_alg_values_supported: ['RS256'], + grant_types_supported: ['authorization_code', 'refresh_token', 'client_credentials'], scopes_supported: options.scopesSupported, From 4d4f51712a071b2818feb3bf6d5c201a1b2b99d1 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sat, 22 Nov 2025 23:47:07 +0200 Subject: [PATCH 02/13] SEP-1046: add jwt prebuilt assertion and signing options, add zod schemas, just in time load jose for client --- src/client/auth.test.ts | 4 +- src/client/auth.ts | 172 ++++++++++++++- src/client/clientCredentials.test.ts | 12 +- src/client/jwtBearer.test.ts | 206 ++++++++++++++++++ .../client/simpleClientCredentials.ts | 9 +- src/examples/client/simpleJwtBearer.ts | 123 +++++++++++ .../server/demoInMemoryOAuthProvider.ts | 6 +- src/server/auth/handlers/authorize.test.ts | 8 + src/server/auth/handlers/revoke.test.ts | 16 ++ src/server/auth/handlers/token.test.ts | 25 ++- src/server/auth/handlers/token.ts | 6 +- src/server/auth/middleware/clientAuth.test.ts | 13 +- src/server/auth/middleware/clientAuth.ts | 12 +- src/server/auth/providers/proxyProvider.ts | 34 +++ src/server/auth/router.test.ts | 25 ++- src/shared/auth.ts | 43 ++++ 16 files changed, 660 insertions(+), 54 deletions(-) create mode 100644 src/client/jwtBearer.test.ts create mode 100644 src/examples/client/simpleJwtBearer.ts diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 0e3a544a2..e529a5157 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(); diff --git a/src/client/auth.ts b/src/client/auth.ts index e5616fe40..e40d6f68c 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, @@ -318,6 +320,14 @@ export async function auth( scope?: string; resourceMetadataUrl?: URL; fetchFn?: FetchLike; + /** + * Optional JWT assertion options for performing an RFC 7523 JWT-bearer grant. + * + * When provided, and no valid/refreshable tokens are available, auth() will + * attempt a JWT-bearer grant before falling back to client_credentials or + * interactive authorization_code flows. + */ + jwtBearerOptions?: JwtAssertionOptions; } ): Promise { try { @@ -344,13 +354,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; @@ -466,6 +478,21 @@ async function authInternal( } } + // Attempt JWT-bearer grant for M2M if explicitly configured + if (jwtBearerOptions) { + const jwtTokens = await exchangeJwtBearer(authorizationServerUrl, { + metadata, + clientInformation, + jwtOptions: jwtBearerOptions, + scope: scope || provider.clientMetadata.scope, + resource, + addClientAuthentication: provider.addClientAuthentication, + fetchFn + }); + await provider.saveTokens(jwtTokens); + return 'AUTHORIZED'; + } + // Attempt client_credentials grant for M2M if supported by client configuration { const requestedGrantTypes = provider.clientMetadata.grant_types ?? []; @@ -1216,6 +1243,147 @@ export async function exchangeClientCredentials( return OAuthTokensSchema.parse(await response.json()); } +/** + * Creates a JWT assertion suitable for RFC 7523 JWT-bearer grant or 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. + */ +export async function exchangeJwtBearer( + authorizationServerUrl: string | URL, + { + metadata, + clientInformation, + jwtOptions, + scope, + resource, + addClientAuthentication, + fetchFn + }: { + metadata?: AuthorizationServerMetadata; + clientInformation: OAuthClientInformationMixed; + jwtOptions: JwtAssertionOptions; + scope?: string; + resource?: URL; + addClientAuthentication?: OAuthClientProvider['addClientAuthentication']; + fetchFn?: FetchLike; + } +): Promise { + const grantType = 'urn:ietf:params:oauth:grant-type:jwt-bearer'; + + 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 assertion = await createJwtBearerAssertion(authorizationServerUrl, metadata, jwtOptions); + + const params = new URLSearchParams({ + grant_type: grantType, + assertion + }); + + 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()); +} + /** * Performs OAuth 2.0 Dynamic Client Registration according to RFC 7591. */ diff --git a/src/client/clientCredentials.test.ts b/src/client/clientCredentials.test.ts index 3338bb240..6c15f837a 100644 --- a/src/client/clientCredentials.test.ts +++ b/src/client/clientCredentials.test.ts @@ -3,7 +3,7 @@ import type { AuthorizationServerMetadata, OAuthClientInformation } from '../sha describe('exchangeClientCredentials', () => { it('posts client_credentials with client_secret_post and scope/resource', async () => { - const mockFetch = jest.fn().mockResolvedValue({ + const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ access_token: 'cc_token', @@ -39,14 +39,14 @@ describe('exchangeClientCredentials', () => { const body = String((init as RequestInit).body); expect(body).toContain('grant_type=client_credentials'); expect(body).toContain('scope=read+write'); - expect(body).toContain(encodeURIComponent('resource=https://api.example.com/mcp')); + 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 = jest.fn().mockResolvedValue({ + const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ access_token: 'cc_token', @@ -84,10 +84,6 @@ describe('exchangeClientCredentials', () => { 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( - encodeURIComponent('client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer') - ); + 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..f10ea1e26 --- /dev/null +++ b/src/client/jwtBearer.test.ts @@ -0,0 +1,206 @@ +import { describe, it, expect, vi } from 'vitest'; +import { createJwtBearerAssertion, exchangeJwtBearer } from './auth.js'; +import type { AuthorizationServerMetadata, JwtAssertionOptions, OAuthClientInformation } 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/); + }); +}); + +describe('exchangeJwtBearer', () => { + it('posts jwt-bearer grant with assertion, scope and resource', async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + access_token: 'jwt_bearer_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 exchangeJwtBearer('https://auth.example.com', { + metadata, + clientInformation, + jwtOptions: { + issuer: 'c1', + subject: 'c1', + privateKey: 'a-string-secret-at-least-256-bits-long', + alg: 'HS256' + }, + scope: 'read write', + resource: new URL('https://api.example.com/mcp'), + fetchFn: mockFetch + }); + + expect(tokens.access_token).toBe('jwt_bearer_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=' + encodeURIComponent('urn:ietf:params:oauth:grant-type:jwt-bearer')); + expect(body).toContain('scope=read+write'); + expect(body).toContain('resource=' + encodeURIComponent('https://api.example.com/mcp')); + expect(body).toContain('assertion='); + }); +}); diff --git a/src/examples/client/simpleClientCredentials.ts b/src/examples/client/simpleClientCredentials.ts index 30d016d66..e8de01138 100644 --- a/src/examples/client/simpleClientCredentials.ts +++ b/src/examples/client/simpleClientCredentials.ts @@ -3,12 +3,15 @@ import { Client } from '../../client/index.js'; import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; import { OAuthClientInformationMixed, OAuthClientMetadata, OAuthTokens } from '../../shared/auth.js'; -import { OAuthClientProvider, createPrivateKeyJwtAuth } from '../../client/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']) {} + constructor( + private readonly _clientMetadata: OAuthClientMetadata, + private readonly addAuth?: OAuthClientProvider['addClientAuthentication'] + ) {} private _tokens?: OAuthTokens; private _client?: OAuthClientInformationMixed; @@ -73,5 +76,3 @@ 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..b2a12977b --- /dev/null +++ b/src/examples/client/simpleJwtBearer.ts @@ -0,0 +1,123 @@ +#!/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, exchangeJwtBearer } 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; + } + + const authorizationServerUrl = new URL('/', serverUrl); + const metadata = undefined; + + const clientInformation: OAuthClientInformationMixed = + this._client ?? + ({ + client_id: this._jwtSigningOptions.issuer + } as OAuthClientInformationMixed); + + const tokens = await exchangeJwtBearer(authorizationServerUrl, { + metadata, + clientInformation, + jwtOptions: this._jwtSigningOptions, + scope: this._clientMetadata.scope, + resource: undefined + }); + + this._tokens = tokens; + } +} + +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/examples/server/demoInMemoryOAuthProvider.ts b/src/examples/server/demoInMemoryOAuthProvider.ts index ee60b265f..ea05ee87c 100644 --- a/src/examples/server/demoInMemoryOAuthProvider.ts +++ b/src/examples/server/demoInMemoryOAuthProvider.ts @@ -143,11 +143,7 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { throw new Error('Not implemented for example demo'); } - async issueClientCredentialsToken( - client: OAuthClientInformationFull, - scopes?: string[], - resource?: URL - ): Promise { + async issueClientCredentialsToken(client: OAuthClientInformationFull, scopes?: string[], resource?: URL): Promise { if (this.validateResource && !this.validateResource(resource)) { throw new Error(`Invalid resource: ${resource}`); } diff --git a/src/server/auth/handlers/authorize.test.ts b/src/server/auth/handlers/authorize.test.ts index 8762d40d7..3b7ee0c87 100644 --- a/src/server/auth/handlers/authorize.test.ts +++ b/src/server/auth/handlers/authorize.test.ts @@ -85,6 +85,14 @@ describe('Authorization Handler', () => { async revokeToken(): Promise { // Do nothing in mock + }, + + async issueClientCredentialsToken(): Promise { + return { + access_token: 'cc_access', + token_type: 'bearer', + expires_in: 3600 + }; } }; diff --git a/src/server/auth/handlers/revoke.test.ts b/src/server/auth/handlers/revoke.test.ts index 35fad72fd..fb1df4b95 100644 --- a/src/server/auth/handlers/revoke.test.ts +++ b/src/server/auth/handlers/revoke.test.ts @@ -70,6 +70,14 @@ describe('Revocation Handler', () => { async revokeToken(_client: OAuthClientInformationFull, _request: OAuthTokenRevocationRequest): Promise { // Success - do nothing in mock + }, + + async issueClientCredentialsToken(): Promise { + return { + access_token: 'cc_access', + token_type: 'bearer', + expires_in: 3600 + }; } }; @@ -113,6 +121,14 @@ describe('Revocation Handler', () => { }; } throw new InvalidTokenError('Token is invalid or expired'); + }, + + async issueClientCredentialsToken(): Promise { + return { + access_token: 'cc_access', + token_type: 'bearer', + expires_in: 3600 + }; } // No revokeToken method }; diff --git a/src/server/auth/handlers/token.test.ts b/src/server/auth/handlers/token.test.ts index cfba90b7e..05b23f49c 100644 --- a/src/server/auth/handlers/token.test.ts +++ b/src/server/auth/handlers/token.test.ts @@ -108,6 +108,15 @@ describe('Token Handler', () => { async revokeToken(_client: OAuthClientInformationFull, _request: OAuthTokenRevocationRequest): Promise { // Do nothing in mock + }, + + async issueClientCredentialsToken(client: OAuthClientInformationFull, scopes?: string[]): Promise { + return { + access_token: 'cc_access', + token_type: 'bearer', + expires_in: 3600, + scope: (scopes || []).join(' ') + }; } }; @@ -465,8 +474,8 @@ describe('Token Handler', () => { describe('Client credentials grant', () => { it('issues token for client_credentials with basic auth', async () => { - // Extend mock provider with client_credentials handler - (mockProvider as any).issueClientCredentialsToken = async ( + // Override mock provider client_credentials handler for this test + mockProvider.issueClientCredentialsToken = async ( client: OAuthClientInformationFull, scopes?: string[] ): Promise => { @@ -479,14 +488,10 @@ describe('Token Handler', () => { }; const basic = Buffer.from('valid-client:valid-secret').toString('base64'); - const response = await supertest(app) - .post('/token') - .set('Authorization', `Basic ${basic}`) - .type('form') - .send({ - grant_type: 'client_credentials', - scope: 'read write' - }); + const response = await supertest(app).post('/token').set('Authorization', `Basic ${basic}`).type('form').send({ + grant_type: 'client_credentials', + scope: 'read write' + }); expect(response.status).toBe(200); expect(response.body.access_token).toBe('cc_access'); diff --git a/src/server/auth/handlers/token.ts b/src/server/auth/handlers/token.ts index de8226988..a216bb621 100644 --- a/src/server/auth/handlers/token.ts +++ b/src/server/auth/handlers/token.ts @@ -148,11 +148,7 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand } const { scope, resource } = parseResult.data; const scopes = scope?.split(' '); - const tokens = await provider.issueClientCredentialsToken( - client, - scopes, - resource ? new URL(resource) : undefined - ); + const tokens = await provider.issueClientCredentialsToken(client, scopes, resource ? new URL(resource) : undefined); res.status(200).json(tokens); break; } diff --git a/src/server/auth/middleware/clientAuth.test.ts b/src/server/auth/middleware/clientAuth.test.ts index 794657934..7991ab2db 100644 --- a/src/server/auth/middleware/clientAuth.test.ts +++ b/src/server/auth/middleware/clientAuth.test.ts @@ -80,14 +80,11 @@ describe('clientAuth middleware', () => { .setExpirationTime(now + 300) .sign(secret); - const response = await supertest(app) - .post('/protected') - .set('Host', 'auth.example.com') - .send({ - client_id: 'hs-client', - client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', - client_assertion: assertion - }); + const response = await supertest(app).post('/protected').set('Host', 'auth.example.com').send({ + client_id: 'hs-client', + client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', + client_assertion: assertion + }); expect(response.status).toBe(200); expect(response.body.success).toBe(true); diff --git a/src/server/auth/middleware/clientAuth.ts b/src/server/auth/middleware/clientAuth.ts index 5e9c795a1..4be46e3a8 100644 --- a/src/server/auth/middleware/clientAuth.ts +++ b/src/server/auth/middleware/clientAuth.ts @@ -3,7 +3,7 @@ import { RequestHandler } from 'express'; import { OAuthRegisteredClientsStore } from '../clients.js'; import { OAuthClientInformationFull } from '../../../shared/auth.js'; import { InvalidRequestError, InvalidClientError, ServerError, OAuthError } from '../errors.js'; -import { createLocalJWKSet, createRemoteJWKSet, decodeProtectedHeader, jwtVerify } from 'jose'; +import { createLocalJWKSet, createRemoteJWKSet, decodeProtectedHeader, jwtVerify, JWTVerifyGetKey } from 'jose'; export type ClientAuthenticationMiddlewareOptions = { /** @@ -70,7 +70,7 @@ export function authenticateClient({ clientsStore }: ClientAuthenticationMiddlew // Verify using JWKS (embedded or remote), or HMAC secret for HS* algorithms // First, parse without verification to extract sub/iss would require more deps; instead verify // against all potential keys we can derive from request client_id if provided, otherwise defer to failure - let candidateClientId = typeof req.body?.client_id === 'string' ? (req.body.client_id as string) : undefined; + const candidateClientId = typeof req.body?.client_id === 'string' ? (req.body.client_id as string) : undefined; // If no client_id provided in body, attempt to verify against all known clients is not feasible. // Require client_id in body for now, or rely on iss/sub matching after verification. @@ -86,11 +86,7 @@ export function authenticateClient({ clientsStore }: ClientAuthenticationMiddlew } // Build key for verification - let keyOrGetKey: - | ReturnType - | ReturnType - | Uint8Array - | undefined; + let keyOrGetKey: ReturnType | ReturnType | Uint8Array | undefined; if (client.jwks) { keyOrGetKey = createLocalJWKSet({ keys: client.jwks.keys ?? client.jwks }); @@ -107,7 +103,7 @@ export function authenticateClient({ clientsStore }: ClientAuthenticationMiddlew throw new InvalidClientError('No verification key available for private_key_jwt'); } - const { payload } = await jwtVerify(assertion, keyOrGetKey as any, { + const { payload } = await jwtVerify(assertion, keyOrGetKey as JWTVerifyGetKey, { audience: expectedAudience, issuer: client.client_id }); diff --git a/src/server/auth/providers/proxyProvider.ts b/src/server/auth/providers/proxyProvider.ts index 32f256450..1f4129f34 100644 --- a/src/server/auth/providers/proxyProvider.ts +++ b/src/server/auth/providers/proxyProvider.ts @@ -231,4 +231,38 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { async verifyAccessToken(token: string): Promise { return this._verifyAccessToken(token); } + + async issueClientCredentialsToken(client: OAuthClientInformationFull, scopes?: string[], resource?: URL): Promise { + const params = new URLSearchParams({ + grant_type: 'client_credentials', + client_id: client.client_id + }); + + if (client.client_secret) { + params.set('client_secret', client.client_secret); + } + + if (scopes?.length) { + params.set('scope', scopes.join(' ')); + } + + if (resource) { + params.set('resource', resource.href); + } + + const response = await (this._fetch ?? fetch)(this._endpoints.tokenUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: params.toString() + }); + + if (!response.ok) { + throw new ServerError(`Token issue (client_credentials) failed: ${response.status}`); + } + + const data = await response.json(); + return OAuthTokensSchema.parse(data); + } } diff --git a/src/server/auth/router.test.ts b/src/server/auth/router.test.ts index ae280286b..247c9bc35 100644 --- a/src/server/auth/router.test.ts +++ b/src/server/auth/router.test.ts @@ -74,6 +74,14 @@ describe('MCP Auth Router', () => { async revokeToken(_client: OAuthClientInformationFull, _request: OAuthTokenRevocationRequest): Promise { // Success - do nothing in mock + }, + + async issueClientCredentialsToken(): Promise { + return { + access_token: 'cc_access', + token_type: 'bearer', + expires_in: 3600 + }; } }; @@ -133,6 +141,14 @@ describe('MCP Auth Router', () => { }; } throw new InvalidTokenError('Token is invalid or expired'); + }, + + async issueClientCredentialsToken(): Promise { + return { + access_token: 'cc_access', + token_type: 'bearer', + expires_in: 3600 + }; } }; @@ -211,9 +227,14 @@ describe('MCP Auth Router', () => { // Verify supported features expect(response.body.response_types_supported).toEqual(['code']); - expect(response.body.grant_types_supported).toEqual(['authorization_code', 'refresh_token']); + expect(response.body.grant_types_supported).toEqual(['authorization_code', 'refresh_token', 'client_credentials']); expect(response.body.code_challenge_methods_supported).toEqual(['S256']); - expect(response.body.token_endpoint_auth_methods_supported).toEqual(['client_secret_post', 'none']); + expect(response.body.token_endpoint_auth_methods_supported).toEqual([ + 'client_secret_post', + 'client_secret_basic', + 'private_key_jwt', + 'none' + ]); expect(response.body.revocation_endpoint_auth_methods_supported).toEqual(['client_secret_post']); // Verify optional fields 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; From 7262bf24770c54d24f3e914eecc3ef37b871f8eb Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sat, 22 Nov 2025 23:58:25 +0200 Subject: [PATCH 03/13] test polyfill setup for jose on node 18 --- vitest.config.ts | 3 ++- vitest.setup.ts | 10 ++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 vitest.setup.ts 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..c95e49dbe --- /dev/null +++ b/vitest.setup.ts @@ -0,0 +1,10 @@ +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; +} + + From bf895de70657dc6b17491df4fb399965ab474865 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Sat, 22 Nov 2025 23:58:50 +0200 Subject: [PATCH 04/13] prettier fix --- vitest.setup.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/vitest.setup.ts b/vitest.setup.ts index c95e49dbe..820dcbd89 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -6,5 +6,3 @@ if (typeof globalThis.crypto === 'undefined') { // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).crypto = webcrypto as unknown as Crypto; } - - From da86864fcac6b4ce8fd74af26f2dca9c6952caef Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Wed, 26 Nov 2025 07:12:13 +0200 Subject: [PATCH 05/13] remove jwt-bearer grant_type, add jwt-bearer client_assertion_type, remove server side implementation --- src/client/auth.test.ts | 80 +++++++++++++++++ src/client/auth.ts | 89 +++--------------- src/client/jwtBearer.test.ts | 55 +----------- src/examples/client/simpleJwtBearer.ts | 26 ++---- src/server/auth/handlers/authorize.test.ts | 8 -- src/server/auth/handlers/revoke.test.ts | 17 +--- src/server/auth/handlers/token.test.ts | 37 -------- src/server/auth/handlers/token.ts | 20 +---- src/server/auth/middleware/clientAuth.test.ts | 39 -------- src/server/auth/middleware/clientAuth.ts | 90 ------------------- src/server/auth/provider.ts | 5 -- src/server/auth/providers/proxyProvider.ts | 34 ------- src/server/auth/router.test.ts | 25 +----- src/server/auth/router.ts | 5 +- 14 files changed, 112 insertions(+), 418 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index e529a5157..450890b2a 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -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 e40d6f68c..8a02b04ae 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -321,11 +321,12 @@ export async function auth( resourceMetadataUrl?: URL; fetchFn?: FetchLike; /** - * Optional JWT assertion options for performing an RFC 7523 JWT-bearer grant. + * 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 JWT-bearer grant before falling back to client_credentials or - * interactive authorization_code flows. + * attempt a client_credentials grant with private_key_jwt client authentication + * before falling back to other flows. */ jwtBearerOptions?: JwtAssertionOptions; } @@ -478,15 +479,19 @@ async function authInternal( } } - // Attempt JWT-bearer grant for M2M if explicitly configured + // 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 exchangeJwtBearer(authorizationServerUrl, { + const jwtTokens = await exchangeClientCredentials(authorizationServerUrl, { metadata, clientInformation, - jwtOptions: jwtBearerOptions, scope: scope || provider.clientMetadata.scope, resource, - addClientAuthentication: provider.addClientAuthentication, + 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); @@ -1244,7 +1249,7 @@ export async function exchangeClientCredentials( } /** - * Creates a JWT assertion suitable for RFC 7523 JWT-bearer grant or private_key_jwt + * 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. @@ -1316,74 +1321,6 @@ export async function createJwtBearerAssertion( * This is a lower-level helper that can be used by higher-level clients for M2M * scenarios where no interactive user authorization is required. */ -export async function exchangeJwtBearer( - authorizationServerUrl: string | URL, - { - metadata, - clientInformation, - jwtOptions, - scope, - resource, - addClientAuthentication, - fetchFn - }: { - metadata?: AuthorizationServerMetadata; - clientInformation: OAuthClientInformationMixed; - jwtOptions: JwtAssertionOptions; - scope?: string; - resource?: URL; - addClientAuthentication?: OAuthClientProvider['addClientAuthentication']; - fetchFn?: FetchLike; - } -): Promise { - const grantType = 'urn:ietf:params:oauth:grant-type:jwt-bearer'; - - 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 assertion = await createJwtBearerAssertion(authorizationServerUrl, metadata, jwtOptions); - - const params = new URLSearchParams({ - grant_type: grantType, - assertion - }); - - 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()); -} - /** * Performs OAuth 2.0 Dynamic Client Registration according to RFC 7591. */ diff --git a/src/client/jwtBearer.test.ts b/src/client/jwtBearer.test.ts index f10ea1e26..91c25678e 100644 --- a/src/client/jwtBearer.test.ts +++ b/src/client/jwtBearer.test.ts @@ -1,6 +1,6 @@ -import { describe, it, expect, vi } from 'vitest'; -import { createJwtBearerAssertion, exchangeJwtBearer } from './auth.js'; -import type { AuthorizationServerMetadata, JwtAssertionOptions, OAuthClientInformation } from '../shared/auth.js'; +import { describe, it, expect } from 'vitest'; +import { createJwtBearerAssertion } from './auth.js'; +import type { JwtAssertionOptions } from '../shared/auth.js'; describe('createJwtBearerAssertion', () => { const baseOptions: JwtAssertionOptions = { @@ -155,52 +155,3 @@ btHHccicX+r3BsSv5adOxQ== ).rejects.toThrow(/Key for the RS256 algorithm must be one of type CryptoKey, KeyObject, or JSON Web Key/); }); }); - -describe('exchangeJwtBearer', () => { - it('posts jwt-bearer grant with assertion, scope and resource', async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, - json: async () => ({ - access_token: 'jwt_bearer_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 exchangeJwtBearer('https://auth.example.com', { - metadata, - clientInformation, - jwtOptions: { - issuer: 'c1', - subject: 'c1', - privateKey: 'a-string-secret-at-least-256-bits-long', - alg: 'HS256' - }, - scope: 'read write', - resource: new URL('https://api.example.com/mcp'), - fetchFn: mockFetch - }); - - expect(tokens.access_token).toBe('jwt_bearer_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=' + encodeURIComponent('urn:ietf:params:oauth:grant-type:jwt-bearer')); - expect(body).toContain('scope=read+write'); - expect(body).toContain('resource=' + encodeURIComponent('https://api.example.com/mcp')); - expect(body).toContain('assertion='); - }); -}); diff --git a/src/examples/client/simpleJwtBearer.ts b/src/examples/client/simpleJwtBearer.ts index b2a12977b..eaee3f3e6 100644 --- a/src/examples/client/simpleJwtBearer.ts +++ b/src/examples/client/simpleJwtBearer.ts @@ -3,7 +3,7 @@ import { Client } from '../../client/index.js'; import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; import { JwtAssertionSigningOptions, OAuthClientInformationMixed, OAuthClientMetadata, OAuthTokens } from '../../shared/auth.js'; -import { OAuthClientProvider, exchangeJwtBearer } from '../../client/auth.js'; +import { OAuthClientProvider, auth } from '../../client/auth.js'; const DEFAULT_SERVER_URL = process.env.MCP_SERVER_URL || 'http://localhost:3000/mcp'; @@ -70,24 +70,16 @@ class InMemoryJwtBearerProvider implements OAuthClientProvider { return; } - const authorizationServerUrl = new URL('/', serverUrl); - const metadata = undefined; - - const clientInformation: OAuthClientInformationMixed = - this._client ?? - ({ - client_id: this._jwtSigningOptions.issuer - } as OAuthClientInformationMixed); - - const tokens = await exchangeJwtBearer(authorizationServerUrl, { - metadata, - clientInformation, - jwtOptions: this._jwtSigningOptions, - scope: this._clientMetadata.scope, - resource: undefined + // 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 }); - this._tokens = tokens; + if (result !== 'AUTHORIZED') { + throw new Error('Failed to obtain JWT-bearer access token'); + } } } diff --git a/src/server/auth/handlers/authorize.test.ts b/src/server/auth/handlers/authorize.test.ts index 3b7ee0c87..8762d40d7 100644 --- a/src/server/auth/handlers/authorize.test.ts +++ b/src/server/auth/handlers/authorize.test.ts @@ -85,14 +85,6 @@ describe('Authorization Handler', () => { async revokeToken(): Promise { // Do nothing in mock - }, - - async issueClientCredentialsToken(): Promise { - return { - access_token: 'cc_access', - token_type: 'bearer', - expires_in: 3600 - }; } }; diff --git a/src/server/auth/handlers/revoke.test.ts b/src/server/auth/handlers/revoke.test.ts index fb1df4b95..6e60e905b 100644 --- a/src/server/auth/handlers/revoke.test.ts +++ b/src/server/auth/handlers/revoke.test.ts @@ -70,14 +70,6 @@ describe('Revocation Handler', () => { async revokeToken(_client: OAuthClientInformationFull, _request: OAuthTokenRevocationRequest): Promise { // Success - do nothing in mock - }, - - async issueClientCredentialsToken(): Promise { - return { - access_token: 'cc_access', - token_type: 'bearer', - expires_in: 3600 - }; } }; @@ -121,15 +113,8 @@ describe('Revocation Handler', () => { }; } throw new InvalidTokenError('Token is invalid or expired'); - }, - - async issueClientCredentialsToken(): Promise { - return { - access_token: 'cc_access', - token_type: 'bearer', - expires_in: 3600 - }; } + // No revokeToken method }; diff --git a/src/server/auth/handlers/token.test.ts b/src/server/auth/handlers/token.test.ts index 05b23f49c..f83b961ae 100644 --- a/src/server/auth/handlers/token.test.ts +++ b/src/server/auth/handlers/token.test.ts @@ -108,15 +108,6 @@ describe('Token Handler', () => { async revokeToken(_client: OAuthClientInformationFull, _request: OAuthTokenRevocationRequest): Promise { // Do nothing in mock - }, - - async issueClientCredentialsToken(client: OAuthClientInformationFull, scopes?: string[]): Promise { - return { - access_token: 'cc_access', - token_type: 'bearer', - expires_in: 3600, - scope: (scopes || []).join(' ') - }; } }; @@ -472,34 +463,6 @@ describe('Token Handler', () => { }); }); - describe('Client credentials grant', () => { - it('issues token for client_credentials with basic auth', async () => { - // Override mock provider client_credentials handler for this test - mockProvider.issueClientCredentialsToken = async ( - client: OAuthClientInformationFull, - scopes?: string[] - ): Promise => { - return { - access_token: 'cc_access', - token_type: 'bearer', - expires_in: 3600, - scope: (scopes || []).join(' ') - }; - }; - - const basic = Buffer.from('valid-client:valid-secret').toString('base64'); - const response = await supertest(app).post('/token').set('Authorization', `Basic ${basic}`).type('form').send({ - grant_type: 'client_credentials', - scope: 'read write' - }); - - expect(response.status).toBe(200); - expect(response.body.access_token).toBe('cc_access'); - expect(response.body.token_type).toBe('bearer'); - expect(response.body.scope).toBe('read write'); - }); - }); - describe('CORS support', () => { it('includes CORS headers in response', async () => { const response = await supertest(app).post('/token').type('form').set('Origin', 'https://example.com').send({ diff --git a/src/server/auth/handlers/token.ts b/src/server/auth/handlers/token.ts index a216bb621..4cc4e8ab8 100644 --- a/src/server/auth/handlers/token.ts +++ b/src/server/auth/handlers/token.ts @@ -41,11 +41,6 @@ const RefreshTokenGrantSchema = z.object({ resource: z.string().url().optional() }); -const ClientCredentialsGrantSchema = z.object({ - scope: z.string().optional(), - resource: z.string().url().optional() -}); - export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHandlerOptions): RequestHandler { // Nested router so we can configure middleware and restrict HTTP method const router = express.Router(); @@ -140,19 +135,8 @@ export function tokenHandler({ provider, rateLimit: rateLimitConfig }: TokenHand res.status(200).json(tokens); break; } - - case 'client_credentials': { - const parseResult = ClientCredentialsGrantSchema.safeParse(req.body); - if (!parseResult.success) { - throw new InvalidRequestError(parseResult.error.message); - } - const { scope, resource } = parseResult.data; - const scopes = scope?.split(' '); - const tokens = await provider.issueClientCredentialsToken(client, scopes, resource ? new URL(resource) : undefined); - res.status(200).json(tokens); - break; - } - + // 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.test.ts b/src/server/auth/middleware/clientAuth.test.ts index 7991ab2db..5ad6f301f 100644 --- a/src/server/auth/middleware/clientAuth.test.ts +++ b/src/server/auth/middleware/clientAuth.test.ts @@ -28,13 +28,6 @@ describe('clientAuth middleware', () => { client_secret_expires_at: Math.floor(Date.now() / 1000) - 3600, // Expired 1 hour ago redirect_uris: ['https://example.com/callback'] }; - } else if (clientId === 'hs-client') { - // Symmetric key client for HS* private_key_jwt verification - return { - client_id: 'hs-client', - client_secret: 'hs-secret', - redirect_uris: ['https://example.com/callback'] - }; } return undefined; } @@ -58,38 +51,6 @@ describe('clientAuth middleware', () => { }); }); - it('authenticates with client_secret_basic header', async () => { - const basic = Buffer.from('valid-client:valid-secret').toString('base64'); - const response = await supertest(app).post('/protected').set('Authorization', `Basic ${basic}`).send(); - expect(response.status).toBe(200); - expect(response.body.success).toBe(true); - expect(response.body.client.client_id).toBe('valid-client'); - }); - - it('authenticates with private_key_jwt (HS256)', async () => { - const jose = await import('jose'); - const secret = new TextEncoder().encode('hs-secret'); - - const now = Math.floor(Date.now() / 1000); - const assertion = await new jose.SignJWT({}) - .setProtectedHeader({ alg: 'HS256', typ: 'JWT' }) - .setIssuer('hs-client') - .setSubject('hs-client') - .setAudience('http://auth.example.com/protected') - .setIssuedAt(now) - .setExpirationTime(now + 300) - .sign(secret); - - const response = await supertest(app).post('/protected').set('Host', 'auth.example.com').send({ - client_id: 'hs-client', - client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', - client_assertion: assertion - }); - - expect(response.status).toBe(200); - expect(response.body.success).toBe(true); - expect(response.body.client.client_id).toBe('hs-client'); - }); it('authenticates valid client credentials', async () => { const response = await supertest(app).post('/protected').send({ client_id: 'valid-client', diff --git a/src/server/auth/middleware/clientAuth.ts b/src/server/auth/middleware/clientAuth.ts index 4be46e3a8..6cc6a1923 100644 --- a/src/server/auth/middleware/clientAuth.ts +++ b/src/server/auth/middleware/clientAuth.ts @@ -3,7 +3,6 @@ import { RequestHandler } from 'express'; import { OAuthRegisteredClientsStore } from '../clients.js'; import { OAuthClientInformationFull } from '../../../shared/auth.js'; import { InvalidRequestError, InvalidClientError, ServerError, OAuthError } from '../errors.js'; -import { createLocalJWKSet, createRemoteJWKSet, decodeProtectedHeader, jwtVerify, JWTVerifyGetKey } from 'jose'; export type ClientAuthenticationMiddlewareOptions = { /** @@ -29,95 +28,6 @@ declare module 'express-serve-static-core' { export function authenticateClient({ clientsStore }: ClientAuthenticationMiddlewareOptions): RequestHandler { return async (req, res, next) => { try { - // 1) HTTP Basic (client_secret_basic) - const authHeader = req.headers.authorization; - if (authHeader && authHeader.toLowerCase().startsWith('basic ')) { - const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf8'); - const sep = decoded.indexOf(':'); - const basicClientId = decoded.slice(0, sep); - const basicClientSecret = decoded.slice(sep + 1); - - const client = await clientsStore.getClient(basicClientId); - if (!client) { - throw new InvalidClientError('Invalid client_id'); - } - if (!client.client_secret) { - throw new InvalidClientError('Client not configured for client_secret authentication'); - } - if (client.client_secret !== basicClientSecret) { - throw new InvalidClientError('Invalid client_secret'); - } - if (client.client_secret_expires_at && client.client_secret_expires_at < Math.floor(Date.now() / 1000)) { - throw new InvalidClientError('Client secret has expired'); - } - - req.client = client; - return next(); - } - - // 2) private_key_jwt via client_assertion - const assertionType = typeof req.body?.client_assertion_type === 'string' ? req.body.client_assertion_type : undefined; - const assertion = typeof req.body?.client_assertion === 'string' ? req.body.client_assertion : undefined; - if (assertionType === 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' && assertion) { - // Decode header to check alg - const protectedHeader = decodeProtectedHeader(assertion); - const alg = protectedHeader.alg || ''; - - // Determine expected audience (token endpoint URL) - const expectedAudience = `${req.protocol}://${req.get('host')}${req.originalUrl}`; - - // We need a client_id to fetch client metadata; per RFC 7523, sub identifies the client - // Verify using JWKS (embedded or remote), or HMAC secret for HS* algorithms - // First, parse without verification to extract sub/iss would require more deps; instead verify - // against all potential keys we can derive from request client_id if provided, otherwise defer to failure - const candidateClientId = typeof req.body?.client_id === 'string' ? (req.body.client_id as string) : undefined; - - // If no client_id provided in body, attempt to verify against all known clients is not feasible. - // Require client_id in body for now, or rely on iss/sub matching after verification. - if (!candidateClientId) { - // We can still verify then read payload, but we need a key set. - // Without client hint, we cannot pick a key. Treat as invalid request. - throw new InvalidRequestError('client_id is required when using private_key_jwt'); - } - - const client = await clientsStore.getClient(candidateClientId); - if (!client) { - throw new InvalidClientError('Invalid client_id'); - } - - // Build key for verification - let keyOrGetKey: ReturnType | ReturnType | Uint8Array | undefined; - - if (client.jwks) { - keyOrGetKey = createLocalJWKSet({ keys: client.jwks.keys ?? client.jwks }); - } else if (client.jwks_uri) { - try { - const jwksUrl = new URL(client.jwks_uri); - keyOrGetKey = createRemoteJWKSet(jwksUrl); - } catch { - throw new InvalidClientError('Invalid jwks_uri in client registration'); - } - } else if (alg && alg.startsWith('HS') && client.client_secret) { - keyOrGetKey = new TextEncoder().encode(client.client_secret); - } else { - throw new InvalidClientError('No verification key available for private_key_jwt'); - } - - const { payload } = await jwtVerify(assertion, keyOrGetKey as JWTVerifyGetKey, { - audience: expectedAudience, - issuer: client.client_id - }); - - // Validate sub and iss - if (payload.sub !== client.client_id) { - throw new InvalidClientError('Invalid client_assertion: subject does not match client_id'); - } - - req.client = client; - return next(); - } - - // 3) client_secret_post (body params) const result = ClientAuthenticatedRequestSchema.safeParse(req.body); if (!result.success) { throw new InvalidRequestError(String(result.error)); diff --git a/src/server/auth/provider.ts b/src/server/auth/provider.ts index 1b2b77619..cf1c306de 100644 --- a/src/server/auth/provider.ts +++ b/src/server/auth/provider.ts @@ -55,11 +55,6 @@ export interface OAuthServerProvider { */ verifyAccessToken(token: string): Promise; - /** - * Issues an access token for the client_credentials grant. - */ - issueClientCredentialsToken(client: OAuthClientInformationFull, scopes?: string[], resource?: URL): Promise; - /** * Revokes an access or refresh token. If unimplemented, token revocation is not supported (not recommended). * diff --git a/src/server/auth/providers/proxyProvider.ts b/src/server/auth/providers/proxyProvider.ts index 1f4129f34..32f256450 100644 --- a/src/server/auth/providers/proxyProvider.ts +++ b/src/server/auth/providers/proxyProvider.ts @@ -231,38 +231,4 @@ export class ProxyOAuthServerProvider implements OAuthServerProvider { async verifyAccessToken(token: string): Promise { return this._verifyAccessToken(token); } - - async issueClientCredentialsToken(client: OAuthClientInformationFull, scopes?: string[], resource?: URL): Promise { - const params = new URLSearchParams({ - grant_type: 'client_credentials', - client_id: client.client_id - }); - - if (client.client_secret) { - params.set('client_secret', client.client_secret); - } - - if (scopes?.length) { - params.set('scope', scopes.join(' ')); - } - - if (resource) { - params.set('resource', resource.href); - } - - const response = await (this._fetch ?? fetch)(this._endpoints.tokenUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded' - }, - body: params.toString() - }); - - if (!response.ok) { - throw new ServerError(`Token issue (client_credentials) failed: ${response.status}`); - } - - const data = await response.json(); - return OAuthTokensSchema.parse(data); - } } diff --git a/src/server/auth/router.test.ts b/src/server/auth/router.test.ts index 247c9bc35..ae280286b 100644 --- a/src/server/auth/router.test.ts +++ b/src/server/auth/router.test.ts @@ -74,14 +74,6 @@ describe('MCP Auth Router', () => { async revokeToken(_client: OAuthClientInformationFull, _request: OAuthTokenRevocationRequest): Promise { // Success - do nothing in mock - }, - - async issueClientCredentialsToken(): Promise { - return { - access_token: 'cc_access', - token_type: 'bearer', - expires_in: 3600 - }; } }; @@ -141,14 +133,6 @@ describe('MCP Auth Router', () => { }; } throw new InvalidTokenError('Token is invalid or expired'); - }, - - async issueClientCredentialsToken(): Promise { - return { - access_token: 'cc_access', - token_type: 'bearer', - expires_in: 3600 - }; } }; @@ -227,14 +211,9 @@ describe('MCP Auth Router', () => { // Verify supported features expect(response.body.response_types_supported).toEqual(['code']); - expect(response.body.grant_types_supported).toEqual(['authorization_code', 'refresh_token', 'client_credentials']); + expect(response.body.grant_types_supported).toEqual(['authorization_code', 'refresh_token']); expect(response.body.code_challenge_methods_supported).toEqual(['S256']); - expect(response.body.token_endpoint_auth_methods_supported).toEqual([ - 'client_secret_post', - 'client_secret_basic', - 'private_key_jwt', - 'none' - ]); + expect(response.body.token_endpoint_auth_methods_supported).toEqual(['client_secret_post', 'none']); expect(response.body.revocation_endpoint_auth_methods_supported).toEqual(['client_secret_post']); // Verify optional fields diff --git a/src/server/auth/router.ts b/src/server/auth/router.ts index e17d42be5..5229c4df8 100644 --- a/src/server/auth/router.ts +++ b/src/server/auth/router.ts @@ -92,9 +92,8 @@ export const createOAuthMetadata = (options: { code_challenge_methods_supported: ['S256'], token_endpoint: new URL(token_endpoint, baseUrl || issuer).href, - token_endpoint_auth_methods_supported: ['client_secret_post', 'client_secret_basic', 'private_key_jwt', 'none'], - token_endpoint_auth_signing_alg_values_supported: ['RS256'], - grant_types_supported: ['authorization_code', 'refresh_token', 'client_credentials'], + token_endpoint_auth_methods_supported: ['client_secret_post', 'none'], + grant_types_supported: ['authorization_code', 'refresh_token'], scopes_supported: options.scopesSupported, From 211534345db55040e3431fe82e7f2c0f143ac942 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Wed, 26 Nov 2025 08:29:12 +0200 Subject: [PATCH 06/13] clean up --- .../server/demoInMemoryOAuthProvider.ts | 26 +------------------ 1 file changed, 1 insertion(+), 25 deletions(-) diff --git a/src/examples/server/demoInMemoryOAuthProvider.ts b/src/examples/server/demoInMemoryOAuthProvider.ts index ea05ee87c..afa3ce83b 100644 --- a/src/examples/server/demoInMemoryOAuthProvider.ts +++ b/src/examples/server/demoInMemoryOAuthProvider.ts @@ -142,31 +142,7 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { ): Promise { throw new Error('Not implemented for example demo'); } - - async issueClientCredentialsToken(client: OAuthClientInformationFull, scopes?: string[], resource?: URL): Promise { - if (this.validateResource && !this.validateResource(resource)) { - throw new Error(`Invalid resource: ${resource}`); - } - - const token = randomUUID(); - const tokenData = { - token, - clientId: client.client_id, - scopes: scopes || [], - expiresAt: Date.now() + 3600000, // 1 hour - resource, - type: 'access' - }; - this.tokens.set(token, tokenData); - - return { - access_token: token, - token_type: 'bearer', - expires_in: 3600, - scope: (scopes || []).join(' ') - }; - } - + async verifyAccessToken(token: string): Promise { const tokenData = this.tokens.get(token); if (!tokenData || !tokenData.expiresAt || tokenData.expiresAt < Date.now()) { From f1b8aefcb30322d6ea10a428bdce01d01ecdbde1 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Wed, 26 Nov 2025 08:29:26 +0200 Subject: [PATCH 07/13] clean up --- src/examples/server/demoInMemoryOAuthProvider.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/examples/server/demoInMemoryOAuthProvider.ts b/src/examples/server/demoInMemoryOAuthProvider.ts index afa3ce83b..1abc040ce 100644 --- a/src/examples/server/demoInMemoryOAuthProvider.ts +++ b/src/examples/server/demoInMemoryOAuthProvider.ts @@ -142,7 +142,7 @@ export class DemoInMemoryAuthProvider implements OAuthServerProvider { ): Promise { throw new Error('Not implemented for example demo'); } - + async verifyAccessToken(token: string): Promise { const tokenData = this.tokens.get(token); if (!tokenData || !tokenData.expiresAt || tokenData.expiresAt < Date.now()) { From faa3c539d801f1e861083a5ad41264ae846b7d61 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Wed, 26 Nov 2025 14:48:32 +0000 Subject: [PATCH 08/13] split out into extension file --- src/client/auth-extensions.ts | 302 ++++++++++ src/client/auth.test.ts | 47 +- src/client/auth.ts | 550 +++++++----------- src/client/clientCredentials.test.ts | 89 --- src/client/jwtBearer.test.ts | 131 +++-- .../client/simpleClientCredentials.ts | 115 ++-- src/examples/client/simpleJwtBearer.ts | 115 ---- src/shared/auth.ts | 43 -- 8 files changed, 684 insertions(+), 708 deletions(-) create mode 100644 src/client/auth-extensions.ts delete mode 100644 src/client/clientCredentials.test.ts delete mode 100644 src/examples/client/simpleJwtBearer.ts diff --git a/src/client/auth-extensions.ts b/src/client/auth-extensions.ts new file mode 100644 index 000000000..6cccbc28e --- /dev/null +++ b/src/client/auth-extensions.ts @@ -0,0 +1,302 @@ +/** + * 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 { 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; +}): 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'); + }; +} + +/** + * 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: OAuthClientProvider['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; + } +} diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 450890b2a..97cf0552c 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 } from '../shared/auth.js'; import { expect, vi, type Mock } from 'vitest'; @@ -1324,12 +1325,11 @@ describe('OAuth Authorization', () => { }), expect.objectContaining({ method: 'POST', - headers: new Headers({ - 'Content-Type': 'application/x-www-form-urlencoded' - }) }) ); + 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'); @@ -1540,7 +1540,7 @@ describe('OAuth Authorization', () => { vi.clearAllMocks(); }); - it('performs client_credentials with private_key_jwt when jwtBearerOptions are provided', async () => { + 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(); @@ -1585,21 +1585,38 @@ describe('OAuth Authorization', () => { 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: { + // 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'); diff --git a/src/client/auth.ts b/src/client/auth.ts index 8a02b04ae..95e23e784 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -10,9 +10,7 @@ import { OAuthProtectedResourceMetadata, OAuthErrorResponseSchema, AuthorizationServerMetadata, - OpenIdProviderDiscoveryMetadataSchema, - JwtAssertionOptions, - isJwtPrebuiltAssertion + OpenIdProviderDiscoveryMetadataSchema } from '../shared/auth.js'; import { OAuthClientInformationFullSchema, @@ -31,7 +29,6 @@ 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. @@ -43,8 +40,10 @@ import type { JWK } from 'jose'; 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 @@ -147,6 +146,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'; @@ -320,15 +357,6 @@ 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 { @@ -355,15 +383,13 @@ async function authInternal( authorizationCode, scope, resourceMetadataUrl, - fetchFn, - jwtBearerOptions + fetchFn }: { serverUrl: string | URL; authorizationCode?: string; scope?: string; resourceMetadataUrl?: URL; fetchFn?: FetchLike; - jwtBearerOptions?: JwtAssertionOptions; } ): Promise { let resourceMetadata: OAuthProtectedResourceMetadata | undefined; @@ -433,18 +459,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); @@ -479,50 +503,6 @@ 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 @@ -1020,77 +1000,76 @@ 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}`); - } + const tokenUrl = metadata?.token_endpoint + ? new URL(metadata.token_endpoint) + : new URL('/token', authorizationServerUrl); - // 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) { - await addClientAuthentication(headers, params, tokenUrl, 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) { @@ -1101,226 +1080,181 @@ export async function exchangeAuthorization( } /** - * Exchange a refresh token for an updated access token. + * 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 - * - Preserves the original refresh token if a new one is not returned + * - 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, refresh token, etc. - * @returns Promise resolving to OAuth tokens (preserves original refresh_token if not replaced) - * @throws {Error} When token refresh fails or authentication is invalid + * @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 refreshAuthorization( +export async function exchangeAuthorization( authorizationServerUrl: string | URL, { metadata, clientInformation, - refreshToken, + authorizationCode, + codeVerifier, + redirectUri, resource, addClientAuthentication, fetchFn }: { metadata?: AuthorizationServerMetadata; clientInformation: OAuthClientInformationMixed; - refreshToken: string; + authorizationCode: string; + codeVerifier: string; + redirectUri: string | URL; resource?: URL; addClientAuthentication?: OAuthClientProvider['addClientAuthentication']; 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, - refresh_token: refreshToken - }); - - if (addClientAuthentication) { - await addClientAuthentication(headers, params, tokenUrl, metadata); - } else { - // Determine and apply client authentication method - 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); - } + const tokenRequestParams = prepareAuthorizationCodeRequest( + authorizationCode, + codeVerifier, + redirectUri + ); - const response = await (fetchFn ?? fetch)(tokenUrl, { - method: 'POST', - headers, - body: params + return executeTokenRequest(authorizationServerUrl, { + metadata, + tokenRequestParams, + clientInformation, + addClientAuthentication, + resource, + fetchFn }); - if (!response.ok) { - throw await parseErrorResponse(response); - } - - return OAuthTokensSchema.parse({ refresh_token: refreshToken, ...(await response.json()) }); } /** - * Exchange client credentials for an access token (client_credentials grant). + * Exchange a refresh token for an updated access token. * - * 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 + * Supports multiple client authentication methods as specified in OAuth 2.1: + * - Automatically selects the best authentication method based on server support + * - Preserves the original refresh token if a new one is not returned * - * Includes RFC 8707 resource parameter only when a protected resource was discovered. + * @param authorizationServerUrl - The authorization server's base URL + * @param options - Configuration object containing client info, refresh token, etc. + * @returns Promise resolving to OAuth tokens (preserves original refresh_token if not replaced) + * @throws {Error} When token refresh fails or authentication is invalid */ -export async function exchangeClientCredentials( +export async function refreshAuthorization( authorizationServerUrl: string | URL, { metadata, clientInformation, - scope, + refreshToken, resource, addClientAuthentication, fetchFn }: { metadata?: AuthorizationServerMetadata; clientInformation: OAuthClientInformationMixed; - scope?: string; + refreshToken: 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 + const tokenRequestParams = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: refreshToken }); - 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 + const tokens = await executeTokenRequest(authorizationServerUrl, { + metadata, + tokenRequestParams, + clientInformation, + addClientAuthentication, + resource, + fetchFn }); - if (!response.ok) { - throw await parseErrorResponse(response); - } - return OAuthTokensSchema.parse(await response.json()); + // Preserve original refresh token if server didn't return a new one + return { refresh_token: refreshToken, ...tokens }; } /** - * Creates a JWT assertion suitable for RFC 7523 Section 2.2 private_key_jwt - * client authentication. + * Unified token fetching that works with any grant type via provider.prepareTokenRequest(). * - * If `options.assertion` is provided, it is returned as-is without signing. + * 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 createJwtBearerAssertion( +export async function fetchToken( + provider: OAuthClientProvider, 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; + { + 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; - const now = Math.floor(Date.now() / 1000); - const jti = `${Date.now()}-${Math.random().toString(36).slice(2)}`; + // 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 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}`); + // Default to authorization_code grant if no custom prepareTokenRequest + if (!tokenRequestParams) { + if (!authorizationCode) { + throw new Error('Either provider.prepareTokenRequest() or authorizationCode is required'); } - } 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); + if (!provider.redirectUrl) { + throw new Error('redirectUrl is required for authorization_code flow'); } - } else { - // Treat as JWK - key = await jose.importJWK(options.privateKey as JWK, alg); + const codeVerifier = await provider.codeVerifier(); + tokenRequestParams = prepareAuthorizationCodeRequest( + authorizationCode, + codeVerifier, + provider.redirectUrl + ); } - 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); + const clientInformation = await provider.clientInformation(); + + return executeTokenRequest(authorizationServerUrl, { + metadata, + tokenRequestParams, + clientInformation: clientInformation ?? undefined, + addClientAuthentication: provider.addClientAuthentication, + resource, + fetchFn + }); } -/** - * 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. */ @@ -1363,77 +1297,3 @@ 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 deleted file mode 100644 index 6c15f837a..000000000 --- a/src/client/clientCredentials.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -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 index 91c25678e..fa0462562 100644 --- a/src/client/jwtBearer.test.ts +++ b/src/client/jwtBearer.test.ts @@ -1,28 +1,27 @@ import { describe, it, expect } from 'vitest'; -import { createJwtBearerAssertion } from './auth.js'; -import type { JwtAssertionOptions } from '../shared/auth.js'; +import { createPrivateKeyJwtAuth } from './auth-extensions.js'; -describe('createJwtBearerAssertion', () => { - const baseOptions: JwtAssertionOptions = { +describe('createPrivateKeyJwtAuth', () => { + const baseOptions = { 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' - }); + it('creates an addClientAuthentication function that sets JWT assertion params', async () => { + const addClientAuth = createPrivateKeyJwtAuth(baseOptions); - expect(assertion).toBe('pre.built.jwt'); - }); + const headers = new Headers(); + const params = new URLSearchParams(); - it('creates a signed JWT when no pre-built assertion is provided', async () => { - const assertion = await createJwtBearerAssertion('https://auth.example.com', undefined, baseOptions); + await addClientAuth(headers, params, 'https://auth.example.com/token', undefined); - // Basic shape check for JWT: three segments separated by dots + 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); }); @@ -30,13 +29,17 @@ describe('createJwtBearerAssertion', () => { 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, { + 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); }); @@ -49,29 +52,33 @@ describe('createJwtBearerAssertion', () => { alg: 'HS256' }; - const assertion = await createJwtBearerAssertion('https://auth.example.com', undefined, { + 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 () => { /** - * 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 - ``` - */ + * 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 @@ -101,39 +108,66 @@ wZs2jVskGA6OxU/II0nCh9C+hp1LV4vl5Hy1mM3Lkqa/I/AC4kJdvTwi45lXpM9o btHHccicX+r3BsSv5adOxQ== -----END PRIVATE KEY-----`; - const assertion = await createJwtBearerAssertion('https://auth.example.com', undefined, { + 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( - 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 - }) + 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( - createJwtBearerAssertion('https://auth.example.com', undefined, { - issuer: 'client-id', - subject: 'client-id', - privateKey: badPem, - alg: 'RS256' - }) + addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined) ).rejects.toThrow(/Invalid character/); }); @@ -144,14 +178,17 @@ btHHccicX+r3BsSv5adOxQ== 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( - 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' - }) + 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/examples/client/simpleClientCredentials.ts b/src/examples/client/simpleClientCredentials.ts index e8de01138..2c3d1f33f 100644 --- a/src/examples/client/simpleClientCredentials.ts +++ b/src/examples/client/simpleClientCredentials.ts @@ -1,75 +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 { OAuthClientInformationMixed, OAuthClientMetadata, OAuthTokens } from '../../shared/auth.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'; -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; +function createProvider(): OAuthClientProvider { + const clientId = process.env.MCP_CLIENT_ID; + if (!clientId) { + console.error('MCP_CLIENT_ID environment variable is required'); + process.exit(1); } - saveTokens(tokens: OAuthTokens): void { - this._tokens = tokens; - } - redirectToAuthorization(): void { - // Not used for client_credentials - } - saveCodeVerifier(): void { - // Not used for client_credentials + + // 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 + }); } - codeVerifier(): string { - throw new Error('Not used for client_credentials'); + + // 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); } - addClientAuthentication = this.addAuth; + + console.log('Using client_secret_basic authentication'); + return new ClientCredentialsProvider({ + clientId, + clientSecret + }); } 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' - }; + const provider = createProvider(); - // 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 client = new Client( + { name: 'client-credentials-example', version: '1.0.0' }, + { capabilities: {} } + ); - 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 }); + const transport = new StreamableHTTPClientTransport(new URL(DEFAULT_SERVER_URL), { + authProvider: provider + }); await client.connect(transport); - console.log('Connected with client_credentials token.'); + 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 => { diff --git a/src/examples/client/simpleJwtBearer.ts b/src/examples/client/simpleJwtBearer.ts deleted file mode 100644 index eaee3f3e6..000000000 --- a/src/examples/client/simpleJwtBearer.ts +++ /dev/null @@ -1,115 +0,0 @@ -#!/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/shared/auth.ts b/src/shared/auth.ts index a5338f995..b37a4c70c 100644 --- a/src/shared/auth.ts +++ b/src/shared/auth.ts @@ -213,49 +213,6 @@ 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; From b58771c756ec907a46d100dddc8ab0b13dfa35bf Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Wed, 26 Nov 2025 17:57:43 +0000 Subject: [PATCH 09/13] fix type error --- src/client/auth-extensions.ts | 12 +++---- src/client/auth.test.ts | 6 ++-- src/client/auth.ts | 34 ++++++++----------- src/client/jwtBearer.test.ts | 18 +++++----- .../client/simpleClientCredentials.ts | 5 +-- 5 files changed, 31 insertions(+), 44 deletions(-) diff --git a/src/client/auth-extensions.ts b/src/client/auth-extensions.ts index 6cccbc28e..3b4c60957 100644 --- a/src/client/auth-extensions.ts +++ b/src/client/auth-extensions.ts @@ -6,12 +6,8 @@ */ import type { JWK } from 'jose'; -import { - OAuthClientInformation, - OAuthClientMetadata, - OAuthTokens -} from '../shared/auth.js'; -import { OAuthClientProvider } from './auth.js'; +import { OAuthClientInformation, OAuthClientMetadata, OAuthTokens } from '../shared/auth.js'; +import { AddClientAuthentication, OAuthClientProvider } from './auth.js'; /** * Helper to produce a private_key_jwt client authentication function. @@ -28,7 +24,7 @@ export function createPrivateKeyJwtAuth(options: { audience?: string | URL; lifetimeSeconds?: number; claims?: Record; -}): OAuthClientProvider['addClientAuthentication'] { +}): AddClientAuthentication { return async (_headers, params, url, metadata) => { // Lazy import to avoid heavy dependency unless used const jose = await import('jose'); @@ -237,7 +233,7 @@ export class PrivateKeyJwtProvider implements OAuthClientProvider { private _tokens?: OAuthTokens; private _clientInfo: OAuthClientInformation; private _clientMetadata: OAuthClientMetadata; - addClientAuthentication: OAuthClientProvider['addClientAuthentication']; + addClientAuthentication: AddClientAuthentication; constructor(options: PrivateKeyJwtProviderOptions) { this._clientInfo = { diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 97cf0552c..4fdf50001 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -1172,11 +1172,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'); } }); @@ -1324,7 +1324,7 @@ describe('OAuth Authorization', () => { href: 'https://auth.example.com/token' }), expect.objectContaining({ - method: 'POST', + method: 'POST' }) ); diff --git a/src/client/auth.ts b/src/client/auth.ts index 95e23e784..7a11d9f17 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. * @@ -124,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 @@ -1045,9 +1050,7 @@ async function executeTokenRequest( fetchFn?: FetchLike; } ): Promise { - const tokenUrl = metadata?.token_endpoint - ? new URL(metadata.token_endpoint) - : new URL('/token', authorizationServerUrl); + const tokenUrl = metadata?.token_endpoint ? new URL(metadata.token_endpoint) : new URL('/token', authorizationServerUrl); const headers = new Headers({ 'Content-Type': 'application/x-www-form-urlencoded', @@ -1113,11 +1116,7 @@ export async function exchangeAuthorization( fetchFn?: FetchLike; } ): Promise { - const tokenRequestParams = prepareAuthorizationCodeRequest( - authorizationCode, - codeVerifier, - redirectUri - ); + const tokenRequestParams = prepareAuthorizationCodeRequest(authorizationCode, codeVerifier, redirectUri); return executeTokenRequest(authorizationServerUrl, { metadata, @@ -1236,11 +1235,7 @@ export async function fetchToken( throw new Error('redirectUrl is required for authorization_code flow'); } const codeVerifier = await provider.codeVerifier(); - tokenRequestParams = prepareAuthorizationCodeRequest( - authorizationCode, - codeVerifier, - provider.redirectUrl - ); + tokenRequestParams = prepareAuthorizationCodeRequest(authorizationCode, codeVerifier, provider.redirectUrl); } const clientInformation = await provider.clientInformation(); @@ -1296,4 +1291,3 @@ export async function registerClient( return OAuthClientInformationFullSchema.parse(await response.json()); } - diff --git a/src/client/jwtBearer.test.ts b/src/client/jwtBearer.test.ts index fa0462562..4d3c64040 100644 --- a/src/client/jwtBearer.test.ts +++ b/src/client/jwtBearer.test.ts @@ -150,9 +150,9 @@ btHHccicX+r3BsSv5adOxQ== }); const params = new URLSearchParams(); - await expect( - addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined) - ).rejects.toThrow('Unsupported algorithm none'); + 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 () => { @@ -166,9 +166,9 @@ btHHccicX+r3BsSv5adOxQ== }); const params = new URLSearchParams(); - await expect( - addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined) - ).rejects.toThrow(/Invalid character/); + 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 () => { @@ -187,8 +187,8 @@ btHHccicX+r3BsSv5adOxQ== }); 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/); + 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/examples/client/simpleClientCredentials.ts b/src/examples/client/simpleClientCredentials.ts index 2c3d1f33f..7defcc41f 100644 --- a/src/examples/client/simpleClientCredentials.ts +++ b/src/examples/client/simpleClientCredentials.ts @@ -61,10 +61,7 @@ function createProvider(): OAuthClientProvider { async function main() { const provider = createProvider(); - const client = new Client( - { name: 'client-credentials-example', version: '1.0.0' }, - { capabilities: {} } - ); + const client = new Client({ name: 'client-credentials-example', version: '1.0.0' }, { capabilities: {} }); const transport = new StreamableHTTPClientTransport(new URL(DEFAULT_SERVER_URL), { authProvider: provider From b1bc454f6104d8a4657987d53290eb67abedc910 Mon Sep 17 00:00:00 2001 From: Paul Carleton Date: Wed, 26 Nov 2025 18:07:14 +0000 Subject: [PATCH 10/13] dont commit a key --- src/client/jwtBearer.test.ts | 43 ++++-------------------------------- 1 file changed, 4 insertions(+), 39 deletions(-) diff --git a/src/client/jwtBearer.test.ts b/src/client/jwtBearer.test.ts index 4d3c64040..c7132e064 100644 --- a/src/client/jwtBearer.test.ts +++ b/src/client/jwtBearer.test.ts @@ -68,45 +68,10 @@ describe('createPrivateKeyJwtAuth', () => { }); 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-----`; + // 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', From 5f992390dddcb28315df3c32f9f497c5228808c2 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Thu, 27 Nov 2025 16:03:38 +0200 Subject: [PATCH 11/13] add StaticPrivateKeyJwtProvider, add tests, consolidate tests in auth-extensions.ts --- src/client/auth-extensions.test.ts | 351 +++++++++++++++++++++++++++++ src/client/auth-extensions.ts | 97 ++++++++ src/client/jwtBearer.test.ts | 159 ------------- 3 files changed, 448 insertions(+), 159 deletions(-) create mode 100644 src/client/auth-extensions.test.ts delete mode 100644 src/client/jwtBearer.test.ts diff --git a/src/client/auth-extensions.test.ts b/src/client/auth-extensions.test.ts new file mode 100644 index 000000000..0ca8623bd --- /dev/null +++ b/src/client/auth-extensions.test.ts @@ -0,0 +1,351 @@ +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('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 index 3b4c60957..2d385ac6f 100644 --- a/src/client/auth-extensions.ts +++ b/src/client/auth-extensions.ts @@ -296,3 +296,100 @@ export class PrivateKeyJwtProvider implements OAuthClientProvider { 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/jwtBearer.test.ts b/src/client/jwtBearer.test.ts deleted file mode 100644 index c7132e064..000000000 --- a/src/client/jwtBearer.test.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { createPrivateKeyJwtAuth } from './auth-extensions.js'; - -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('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/ - ); - }); -}); From f16bc71a5efebd833876f5744dea8b8848e97caf Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Thu, 27 Nov 2025 22:15:51 +0200 Subject: [PATCH 12/13] Add README, add throw if crypto not available in older Node versions --- README.md | 85 ++++++++++++++++++++++++++++++ src/client/auth-extensions.test.ts | 24 +++++++++ src/client/auth-extensions.ts | 4 ++ 3 files changed, 113 insertions(+) diff --git a/README.md b/README.md index bfe5f6625..2bb00e884 100644 --- a/README.md +++ b/README.md @@ -790,6 +790,29 @@ 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 @@ -1430,6 +1453,68 @@ 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/src/client/auth-extensions.test.ts b/src/client/auth-extensions.test.ts index 0ca8623bd..605bb3a96 100644 --- a/src/client/auth-extensions.test.ts +++ b/src/client/auth-extensions.test.ts @@ -218,6 +218,30 @@ describe('createPrivateKeyJwtAuth', () => { 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'); diff --git a/src/client/auth-extensions.ts b/src/client/auth-extensions.ts index 2d385ac6f..44e2478e0 100644 --- a/src/client/auth-extensions.ts +++ b/src/client/auth-extensions.ts @@ -27,6 +27,10 @@ export function createPrivateKeyJwtAuth(options: { }): 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); From 6822457c26311e8c686dd98cbd1f569e6ec92ba2 Mon Sep 17 00:00:00 2001 From: Konstantin Konstantinov Date: Thu, 27 Nov 2025 22:17:30 +0200 Subject: [PATCH 13/13] prettier fix --- README.md | 9 +++------ src/client/auth-extensions.test.ts | 4 +--- src/client/auth-extensions.ts | 4 +++- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 2bb00e884..1866998f0 100644 --- a/README.md +++ b/README.md @@ -795,7 +795,8 @@ To test your server, you can use the [MCP Inspector](https://github.com/modelcon 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 ) +- **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`: @@ -1466,11 +1467,7 @@ You can use these providers with the `StreamableHTTPClientTransport` and the hig ```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 { 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/'); diff --git a/src/client/auth-extensions.test.ts b/src/client/auth-extensions.test.ts index 605bb3a96..0592c28e4 100644 --- a/src/client/auth-extensions.test.ts +++ b/src/client/auth-extensions.test.ts @@ -231,9 +231,7 @@ describe('createPrivateKeyJwtAuth', () => { const addClientAuth = createPrivateKeyJwtAuth(baseOptions); const params = new URLSearchParams(); - await expect( - addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined) - ).rejects.toThrow( + 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 { diff --git a/src/client/auth-extensions.ts b/src/client/auth-extensions.ts index 44e2478e0..f3908d2c2 100644 --- a/src/client/auth-extensions.ts +++ b/src/client/auth-extensions.ts @@ -28,7 +28,9 @@ export function createPrivateKeyJwtAuth(options: { 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)'); + 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');