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