From c598a2f1d0e9282efb2325486e03083c73f774bd Mon Sep 17 00:00:00 2001 From: Zachary German Date: Thu, 14 Aug 2025 02:39:51 +0000 Subject: [PATCH] Add RFC 7523 (JWT Bearer Grant) support --- package-lock.json | 33 ++ package.json | 1 + src/client/auth.test.ts | 331 ++++++++++++++- src/client/auth.ts | 283 +++++++++++-- src/client/streamableHttp.test.ts | 3 + src/examples/client/jwtOAuthClient.ts | 539 +++++++++++++++++++++++++ src/server/auth/errors.ts | 41 +- src/shared/auth.ts | 158 ++++++++ src/shared/jwt-utils.test.ts | 379 ++++++++++++++++++ src/shared/jwt-utils.ts | 553 ++++++++++++++++++++++++++ 10 files changed, 2269 insertions(+), 52 deletions(-) create mode 100644 src/examples/client/jwtOAuthClient.ts create mode 100644 src/shared/jwt-utils.test.ts create mode 100644 src/shared/jwt-utils.ts diff --git a/package-lock.json b/package-lock.json index 2fdf89b2e..c632e9644 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.17.2", "license": "MIT", "dependencies": { + "@modelcontextprotocol/sdk": "^1.17.1", "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", @@ -17,6 +18,7 @@ "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", + "jose": "^5.0.0", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", @@ -1559,6 +1561,29 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.17.1.tgz", + "integrity": "sha512-CPle1OQehbWqd25La9Ack5B07StKIxh4+Bf19qnpZKJC1oI22Y0czZHbifjw1UoczIfKBwBDAp/dFxvHG13B5A==", + "license": "MIT", + "dependencies": { + "ajv": "^6.12.6", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.0.1", + "express-rate-limit": "^7.5.0", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.23.8", + "zod-to-json-schema": "^3.24.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -4854,6 +4879,14 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/jose": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.10.0.tgz", + "integrity": "sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==", + "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 445134892..f59101cc4 100644 --- a/package.json +++ b/package.json @@ -70,6 +70,7 @@ "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", + "jose": "^5.0.0", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index fb9b31006..c54212c7d 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -12,9 +12,17 @@ import { auth, type OAuthClientProvider, } from "./auth.js"; -import {ServerError} from "../server/auth/errors.js"; +import { ServerError } from "../server/auth/errors.js"; import { AuthorizationServerMetadata } from '../shared/auth.js'; +// Mock JWT utilities +jest.mock('../shared/jwt-utils.js', () => ({ + JWTAssertionGenerator: { + generateClientAssertion: jest.fn().mockResolvedValue('mock.jwt.assertion'), + generateBearerAssertion: jest.fn().mockResolvedValue('mock.bearer.assertion'), + }, +})); + // Mock fetch globally const mockFetch = jest.fn(); global.fetch = mockFetch; @@ -907,7 +915,7 @@ describe("OAuth Authorization", () => { const metadata = await discoverAuthorizationServerMetadata("https://auth.example.com/tenant1"); expect(metadata).toBeUndefined(); - + // Verify that all discovery URLs were attempted expect(mockFetch).toHaveBeenCalledTimes(8); // 4 URLs Ɨ 2 attempts each (with and without headers) }); @@ -1008,12 +1016,12 @@ describe("OAuth Authorization", () => { // OpenID Connect requires that the user is prompted for consent if the scope includes 'offline_access' it("includes consent prompt parameter if scope includes 'offline_access'", async () => { const { authorizationUrl } = await startAuthorization( - "https://auth.example.com", - { - clientInformation: validClientInfo, - redirectUrl: "http://localhost:3000/callback", - scope: "read write profile offline_access", - } + "https://auth.example.com", + { + clientInformation: validClientInfo, + redirectUrl: "http://localhost:3000/callback", + scope: "read write profile offline_access", + } ); expect(authorizationUrl.searchParams.get("prompt")).toBe("consent"); @@ -2220,6 +2228,211 @@ describe("OAuth Authorization", () => { }); }); + describe("auth function with JWT Bearer Grant", () => { + const mockProvider: OAuthClientProvider = { + get redirectUrl() { return "http://localhost:3000/callback"; }, + get clientMetadata() { + return { + redirect_uris: ["http://localhost:3000/callback"], + grant_types: ["authorization_code", "urn:ietf:params:oauth:grant-type:jwt-bearer"], + response_types: ["code"], + scope: "read write", + }; + }, + clientInformation: jest.fn(), + tokens: jest.fn(), + saveTokens: jest.fn(), + redirectToAuthorization: jest.fn(), + saveCodeVerifier: jest.fn(), + codeVerifier: jest.fn(), + jwtCredentials: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should exchange JWT bearer assertion for tokens", async () => { + const jwtAssertion = 'test-jwt-assertion'; + + // Mock client information + (mockProvider.clientInformation as jest.Mock).mockResolvedValue({ + client_id: "test-client", + client_secret: "test-secret", + }); + + // Mock no existing tokens + (mockProvider.tokens as jest.Mock).mockResolvedValue(undefined); + + // Mock protected resource metadata discovery (404 - not found) + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + // Mock authorization server metadata discovery - first URL (path-aware) succeeds + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + issuer: "https://api.example.com", + authorization_endpoint: "https://api.example.com/authorize", + token_endpoint: "https://api.example.com/token", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "urn:ietf:params:oauth:grant-type:jwt-bearer"], + token_endpoint_auth_methods_supported: ["client_secret_basic"], + }), + }); + + // Mock token exchange + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + access_token: "access123", + token_type: "Bearer", + expires_in: 3600, + }), + }); + + const result = await auth(mockProvider, { + serverUrl: "https://api.example.com", // Use root URL to avoid multiple discovery URLs + jwtBearerAssertion: jwtAssertion, + scope: "read write", + }); + + expect(result).toBe("AUTHORIZED"); + expect(mockProvider.saveTokens).toHaveBeenCalledWith({ + access_token: "access123", + token_type: "Bearer", + expires_in: 3600, + }); + + // Verify token exchange request + const tokenCall = mockFetch.mock.calls[2]; // Third call is token exchange + const body = tokenCall[1].body as URLSearchParams; + expect(body.get("grant_type")).toBe("urn:ietf:params:oauth:grant-type:jwt-bearer"); + expect(body.get("assertion")).toBe(jwtAssertion); + expect(body.get("scope")).toBe("read write"); + }); + + it("should generate JWT bearer assertion from credentials when jwtBearerAssertion not provided", async () => { + // Mock JWT credentials + (mockProvider.jwtCredentials as jest.Mock).mockResolvedValue({ + clientSecret: "jwt-secret", + algorithm: "HS256", + tokenLifetime: 300, + }); + + // Mock client information + (mockProvider.clientInformation as jest.Mock).mockResolvedValue({ + client_id: "test-client", + client_secret: "test-secret", + }); + + // Mock no existing tokens + (mockProvider.tokens as jest.Mock).mockResolvedValue(undefined); + + // Mock protected resource metadata discovery (404 - not found) + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + }); + + // Mock authorization server metadata discovery - first URL (path-aware) succeeds + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + issuer: "https://api.example.com", + authorization_endpoint: "https://api.example.com/authorize", + token_endpoint: "https://api.example.com/token", + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "urn:ietf:params:oauth:grant-type:jwt-bearer"], + token_endpoint_auth_methods_supported: ["client_secret_basic"], + }), + }); + + // Mock token exchange + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + access_token: "access123", + token_type: "Bearer", + expires_in: 3600, + }), + }); + + const result = await auth(mockProvider, { + serverUrl: "https://api.example.com", // Use root URL to avoid multiple discovery URLs + scope: "read write", + }); + + expect(result).toBe("AUTHORIZED"); + expect(mockProvider.saveTokens).toHaveBeenCalledWith({ + access_token: "access123", + token_type: "Bearer", + expires_in: 3600, + }); + + // Verify token exchange request uses JWT bearer grant + const tokenCall = mockFetch.mock.calls[2]; // Third call is token exchange + const body = tokenCall[1].body as URLSearchParams; + expect(body.get("grant_type")).toBe("urn:ietf:params:oauth:grant-type:jwt-bearer"); + expect(body.get("assertion")).toBeTruthy(); // Should have generated assertion + expect(body.get("scope")).toBe("read write"); + }); + + it("should fall back to authorization code flow when JWT credentials are not available", async () => { + // Mock no JWT credentials + (mockProvider.jwtCredentials as jest.Mock).mockResolvedValue(undefined); + + // Mock client information + (mockProvider.clientInformation as jest.Mock).mockResolvedValue({ + client_id: "test-client", + client_secret: "test-secret", + }); + + // Mock no existing tokens + (mockProvider.tokens as jest.Mock).mockResolvedValue(undefined); + + // Mock authorization server metadata discovery + mockFetch.mockResolvedValueOnce({ + ok: true, + 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"], + grant_types_supported: ["authorization_code"], + token_endpoint_auth_methods_supported: ["client_secret_basic"], + }), + }); + + const result = await auth(mockProvider, { + serverUrl: "https://api.example.com/mcp-server", + scope: "read write", + }); + + expect(result).toBe("REDIRECT"); + expect(mockProvider.redirectToAuthorization).toHaveBeenCalled(); + expect(mockProvider.saveCodeVerifier).toHaveBeenCalled(); + }); + + it("should throw error when both jwtBearerAssertion and authorizationCode are provided", async () => { + const jwtAssertion = 'test-jwt-assertion'; + + await expect( + auth(mockProvider, { + serverUrl: "https://api.example.com/mcp-server", + jwtBearerAssertion: jwtAssertion, + authorizationCode: "code123", // This should cause an error + }) + ).rejects.toThrow("Cannot use both authorizationCode and jwtBearerAssertion"); + }); + }); + describe("exchangeAuthorization with multiple client authentication methods", () => { const validTokens = { access_token: "access123", @@ -2401,6 +2614,108 @@ describe("OAuth Authorization", () => { }); }); + describe("exchangeAuthorization with JWT Bearer Grant", () => { + const validTokens = { + access_token: "access123", + token_type: "Bearer", + expires_in: 3600, + }; + + const validClientInfo = { + client_id: "client123", + client_secret: "secret123", + redirect_uris: ["http://localhost:3000/callback"], + client_name: "Test Client", + }; + + const validMetadata = { + issuer: "https://auth.example.com", + authorization_endpoint: "https://auth.example.com/authorize", + token_endpoint: "https://auth.example.com/token", + response_types_supported: ["code"], + token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post"], + }; + + beforeEach(() => { + mockFetch.mockResolvedValue({ + ok: true, + json: async () => validTokens, + }); + }); + + it("exchanges JWT bearer assertion for tokens", async () => { + const jwtAssertion = 'test-jwt-assertion'; + + const tokens = await exchangeAuthorization("https://auth.example.com", { + metadata: validMetadata, + clientInformation: validClientInfo, + jwtBearerAssertion: jwtAssertion, + scope: "read write", + resource: new URL("https://api.example.com"), + }); + + expect(tokens).toEqual(validTokens); + + const call = mockFetch.mock.calls[0]; + const body = call[1].body as URLSearchParams; + + expect(body.get("grant_type")).toBe("urn:ietf:params:oauth:grant-type:jwt-bearer"); + expect(body.get("assertion")).toBe(jwtAssertion); + expect(body.get("scope")).toBe("read write"); + expect(body.get("resource")).toBe("https://api.example.com/"); + + // Should not have authorization code parameters + expect(body.get("code")).toBeNull(); + expect(body.get("code_verifier")).toBeNull(); + expect(body.get("redirect_uri")).toBeNull(); + }); + + it("exchanges JWT bearer assertion without optional parameters", async () => { + const jwtAssertion = 'test-jwt-assertion'; + + const tokens = await exchangeAuthorization("https://auth.example.com", { + metadata: validMetadata, + clientInformation: validClientInfo, + jwtBearerAssertion: jwtAssertion, + }); + + expect(tokens).toEqual(validTokens); + + const call = mockFetch.mock.calls[0]; + const body = call[1].body as URLSearchParams; + + expect(body.get("grant_type")).toBe("urn:ietf:params:oauth:grant-type:jwt-bearer"); + expect(body.get("assertion")).toBe(jwtAssertion); + expect(body.get("scope")).toBeNull(); + expect(body.get("resource")).toBeNull(); + }); + + it("throws error when mixing JWT bearer assertion with authorization code parameters", async () => { + const jwtAssertion = 'test-jwt-assertion'; + + await expect( + exchangeAuthorization("https://auth.example.com", { + metadata: validMetadata, + clientInformation: validClientInfo, + jwtBearerAssertion: jwtAssertion, + authorizationCode: "code123", // This should cause an error + codeVerifier: "verifier123", + redirectUri: "https://client.example.com/callback", + }) + ).rejects.toThrow("JWT Bearer Grant cannot be used with authorization code parameters"); + }); + + it("throws error when neither JWT bearer assertion nor authorization code is provided", async () => { + await expect( + exchangeAuthorization("https://auth.example.com", { + metadata: validMetadata, + clientInformation: validClientInfo, + // Missing both jwtBearerAssertion and authorizationCode + }) + ).rejects.toThrow("Missing required components to exchange for token"); + }); + }); + describe("refreshAuthorization with multiple client authentication methods", () => { const validTokens = { access_token: "newaccess123", diff --git a/src/client/auth.ts b/src/client/auth.ts index ab8aff0c7..a5b4ab6f6 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -13,6 +13,7 @@ import { } from "../shared/auth.js"; import { OAuthClientInformationFullSchema, OAuthMetadataSchema, OAuthProtectedResourceMetadataSchema, OAuthTokensSchema } from "../shared/auth.js"; import { checkResourceAllowed, resourceUrlFromServerUrl } from "../shared/auth-utils.js"; +import { JWTAssertionGenerator } from "../shared/jwt-utils.js"; import { InvalidClientError, InvalidGrantError, @@ -22,6 +23,12 @@ import { UnauthorizedClientError } from "../server/auth/errors.js"; import { FetchLike } from "../shared/transport.js"; +import { JWTClientCredentials, JWTClientCredentialsSchema } from "../shared/auth.js"; +import { + InvalidJWTError, + UnsupportedJWTAlgorithmError, + ExpiredJWTError, +} from "../server/auth/errors.js"; /** * Implements an end-to-end OAuth client to be used with one MCP server. @@ -127,6 +134,14 @@ export interface OAuthClientProvider { * This avoids requiring the user to intervene manually. */ invalidateCredentials?(scope: 'all' | 'client' | 'tokens' | 'verifier'): void | Promise; + + /** + * Provides JWT credentials for client authentication. + * When provided, enables JWT-based client authentication methods. + * + * @returns JWT credentials configuration or undefined if JWT authentication is not supported + */ + jwtCredentials?(): JWTClientCredentials | undefined | Promise; } export type AuthResult = "AUTHORIZED" | "REDIRECT"; @@ -137,25 +152,36 @@ export class UnauthorizedError extends Error { } } -type ClientAuthMethod = 'client_secret_basic' | 'client_secret_post' | 'none'; +type ClientAuthMethod = + | 'client_secret_basic' + | 'client_secret_post' + | 'client_secret_jwt' + | 'private_key_jwt' + | 'none'; /** * Determines the best client authentication method to use based on server support and client configuration. * * Priority order (highest to lowest): - * 1. client_secret_basic (if client secret is available) - * 2. client_secret_post (if client secret is available) - * 3. none (for public clients) + * 1. private_key_jwt (if private key is available) + * 2. client_secret_jwt (if client secret is available) + * 3. client_secret_basic (if client secret is available) + * 4. client_secret_post (if client secret is available) + * 5. none (for public clients) * * @param clientInformation - OAuth client information containing credentials * @param supportedMethods - Authentication methods supported by the authorization server + * @param jwtCredentials - Optional JWT credentials for JWT-based authentication * @returns The selected authentication method */ function selectClientAuthMethod( clientInformation: OAuthClientInformation, - supportedMethods: string[] + supportedMethods: string[], + jwtCredentials?: JWTClientCredentials ): ClientAuthMethod { const hasClientSecret = clientInformation.client_secret !== undefined; + const hasJWTPrivateKey = jwtCredentials?.privateKey !== undefined; + const hasJWTClientSecret = jwtCredentials?.clientSecret !== undefined; // If server doesn't specify supported methods, use RFC 6749 defaults if (supportedMethods.length === 0) { @@ -163,6 +189,14 @@ function selectClientAuthMethod( } // Try methods in priority order (most secure first) + if (hasJWTPrivateKey && supportedMethods.includes("private_key_jwt")) { + return "private_key_jwt"; + } + + if ((hasJWTClientSecret || hasClientSecret) && supportedMethods.includes("client_secret_jwt")) { + return "client_secret_jwt"; + } + if (hasClientSecret && supportedMethods.includes("client_secret_basic")) { return "client_secret_basic"; } @@ -185,20 +219,28 @@ function selectClientAuthMethod( * Implements OAuth 2.1 client authentication methods: * - client_secret_basic: HTTP Basic authentication (RFC 6749 Section 2.3.1) * - client_secret_post: Credentials in request body (RFC 6749 Section 2.3.1) + * - client_secret_jwt: JWT assertion with HMAC signature (RFC 7523) + * - private_key_jwt: JWT assertion with RSA/ECDSA signature (RFC 7523) * - none: Public client authentication (RFC 6749 Section 2.1) * * @param method - The authentication method to use * @param clientInformation - OAuth client information containing credentials * @param headers - HTTP headers object to modify * @param params - URL search parameters to modify + * @param tokenEndpoint - The token endpoint URL for JWT audience + * @param jwtCredentials - JWT credentials for JWT-based authentication + * @param resource - Optional MCP resource URL for resource binding * @throws {Error} When required credentials are missing */ -function applyClientAuthentication( +export async function applyClientAuthentication( method: ClientAuthMethod, clientInformation: OAuthClientInformation, headers: Headers, - params: URLSearchParams -): void { + params: URLSearchParams, + tokenEndpoint?: string, + jwtCredentials?: JWTClientCredentials, + resource?: URL +): Promise { const { client_id, client_secret } = clientInformation; switch (method) { @@ -208,6 +250,12 @@ function applyClientAuthentication( case "client_secret_post": applyPostAuth(client_id, client_secret, params); return; + case "client_secret_jwt": + await applyJWTAuth(client_id, tokenEndpoint, jwtCredentials, params, 'client_secret', resource); + return; + case "private_key_jwt": + await applyJWTAuth(client_id, tokenEndpoint, jwtCredentials, params, 'private_key', resource); + return; case "none": applyPublicAuth(client_id, params); return; @@ -245,6 +293,64 @@ function applyPublicAuth(clientId: string, params: URLSearchParams): void { params.set("client_id", clientId); } +/** + * Applies JWT client assertion authentication (RFC 7523) + */ +async function applyJWTAuth( + clientId: string, + tokenEndpoint: string | undefined, + jwtCredentials: JWTClientCredentials | undefined, + params: URLSearchParams, + keyType: 'client_secret' | 'private_key', + resource?: URL +): Promise { + if (!tokenEndpoint) { + throw new Error("JWT client authentication requires a token endpoint URL"); + } + + if (!jwtCredentials) { + throw new Error("JWT client authentication requires JWT credentials"); + } + + // Validate that we have the right type of credentials + if (keyType === 'client_secret' && !jwtCredentials.clientSecret) { + throw new Error("client_secret_jwt authentication requires a client secret in JWT credentials"); + } + + if (keyType === 'private_key' && !jwtCredentials.privateKey) { + throw new Error("private_key_jwt authentication requires a private key in JWT credentials"); + } + + // Validate JWT credentials using schema + try { + JWTClientCredentialsSchema.parse(jwtCredentials); + } catch (error) { + throw new Error(`Invalid JWT credentials: ${error instanceof Error ? error.message : 'Unknown validation error'}`); + } + + try { + const assertion = await JWTAssertionGenerator.generateClientAssertion( + clientId, + tokenEndpoint, + jwtCredentials, + resource + ); + + params.set("client_assertion_type", "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"); + params.set("client_assertion", assertion); + } catch (error) { + // Re-throw JWT-specific errors as-is + if (error instanceof InvalidJWTError || + error instanceof UnsupportedJWTAlgorithmError || + error instanceof ExpiredJWTError) { + throw error; + } + + // Wrap other errors with context + throw new Error(`Failed to generate JWT client assertion: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +} + /** * Parses an OAuth error response from a string or Response object. * @@ -264,7 +370,12 @@ export async function parseErrorResponse(input: Response | string): Promise OAuthError)( + error_description || '', + error_uri + ); + return errorInstance; } catch (error) { // Not a valid OAuth error response, but try to inform the user of the raw data anyway const errorMessage = `${statusCode ? `HTTP ${statusCode}: ` : ''}Invalid OAuth error response: ${error}. Raw body: ${body}`; @@ -275,6 +386,10 @@ export async function parseErrorResponse(input: Response | string): Promise { + }): Promise { try { return await authInternal(provider, options); } catch (error) { @@ -308,18 +424,24 @@ async function authInternal( provider: OAuthClientProvider, { serverUrl, authorizationCode, + jwtBearerAssertion, scope, resourceMetadataUrl, fetchFn, }: { serverUrl: string | URL; authorizationCode?: string; + jwtBearerAssertion?: string; scope?: string; resourceMetadataUrl?: URL; fetchFn?: FetchLike; }, ): Promise { + if (authorizationCode && jwtBearerAssertion) { + throw new Error("Cannot use both authorizationCode and jwtBearerAssertion"); + } + let resourceMetadata: OAuthProtectedResourceMetadata | undefined; let authorizationServerUrl: string | URL | undefined; try { @@ -348,8 +470,8 @@ async function authInternal( // Handle client registration if needed let clientInformation = await Promise.resolve(provider.clientInformation()); if (!clientInformation) { - if (authorizationCode !== undefined) { - throw new Error("Existing OAuth client information is required when exchanging an authorization code"); + if (authorizationCode !== undefined || jwtBearerAssertion !== undefined) { + throw new Error("Existing OAuth client information is required when exchanging credentials"); } if (!provider.saveClientInformation) { @@ -365,7 +487,36 @@ async function authInternal( clientInformation = fullInformation; } - // Exchange authorization code for tokens + if (jwtBearerAssertion !== undefined || provider.jwtCredentials) { + const jwtCredentials = provider.jwtCredentials ? await provider.jwtCredentials() : undefined; + + // Use provided assertion or generate one from credentials + const assertion = jwtBearerAssertion || + (jwtCredentials ? await JWTAssertionGenerator.generateBearerAssertion( + clientInformation.client_id, + clientInformation.client_id, + authorizationServerUrl.toString(), + jwtCredentials, + resource + ) : undefined); + + if (assertion) { + const tokens = await exchangeAuthorization(authorizationServerUrl, { + metadata, + clientInformation, + jwtBearerAssertion: assertion, + scope, + resource, + addClientAuthentication: provider.addClientAuthentication, + jwtCredentials, + fetchFn: fetchFn, + }); + + await provider.saveTokens(tokens); + return "AUTHORIZED"; + } + } + if (authorizationCode !== undefined) { const codeVerifier = await provider.codeVerifier(); const tokens = await exchangeAuthorization(authorizationServerUrl, { @@ -380,7 +531,7 @@ async function authInternal( }); await provider.saveTokens(tokens); - return "AUTHORIZED" + return "AUTHORIZED"; } const tokens = await provider.tokens(); @@ -389,16 +540,18 @@ async function authInternal( if (tokens?.refresh_token) { try { // Attempt to refresh the token + const jwtCredentials = provider.jwtCredentials ? await provider.jwtCredentials() : undefined; const newTokens = await refreshAuthorization(authorizationServerUrl, { metadata, clientInformation, refreshToken: tokens.refresh_token, resource, addClientAuthentication: provider.addClientAuthentication, + jwtCredentials, }); await provider.saveTokens(newTokens); - return "AUTHORIZED" + return "AUTHORIZED"; } catch (error) { // If this is a ServerError, or an unknown type, log it out and try to continue. Otherwise, escalate so we can fix things and retry. if (!(error instanceof OAuthError) || error instanceof ServerError) { @@ -424,7 +577,7 @@ async function authInternal( await provider.saveCodeVerifier(codeVerifier); await provider.redirectToAuthorization(authorizationUrl); - return "REDIRECT" + return "REDIRECT"; } export async function selectResourceURL(serverUrl: string | URL, provider: OAuthClientProvider, resourceMetadata?: OAuthProtectedResourceMetadata): Promise { @@ -633,7 +786,7 @@ export async function discoverOAuthMetadata( if (typeof authorizationServerUrl === 'string') { authorizationServerUrl = new URL(authorizationServerUrl); } - protocolVersion ??= LATEST_PROTOCOL_VERSION ; + protocolVersion ??= LATEST_PROTOCOL_VERSION; const response = await discoverMetadataWithFallback( authorizationServerUrl, @@ -876,14 +1029,18 @@ export async function startAuthorization( } /** - * Exchanges an authorization code for an access token with the given server. + * Exchanges authorization credentials for an access token with the given server. + * + * This function supports two OAuth grant types: + * 1. Authorization Code Grant (RFC 6749 Section 4.1) - when authorizationCode is provided + * 2. JWT Bearer Grant (RFC 7523 Section 2.1) - when jwtBearerAssertion is provided * * Supports multiple client authentication methods as specified in OAuth 2.1: * - Automatically selects the best authentication method based on server support * - Falls back to appropriate defaults when server metadata is unavailable * * @param authorizationServerUrl - The authorization server's base URL - * @param options - Configuration object containing client info, auth code, etc. + * @param options - Configuration object containing grant-specific parameters * @returns Promise resolving to OAuth tokens * @throws {Error} When token exchange fails or authentication is invalid */ @@ -897,30 +1054,46 @@ export async function exchangeAuthorization( redirectUri, resource, addClientAuthentication, + jwtCredentials, + jwtBearerAssertion, + scope, fetchFn, }: { metadata?: AuthorizationServerMetadata; clientInformation: OAuthClientInformation; - authorizationCode: string; - codeVerifier: string; - redirectUri: string | URL; + authorizationCode?: string; + codeVerifier?: string; + redirectUri?: string | URL; resource?: URL; addClientAuthentication?: OAuthClientProvider["addClientAuthentication"]; + jwtCredentials?: JWTClientCredentials; + jwtBearerAssertion?: string; + scope?: string; fetchFn?: FetchLike; }, ): Promise { - const grantType = "authorization_code"; + if (jwtBearerAssertion && (authorizationCode || codeVerifier || redirectUri)) { + throw new Error("JWT Bearer Grant cannot be used with authorization code parameters"); + } + // Get grant type based on provided options + const grantType = + // RFC 7523 Section 2.1: JWT Bearer Grant + jwtBearerAssertion ? "urn:ietf:params:oauth:grant-type:jwt-bearer" : + // RFC 6749 Section 4.1: Authorization Code Grant + authorizationCode && codeVerifier && redirectUri ? "authorization_code" : + // No set of required options were fulfilled + (() => { throw new Error("Missing required components to exchange for token"); })(); const tokenUrl = metadata?.token_endpoint - ? new URL(metadata.token_endpoint) - : new URL("/token", authorizationServerUrl); + ? new URL(metadata.token_endpoint) + : new URL("/token", authorizationServerUrl); if ( - metadata?.grant_types_supported && - !metadata.grant_types_supported.includes(grantType) + metadata?.grant_types_supported && + !metadata.grant_types_supported.includes(grantType) ) { throw new Error( - `Incompatible auth server: does not support grant type ${grantType}`, + `Incompatible auth server: does not support grant type ${grantType}`, ); } @@ -931,23 +1104,39 @@ export async function exchangeAuthorization( }); const params = new URLSearchParams({ grant_type: grantType, - code: authorizationCode, - code_verifier: codeVerifier, - redirect_uri: String(redirectUri), + ...(resource && { resource: resource.href }) }); - if (addClientAuthentication) { - addClientAuthentication(headers, params, authorizationServerUrl, metadata); + // Add grant-specific parameters + if (jwtBearerAssertion) { + // RFC 7523 Section 2.1: JWT Bearer Grant + params.set("assertion", jwtBearerAssertion); + if (scope) { + params.set("scope", scope); + } } else { + // RFC 6749 Section 4.1: Authorization Code Grant + params.set("code", authorizationCode!); + params.set("code_verifier", codeVerifier!); + params.set("redirect_uri", String(redirectUri!)); + } + + if (addClientAuthentication) { + await addClientAuthentication(headers, params, authorizationServerUrl, metadata); + } else if (!jwtBearerAssertion) { // Determine and apply client authentication method const supportedMethods = metadata?.token_endpoint_auth_methods_supported ?? []; - const authMethod = selectClientAuthMethod(clientInformation, supportedMethods); + const authMethod = selectClientAuthMethod(clientInformation, supportedMethods, jwtCredentials); - applyClientAuthentication(authMethod, clientInformation, headers, params); - } - - if (resource) { - params.set("resource", resource.href); + await applyClientAuthentication( + authMethod, + clientInformation, + headers, + params, + tokenUrl.href, + jwtCredentials, + resource + ); } const response = await (fetchFn ?? fetch)(tokenUrl, { @@ -983,6 +1172,7 @@ export async function refreshAuthorization( refreshToken, resource, addClientAuthentication, + jwtCredentials, fetchFn, }: { metadata?: AuthorizationServerMetadata; @@ -990,6 +1180,7 @@ export async function refreshAuthorization( refreshToken: string; resource?: URL; addClientAuthentication?: OAuthClientProvider["addClientAuthentication"]; + jwtCredentials?: JWTClientCredentials; fetchFn?: FetchLike; } ): Promise { @@ -1021,13 +1212,21 @@ export async function refreshAuthorization( }); if (addClientAuthentication) { - addClientAuthentication(headers, params, authorizationServerUrl, metadata); + await addClientAuthentication(headers, params, authorizationServerUrl, metadata); } else { // Determine and apply client authentication method const supportedMethods = metadata?.token_endpoint_auth_methods_supported ?? []; - const authMethod = selectClientAuthMethod(clientInformation, supportedMethods); + const selectedMethod = selectClientAuthMethod(clientInformation, supportedMethods, jwtCredentials); - applyClientAuthentication(authMethod, clientInformation, headers, params); + await applyClientAuthentication( + selectedMethod, + clientInformation, + headers, + params, + tokenUrl.href, + jwtCredentials, + resource + ); } if (resource) { diff --git a/src/client/streamableHttp.test.ts b/src/client/streamableHttp.test.ts index 88fd48017..5d846c683 100644 --- a/src/client/streamableHttp.test.ts +++ b/src/client/streamableHttp.test.ts @@ -920,6 +920,9 @@ describe("StreamableHTTPClientTransport", () => { }); it("uses custom fetch in finishAuth method - no global fetch fallback", async () => { + // Setup mock auth provider to return required values + mockAuthProvider.codeVerifier = jest.fn().mockResolvedValue("test-code-verifier"); + // Create custom fetch const customFetch = jest.fn() // Protected resource metadata discovery diff --git a/src/examples/client/jwtOAuthClient.ts b/src/examples/client/jwtOAuthClient.ts new file mode 100644 index 000000000..9411dcf3a --- /dev/null +++ b/src/examples/client/jwtOAuthClient.ts @@ -0,0 +1,539 @@ +import { createServer } from 'node:http'; +import { createInterface } from 'node:readline'; +import { URL } from 'node:url'; +import { exec } from 'node:child_process'; +import { Client } from '../../client/index.js'; +import { StreamableHTTPClientTransport } from '../../client/streamableHttp.js'; +import { + OAuthClientInformation, + OAuthClientInformationFull, + OAuthClientMetadata, + OAuthTokens, + JWTClientCredentials +} from '../../shared/auth.js'; +import { + CallToolRequest, + ListToolsRequest, + CallToolResultSchema, + ListToolsResultSchema +} from '../../types.js'; +import { OAuthClientProvider, UnauthorizedError } from '../../client/auth.js'; + +// Configuration +const DEFAULT_SERVER_URL = 'http://localhost:3000/mcp'; +const CALLBACK_PORT = 8090; +const CALLBACK_URL = `http://localhost:${CALLBACK_PORT}/callback`; + +/** + * JWT-based OAuth client provider that fully utilizes auth.ts infrastructure + * No custom authentication logic - everything is handled by the framework + */ +class JWTOAuthClientProvider implements OAuthClientProvider { + private _clientInformation?: OAuthClientInformationFull; + private _tokens?: OAuthTokens; + private _codeVerifier?: string; + + constructor( + private readonly _redirectUrl: string | URL, + private readonly _clientMetadata: OAuthClientMetadata, + private readonly _jwtCredentials?: JWTClientCredentials, + private readonly _jwtBearerAssertion?: string, + onRedirect?: (url: URL) => void + ) { + this._onRedirect = onRedirect || ((url) => { + console.log(`Redirect to: ${url.toString()}`); + }); + } + + private _onRedirect: (url: URL) => void; + + get redirectUrl(): string | URL { + return this._redirectUrl; + } + + get clientMetadata(): OAuthClientMetadata { + return this._clientMetadata; + } + + clientInformation(): OAuthClientInformation | undefined { + return this._clientInformation; + } + + saveClientInformation(clientInformation: OAuthClientInformationFull): void { + this._clientInformation = clientInformation; + console.log('šŸ’¾ Saved client information:', { + client_id: clientInformation.client_id, + client_secret: clientInformation.client_secret ? '[REDACTED]' : undefined, + token_endpoint_auth_method: clientInformation.token_endpoint_auth_method + }); + } + + tokens(): OAuthTokens | undefined { + return this._tokens; + } + + saveTokens(tokens: OAuthTokens): void { + this._tokens = tokens; + console.log('šŸŽ« Saved tokens:', { + access_token: tokens.access_token ? `${tokens.access_token.substring(0, 20)}...` : undefined, + token_type: tokens.token_type, + expires_in: tokens.expires_in, + scope: tokens.scope + }); + } + + redirectToAuthorization(authorizationUrl: URL): void { + this._onRedirect(authorizationUrl); + } + + saveCodeVerifier(codeVerifier: string): void { + this._codeVerifier = codeVerifier; + } + + codeVerifier(): string { + if (!this._codeVerifier) { + throw new Error('No code verifier saved'); + } + return this._codeVerifier; + } + + /** + * Provide JWT credentials - auth.ts will use these automatically for: + * 1. JWT bearer assertion generation + * 2. Client authentication method selection + * 3. JWT client assertion generation + */ + jwtCredentials(): JWTClientCredentials | undefined { + return this._jwtCredentials; + } + + /** + * Get pre-provided JWT bearer assertion - auth.ts will use this automatically + */ + getJwtBearerAssertion(): string | undefined { + return this._jwtBearerAssertion; + } + + /** + * Invalidate credentials on auth failures + */ + async invalidateCredentials(scope: 'all' | 'client' | 'tokens' | 'verifier'): Promise { + console.log(`šŸ—‘ļø Invalidating credentials: ${scope}`); + + switch (scope) { + case 'all': + this._clientInformation = undefined; + this._tokens = undefined; + this._codeVerifier = undefined; + break; + case 'client': + this._clientInformation = undefined; + break; + case 'tokens': + this._tokens = undefined; + break; + case 'verifier': + this._codeVerifier = undefined; + break; + } + } +} + +/** + * Interactive MCP client with JWT-based OAuth authentication + * Fully utilizes auth.ts infrastructure - no custom auth logic + */ +class InteractiveJWTOAuthClient { + private client: Client | null = null; + private readonly rl = createInterface({ + input: process.stdin, + output: process.stdout, + }); + + constructor(private serverUrl: string) { } + + private async question(query: string): Promise { + return new Promise((resolve) => { + this.rl.question(query, resolve); + }); + } + + private async openBrowser(url: string): Promise { + console.log(`🌐 Opening browser for authorization: ${url}`); + + const command = process.platform === 'darwin' ? `open "${url}"` : + process.platform === 'win32' ? `start "${url}"` : + `xdg-open "${url}"`; + + exec(command, (error) => { + if (error) { + console.error(`Failed to open browser: ${error.message}`); + console.log(`Please manually open: ${url}`); + } + }); + } + + private async waitForOAuthCallback(): Promise { + return new Promise((resolve, reject) => { + const server = createServer((req, res) => { + if (req.url === '/favicon.ico') { + res.writeHead(404); + res.end(); + return; + } + + console.log(`šŸ“„ Received callback: ${req.url}`); + const parsedUrl = new URL(req.url || '', 'http://localhost'); + const code = parsedUrl.searchParams.get('code'); + const error = parsedUrl.searchParams.get('error'); + + if (code) { + console.log(`āœ… Authorization code received: ${code?.substring(0, 10)}...`); + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(` + + +

Authorization Successful!

+

JWT OAuth client authenticated successfully.

+

You can close this window and return to the terminal.

+ + + + `); + + resolve(code); + setTimeout(() => server.close(), 3000); + } else if (error) { + console.log(`āŒ Authorization error: ${error}`); + res.writeHead(400, { 'Content-Type': 'text/html' }); + res.end(` + + +

Authorization Failed

+

Error: ${error}

+ + + `); + reject(new Error(`OAuth authorization failed: ${error}`)); + } else { + console.log(`āŒ No authorization code or error in callback`); + res.writeHead(400); + res.end('Bad request'); + reject(new Error('No authorization code provided')); + } + }); + + server.listen(CALLBACK_PORT, () => { + console.log(`OAuth callback server started on http://localhost:${CALLBACK_PORT}`); + }); + }); + } + + private createJWTCredentials(): JWTClientCredentials | undefined { + const clientSecret = process.env.JWT_CLIENT_SECRET; + const privateKey = process.env.JWT_PRIVATE_KEY; + const algorithm = process.env.JWT_ALGORITHM as JWTClientCredentials['algorithm']; + const keyId = process.env.JWT_KEY_ID; + const tokenLifetime = process.env.JWT_TOKEN_LIFETIME ? parseInt(process.env.JWT_TOKEN_LIFETIME) : undefined; + + if (clientSecret || privateKey) { + return { + clientSecret, + privateKey, + algorithm: algorithm || (clientSecret ? 'HS256' : 'RS256'), + keyId, + tokenLifetime: tokenLifetime || 300 + }; + } + + return undefined; + } + + private async getJWTBearerAssertion(): Promise { + const bearerAssertion = process.env.JWT_BEARER_ASSERTION; + if (bearerAssertion) { + console.log('šŸŽ« Using provided JWT bearer assertion'); + return bearerAssertion; + } + + return undefined; + } + + /** + * Simplified connection method that fully trusts auth.ts to handle everything + */ + private async attemptConnection(oauthProvider: JWTOAuthClientProvider): Promise { + console.log('🚢 Creating transport with JWT OAuth provider...'); + const baseUrl = new URL(this.serverUrl); + const transport = new StreamableHTTPClientTransport(baseUrl, { + authProvider: oauthProvider + }); + console.log('🚢 Transport created'); + + try { + console.log('šŸ”Œ Attempting connection...'); + + // Let auth.ts handle EVERYTHING: + // - JWT bearer assertion generation from jwtCredentials() + // - Pre-provided JWT bearer assertion handling + // - Client authentication method selection + // - JWT client assertion generation + // - Authorization code flow fallback + // - Token refresh + // - Error handling and retries + await this.client!.connect(transport); + console.log('āœ… Connected successfully with JWT authentication'); + } catch (error) { + if (error instanceof UnauthorizedError) { + // Only handle the interactive part - let auth.ts handle the JWT logic + console.log('šŸ” Starting authorization code flow...'); + const callbackPromise = this.waitForOAuthCallback(); + const authCode = await callbackPromise; + await transport.finishAuth(authCode); + console.log('šŸ” Authorization completed - reconnecting...'); + await this.attemptConnection(oauthProvider); + } else { + console.error('āŒ Connection failed:', error); + throw error; + } + } + } + + async connect(): Promise { + console.log(`šŸ”— Attempting to connect to ${this.serverUrl}...`); + console.log('šŸ”‘ JWT OAuth Client - fully integrated with auth.ts'); + + const jwtCredentials = this.createJWTCredentials(); + if (jwtCredentials) { + console.log('āœ… JWT credentials loaded from environment'); + console.log(' Algorithm:', jwtCredentials.algorithm); + console.log(' Key type:', jwtCredentials.clientSecret ? 'HMAC secret' : 'Private key'); + } else { + console.log('āš ļø No JWT credentials found in environment variables'); + } + + const jwtBearerAssertion = await this.getJWTBearerAssertion(); + + const clientMetadata: OAuthClientMetadata = { + client_name: 'JWT OAuth MCP Client', + redirect_uris: [CALLBACK_URL], + grant_types: (jwtCredentials || jwtBearerAssertion) ? + ['urn:ietf:params:oauth:grant-type:jwt-bearer', 'authorization_code', 'refresh_token'] : + ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: jwtCredentials ? + (jwtCredentials.clientSecret ? 'client_secret_jwt' : 'private_key_jwt') : + 'client_secret_post', + scope: 'mcp:tools' + }; + + console.log('šŸ” Creating JWT OAuth provider...'); + const oauthProvider = new JWTOAuthClientProvider( + CALLBACK_URL, + clientMetadata, + jwtCredentials, + jwtBearerAssertion, + (redirectUrl: URL) => { + console.log(`šŸ“Œ OAuth redirect - opening browser`); + this.openBrowser(redirectUrl.toString()); + } + ); + + console.log('šŸ‘¤ Creating MCP client...'); + this.client = new Client({ + name: 'jwt-oauth-client', + version: '1.0.0', + }, { capabilities: {} }); + + console.log('šŸ” Starting JWT OAuth flow...'); + await this.attemptConnection(oauthProvider); + await this.interactiveLoop(); + } + + async interactiveLoop(): Promise { + console.log('\nšŸŽÆ Interactive MCP Client with JWT OAuth'); + console.log('Commands: list, call [args], quit'); + console.log(); + + while (true) { + try { + const command = await this.question('mcp-jwt> '); + + if (!command.trim()) continue; + + if (command === 'quit') { + console.log('\nšŸ‘‹ Goodbye!'); + this.close(); + process.exit(0); + } else if (command === 'list') { + await this.listTools(); + } else if (command.startsWith('call ')) { + await this.handleCallTool(command); + } else { + console.log('āŒ Unknown command. Try \'list\', \'call \', or \'quit\''); + } + } catch (error) { + if (error instanceof Error && error.message === 'SIGINT') { + console.log('\n\nšŸ‘‹ Goodbye!'); + break; + } + console.error('āŒ Error:', error); + } + } + } + + private async listTools(): Promise { + if (!this.client) { + console.log('āŒ Not connected to server'); + return; + } + + try { + const request: ListToolsRequest = { + method: 'tools/list', + params: {}, + }; + + const result = await this.client.request(request, ListToolsResultSchema); + + if (result.tools && result.tools.length > 0) { + console.log('\nšŸ“‹ Available tools:'); + result.tools.forEach((tool, index) => { + console.log(`${index + 1}. ${tool.name}`); + if (tool.description) { + console.log(` Description: ${tool.description}`); + } + console.log(); + }); + } else { + console.log('No tools available'); + } + } catch (error) { + console.error('āŒ Failed to list tools:', error); + } + } + + private async handleCallTool(command: string): Promise { + const parts = command.split(/\s+/); + const toolName = parts[1]; + + if (!toolName) { + console.log('āŒ Please specify a tool name'); + return; + } + + let toolArgs: Record = {}; + if (parts.length > 2) { + const argsString = parts.slice(2).join(' '); + try { + toolArgs = JSON.parse(argsString); + } catch { + console.log('āŒ Invalid arguments format (expected JSON)'); + return; + } + } + + await this.callTool(toolName, toolArgs); + } + + private async callTool(toolName: string, toolArgs: Record): Promise { + if (!this.client) { + console.log('āŒ Not connected to server'); + return; + } + + try { + const request: CallToolRequest = { + method: 'tools/call', + params: { + name: toolName, + arguments: toolArgs, + }, + }; + + const result = await this.client.request(request, CallToolResultSchema); + + console.log(`\nšŸ”§ Tool '${toolName}' result:`); + if (result.content) { + result.content.forEach((content) => { + if (content.type === 'text') { + console.log(content.text); + } else { + console.log(content); + } + }); + } else { + console.log(result); + } + } catch (error) { + console.error(`āŒ Failed to call tool '${toolName}':`, error); + } + } + + close(): void { + this.rl.close(); + } +} + +function printUsage(): void { + console.log('šŸ”‘ JWT OAuth Client Configuration'); + console.log('================================'); + console.log('Environment Variables:'); + console.log(' JWT_CLIENT_SECRET - HMAC secret for client authentication'); + console.log(' JWT_PRIVATE_KEY - RSA/ECDSA private key for client authentication'); + console.log(' JWT_ALGORITHM - JWT signing algorithm (default: HS256/RS256)'); + console.log(' JWT_KEY_ID - Key ID for JWT header (optional)'); + console.log(' JWT_TOKEN_LIFETIME - Token lifetime in seconds (default: 300)'); + console.log(' JWT_BEARER_ASSERTION - Pre-generated JWT bearer assertion'); + console.log(' MCP_SERVER_URL - MCP server URL (default: http://localhost:3000/mcp)'); + console.log(); + console.log('Examples:'); + console.log(' export JWT_CLIENT_SECRET="your-secret-key"'); + console.log(' export JWT_BEARER_ASSERTION="eyJ0eXAiOiJKV1Q..."'); + console.log(); +} + +async function main(): Promise { + if (process.argv.includes('--help') || process.argv.includes('-h')) { + printUsage(); + process.exit(0); + } + + const serverUrl = process.env.MCP_SERVER_URL || DEFAULT_SERVER_URL; + + console.log('šŸš€ JWT OAuth MCP Client (Simplified)'); + console.log(`Connecting to: ${serverUrl}`); + console.log(); + + const client = new InteractiveJWTOAuthClient(serverUrl); + + process.on('SIGINT', () => { + console.log('\n\nšŸ‘‹ Goodbye!'); + client.close(); + process.exit(0); + }); + + try { + await client.connect(); + } catch (error) { + console.error('Failed to start JWT OAuth client:', error); + console.log('\nFor configuration help, run: node jwtOAuthClient.js --help'); + process.exit(1); + } finally { + client.close(); + } +} + +const isMainModule = process.argv[1] && ( + process.argv[1].endsWith('jwtOAuthClient_simplified.ts') || + process.argv[1].endsWith('jwtOAuthClient_simplified.js') +); + +if (isMainModule) { + main().catch((error) => { + console.error('Unhandled error:', error); + process.exit(1); + }); +} + +export { InteractiveJWTOAuthClient, JWTOAuthClientProvider }; \ No newline at end of file diff --git a/src/server/auth/errors.ts b/src/server/auth/errors.ts index 791b3b86c..b7e519dfc 100644 --- a/src/server/auth/errors.ts +++ b/src/server/auth/errors.ts @@ -176,13 +176,47 @@ export class CustomOAuthError extends OAuthError { } } +/** + * Invalid JWT error - The JWT assertion is malformed, has invalid claims, or signature verification failed. + */ +export class InvalidJWTError extends InvalidClientError { + constructor(description?: string, uri?: string) { + super(description || 'Invalid JWT assertion', uri); + } +} + +/** + * Unsupported JWT algorithm error - The JWT uses an algorithm that is not supported by the server. + */ +export class UnsupportedJWTAlgorithmError extends InvalidClientError { + constructor(algorithm: string) { + super(`Unsupported JWT algorithm: ${algorithm}`); + } +} + +/** + * Expired JWT error - The JWT assertion has expired. + */ +export class ExpiredJWTError extends InvalidGrantError { + constructor() { + super('JWT assertion has expired'); + } +} + +/** + * Invalid JWT audience error - The JWT audience claim does not match the expected value. + */ +export class InvalidJWTAudienceError extends InvalidGrantError { + constructor(expected: string, actual: string) { + super(`Invalid JWT audience. Expected: ${expected}, Got: ${actual}`); + } +} + /** * A full list of all OAuthErrors, enabling parsing from error responses */ export const OAUTH_ERRORS = { [InvalidRequestError.errorCode]: InvalidRequestError, - [InvalidClientError.errorCode]: InvalidClientError, - [InvalidGrantError.errorCode]: InvalidGrantError, [UnauthorizedClientError.errorCode]: UnauthorizedClientError, [UnsupportedGrantTypeError.errorCode]: UnsupportedGrantTypeError, [InvalidScopeError.errorCode]: InvalidScopeError, @@ -196,4 +230,7 @@ export const OAUTH_ERRORS = { [TooManyRequestsError.errorCode]: TooManyRequestsError, [InvalidClientMetadataError.errorCode]: InvalidClientMetadataError, [InsufficientScopeError.errorCode]: InsufficientScopeError, + [InvalidClientError.errorCode]: InvalidClientError, + [InvalidGrantError.errorCode]: InvalidGrantError, + [CustomOAuthError.errorCode]: CustomOAuthError } as const; diff --git a/src/shared/auth.ts b/src/shared/auth.ts index 47eba9ac5..a227e0d0a 100644 --- a/src/shared/auth.ts +++ b/src/shared/auth.ts @@ -1,4 +1,8 @@ import { z } from "zod"; +import type { + JWTHeaderParameters, + JWTClaimVerificationOptions +} from 'jose'; /** * RFC 9728 OAuth Protected Resource Metadata @@ -24,6 +28,7 @@ export const OAuthProtectedResourceMetadataSchema = z /** * RFC 8414 OAuth 2.0 Authorization Server Metadata + * Extended to support JWT authentication methods */ export const OAuthMetadataSchema = z .object({ @@ -210,3 +215,156 @@ export type OAuthProtectedResourceMetadata = z.infer; +export type JWTBearerGrantPayload = z.infer; +export type JWTClientCredentials = z.infer; +export type JWTClientAssertionRequest = z.infer; +export type JWTBearerGrantRequest = z.infer; +export type JWTBearerGrant = z.infer; + +/** + * JWT Assertion Options for generation + */ +export interface JWTAssertionOptions { + issuer: string; + subject: string; + audience: string | string[]; + expiresIn?: number; + notBefore?: number; + jwtId?: string; + additionalClaims?: Record; +} + +/** + * JWT Signing Options + */ +export interface JWTSigningOptions { + algorithm: string; + secret?: string; + privateKey?: string | Buffer; + keyId?: string; +} + +/** + * JWT Validation Result + */ +export interface JWTValidationResult { + payload: JWTClientAssertionPayload | JWTBearerGrantPayload; + header: JWTHeader; + clientId: string; + issuedAt: number; + expiresAt: number; + audience: string[]; +} + +/** + * JWT Validation Options + * Uses jose's JWTClaimVerificationOptions with required audience for OAuth compliance + * - audience is required (jose's is optional) + * - maxAge maps to jose's maxTokenAge + * - clockTolerance is number-only (jose accepts string | number) + */ +export interface JWTValidationOptions { + audience: string | string[]; + issuer?: string; + clockTolerance?: number; // seconds, default 30 + maxAge?: number; // seconds, default 300 +} + +/** + * Convert our custom JWT validation options to jose's format + */ +export function toJoseValidationOptions(options: JWTValidationOptions): JWTClaimVerificationOptions { + return { + audience: options.audience, + issuer: options.issuer, + clockTolerance: options.clockTolerance, + maxTokenAge: options.maxAge, + }; +} diff --git a/src/shared/jwt-utils.test.ts b/src/shared/jwt-utils.test.ts new file mode 100644 index 000000000..c2c665d64 --- /dev/null +++ b/src/shared/jwt-utils.test.ts @@ -0,0 +1,379 @@ +import { JWTAssertionGenerator, JWTValidator, generateJwtId, isSupportedJWTAlgorithm, selectJWTAlgorithm } from './jwt-utils.js'; +import { + JWTClientCredentials, + JWTClientAssertionPayloadSchema, + JWTBearerGrantPayloadSchema, + JWTClientCredentialsSchema +} from './auth.js'; + +// Mock JOSE library for testing +jest.mock('jose', () => ({ + SignJWT: jest.fn().mockImplementation(() => ({ + setProtectedHeader: jest.fn().mockReturnThis(), + sign: jest.fn().mockResolvedValue('mock.jwt.token'), + })), + jwtVerify: jest.fn().mockResolvedValue({ + payload: { + iss: 'test-client', + sub: 'test-client', + aud: 'https://example.com/token', + exp: Math.floor(Date.now() / 1000) + 300, + iat: Math.floor(Date.now() / 1000), + jti: 'test-jti', + }, + }), + decodeProtectedHeader: jest.fn().mockReturnValue({ + alg: 'HS256', + typ: 'JWT', + }), + decodeJwt: jest.fn().mockImplementation((_token: string) => ({ + iss: 'test-client', + sub: 'test-client', + aud: 'https://example.com/token', + exp: Math.floor(Date.now() / 1000) + 300, + iat: Math.floor(Date.now() / 1000), + jti: 'test-jti', + })), + importSPKI: jest.fn().mockResolvedValue('mock-public-key'), + importPKCS8: jest.fn().mockResolvedValue('mock-private-key'), +})); + +describe('JWTAssertionGenerator', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('generateClientAssertion', () => { + it('should generate a client assertion with HMAC signing', async () => { + const credentials: JWTClientCredentials = { + clientSecret: 'test-secret', + algorithm: 'HS256', + }; + + const assertion = await JWTAssertionGenerator.generateClientAssertion( + 'test-client', + 'https://example.com/token', + credentials + ); + + expect(assertion).toBe('mock.jwt.token'); + }); + + it('should generate a client assertion with RSA signing', async () => { + const credentials: JWTClientCredentials = { + privateKey: '-----BEGIN PRIVATE KEY-----\nMOCK_KEY\n-----END PRIVATE KEY-----', + algorithm: 'RS256', + keyId: 'test-key-id', + }; + + const assertion = await JWTAssertionGenerator.generateClientAssertion( + 'test-client', + 'https://example.com/token', + credentials + ); + + expect(assertion).toBe('mock.jwt.token'); + }); + + it('should use default values when not provided', async () => { + const credentials: JWTClientCredentials = { + clientSecret: 'test-secret', + }; + + const assertion = await JWTAssertionGenerator.generateClientAssertion( + 'test-client', + 'https://example.com/token', + credentials + ); + + expect(assertion).toBe('mock.jwt.token'); + }); + }); + + describe('generateBearerAssertion', () => { + it('should generate a bearer assertion', async () => { + const credentials: JWTClientCredentials = { + clientSecret: 'test-secret', + algorithm: 'HS256', + }; + + const assertion = await JWTAssertionGenerator.generateBearerAssertion( + 'test-issuer', + 'test-subject', + 'https://example.com/resource', + credentials, + undefined, + { scope: 'read write' } + ); + + expect(assertion).toBe('mock.jwt.token'); + }); + }); +}); + +describe('JWTValidator', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('validateClientAssertion', () => { + it('should validate a client assertion', async () => { + const result = await JWTValidator.validateClientAssertion( + 'mock.jwt.token', + 'https://example.com/token', + 'test-secret' + ); + + expect(result.clientId).toBe('test-client'); + expect(result.audience).toEqual(['https://example.com/token']); + }); + }); + + describe('validateBearerAssertion', () => { + it('should validate a bearer assertion', async () => { + const result = await JWTValidator.validateBearerAssertion( + 'mock.jwt.token', + 'https://example.com/resource', + 'test-secret' + ); + + expect(result.clientId).toBe('test-client'); + }); + }); +}); + +describe('Utility functions', () => { + describe('generateJwtId', () => { + it('should generate a unique JWT ID', () => { + const jwtId1 = generateJwtId(); + const jwtId2 = generateJwtId(); + + expect(jwtId1).not.toBe(jwtId2); + // UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx + expect(jwtId1).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); + }); + + it('should generate a JWT ID with prefix', () => { + const jwtId = generateJwtId('test-client'); + + expect(jwtId).toMatch(/^test-client-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); + }); + }); + + describe('isSupportedJWTAlgorithm', () => { + it('should return true for supported algorithms', () => { + expect(isSupportedJWTAlgorithm('HS256')).toBe(true); + expect(isSupportedJWTAlgorithm('RS256')).toBe(true); + expect(isSupportedJWTAlgorithm('ES256')).toBe(true); + }); + + it('should return false for unsupported algorithms', () => { + expect(isSupportedJWTAlgorithm('none')).toBe(false); + expect(isSupportedJWTAlgorithm('HS128')).toBe(false); + expect(isSupportedJWTAlgorithm('invalid')).toBe(false); + }); + }); + + describe('selectJWTAlgorithm', () => { + it('should return the specified algorithm', () => { + const credentials: JWTClientCredentials = { + algorithm: 'RS256', + privateKey: 'test-key', + }; + + expect(selectJWTAlgorithm(credentials)).toBe('RS256'); + }); + + it('should default to HS256 for client secret', () => { + const credentials: JWTClientCredentials = { + clientSecret: 'test-secret', + }; + + expect(selectJWTAlgorithm(credentials)).toBe('HS256'); + }); + + it('should default to RS256 for private key', () => { + const credentials: JWTClientCredentials = { + privateKey: 'test-key', + }; + + expect(selectJWTAlgorithm(credentials)).toBe('RS256'); + }); + + it('should throw error when no credentials provided', () => { + const credentials: JWTClientCredentials = {}; + + expect(() => selectJWTAlgorithm(credentials)).toThrow('Cannot determine JWT algorithm: no credentials provided'); + }); + }); +}); + +// Merged from jwt.test.ts - Basic JWT schema tests +describe('JWT Schemas', () => { + describe('JWTClientAssertionPayloadSchema', () => { + it('should validate a valid client assertion payload', () => { + const now = Math.floor(Date.now() / 1000); + const validPayload = { + iss: 'test-client', + sub: 'test-client', + aud: 'https://example.com/token', + jti: 'test-jti-123', + exp: now + 300, + iat: now, + nbf: now + }; + + const result = JWTClientAssertionPayloadSchema.safeParse(validPayload); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.iss).toBe('test-client'); + expect(result.data.sub).toBe('test-client'); + expect(result.data.aud).toBe('https://example.com/token'); + expect(result.data.jti).toBe('test-jti-123'); + } + }); + + it('should validate payload with array audience', () => { + const now = Math.floor(Date.now() / 1000); + const validPayload = { + iss: 'test-client', + sub: 'test-client', + aud: ['https://example.com/token', 'https://example.com/api'], + jti: 'test-jti-123', + exp: now + 300, + iat: now + }; + + const result = JWTClientAssertionPayloadSchema.safeParse(validPayload); + expect(result.success).toBe(true); + if (result.success) { + expect(Array.isArray(result.data.aud)).toBe(true); + expect(result.data.aud).toEqual(['https://example.com/token', 'https://example.com/api']); + } + }); + }); + + describe('JWTBearerGrantPayloadSchema', () => { + it('should validate a valid bearer grant payload', () => { + const now = Math.floor(Date.now() / 1000); + const validPayload = { + iss: 'test-issuer', + sub: 'test-subject', + aud: 'https://example.com/resource', + exp: now + 300, + iat: now, + scope: 'read write', + customClaim: 'custom-value' + }; + + const result = JWTBearerGrantPayloadSchema.safeParse(validPayload); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.iss).toBe('test-issuer'); + expect(result.data.sub).toBe('test-subject'); + expect(result.data.scope).toBe('read write'); + expect((result.data as Record).customClaim).toBe('custom-value'); + } + }); + }); + + describe('JWTClientCredentialsSchema', () => { + it('should validate HMAC credentials', () => { + const hmacCredentials = { + clientSecret: 'test-secret', + algorithm: 'HS256' as const, + tokenLifetime: 300, + issuer: 'test-issuer', + subject: 'test-subject' + }; + + const result = JWTClientCredentialsSchema.safeParse(hmacCredentials); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.clientSecret).toBe('test-secret'); + expect(result.data.algorithm).toBe('HS256'); + expect(result.data.tokenLifetime).toBe(300); + } + }); + + it('should validate RSA credentials', () => { + const rsaCredentials = { + privateKey: '-----BEGIN PRIVATE KEY-----\nMOCK_KEY\n-----END PRIVATE KEY-----', + keyId: 'test-key-id', + algorithm: 'RS256' as const, + tokenLifetime: 180 + }; + + const result = JWTClientCredentialsSchema.safeParse(rsaCredentials); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.privateKey).toContain('MOCK_KEY'); + expect(result.data.keyId).toBe('test-key-id'); + expect(result.data.algorithm).toBe('RS256'); + } + }); + }); +}); + +// Merged from jwt-integration.test.ts - Infrastructure integration tests +describe('JWT Infrastructure Integration', () => { + describe('Schema validation', () => { + it('should validate JWT client assertion payload schema', () => { + const validPayload = { + iss: 'test-client', + sub: 'test-client', + aud: 'https://example.com/token', + jti: 'test-jti', + exp: Math.floor(Date.now() / 1000) + 300, + iat: Math.floor(Date.now() / 1000), + }; + + const result = JWTClientAssertionPayloadSchema.safeParse(validPayload); + expect(result.success).toBe(true); + }); + + it('should validate JWT bearer grant payload schema', () => { + const validPayload = { + iss: 'test-issuer', + sub: 'test-subject', + aud: 'https://example.com/resource', + exp: Math.floor(Date.now() / 1000) + 300, + iat: Math.floor(Date.now() / 1000), + scope: 'read write', + customClaim: 'custom-value' + }; + + const result = JWTBearerGrantPayloadSchema.safeParse(validPayload); + expect(result.success).toBe(true); + }); + + it('should validate JWT client credentials schema', () => { + const validCredentials = { + clientSecret: 'test-secret', + algorithm: 'HS256' as const, + tokenLifetime: 300, + issuer: 'test-issuer', + subject: 'test-subject' + }; + + const result = JWTClientCredentialsSchema.safeParse(validCredentials); + expect(result.success).toBe(true); + }); + }); + + describe('Type definitions', () => { + it('should support all client authentication methods', () => { + const methods = [ + 'client_secret_basic', + 'client_secret_post', + 'client_secret_jwt', + 'private_key_jwt', + 'none' + ]; + + expect(methods.length).toBe(5); + expect(methods).toContain('client_secret_jwt'); + expect(methods).toContain('private_key_jwt'); + }); + }); +}); \ No newline at end of file diff --git a/src/shared/jwt-utils.ts b/src/shared/jwt-utils.ts new file mode 100644 index 000000000..3d41a1c83 --- /dev/null +++ b/src/shared/jwt-utils.ts @@ -0,0 +1,553 @@ +import { + JWTAssertionOptions, + JWTSigningOptions, + JWTClientCredentials, + JWTValidationResult, + JWTClientAssertionPayload, + JWTBearerGrantPayload +} from './auth.js'; +import type { JWTClaimVerificationOptions, JWTHeaderParameters } from 'jose'; +import { SignJWT, importPKCS8, importJWK, decodeProtectedHeader, decodeJwt, jwtVerify, importSPKI } from 'jose'; +import { randomUUID } from 'crypto'; +import { + InvalidJWTError, + UnsupportedJWTAlgorithmError, + ExpiredJWTError, + InvalidJWTAudienceError +} from '../server/auth/errors.js'; + +/** + * JWT Assertion Generator + * Handles generation of JWT assertions for client authentication and bearer grants + */ +export class JWTAssertionGenerator { + /** + * Generate a JWT assertion with the given options and signing configuration + */ + static async generateAssertion( + options: JWTAssertionOptions, + signingOptions: JWTSigningOptions + ): Promise { + try { + + const now = Math.floor(Date.now() / 1000); + const expiresIn = options.expiresIn || 300; // Default 5 minutes + + // Create JWT payload + const payload = { + iss: options.issuer, + sub: options.subject, + aud: options.audience, + exp: now + expiresIn, + iat: now, + ...(options.notBefore && { nbf: options.notBefore }), + ...(options.jwtId && { jti: options.jwtId }), + ...options.additionalClaims, + }; + + // Create JWT builder + const jwt = new SignJWT(payload) + .setProtectedHeader({ + alg: signingOptions.algorithm, + ...(signingOptions.keyId && { kid: signingOptions.keyId }) + }); + + // Sign with appropriate key material using jose library's key handling + if (signingOptions.secret) { + // HMAC signing + const secret = new TextEncoder().encode(signingOptions.secret); + return await jwt.sign(secret); + } else if (signingOptions.privateKey) { + // RSA/ECDSA signing - let jose handle key format detection + if (typeof signingOptions.privateKey === 'string') { + try { + // Try PKCS8 format first + const key = await importPKCS8(signingOptions.privateKey, signingOptions.algorithm); + return await jwt.sign(key); + } catch { + try { + // Try JWK format if PKCS8 fails + const key = await importJWK(JSON.parse(signingOptions.privateKey), signingOptions.algorithm); + return await jwt.sign(key); + } catch { + // If both fail, throw JWT-specific error + throw new InvalidJWTError('Invalid private key format. Expected PKCS8 PEM or JWK JSON format'); + } + } + } else { + return await jwt.sign(signingOptions.privateKey); + } + } else { + throw new InvalidJWTError('Either secret or privateKey must be provided for JWT signing'); + } + } catch (error) { + // Re-throw JWT-specific errors as-is + if (error instanceof InvalidJWTError || + error instanceof UnsupportedJWTAlgorithmError || + error instanceof ExpiredJWTError || + error instanceof InvalidJWTAudienceError) { + throw error; + } + + // Wrap other errors as InvalidJWTError + throw new InvalidJWTError(`Failed to generate JWT assertion: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + /** + * Generate a JWT assertion for client authentication + * Ensures MCP-compliant audience validation for token endpoints + */ + static async generateClientAssertion( + clientId: string, + tokenEndpoint: string, + credentials: JWTClientCredentials, + resource?: URL + ): Promise { + const jwtId = generateJwtId(clientId); + + // For MCP compliance, ensure audience is properly set to the token endpoint + // The audience should be the token endpoint URL, not the resource URL + const audience = normalizeMCPAudience(tokenEndpoint, resource); + + const options: JWTAssertionOptions = { + issuer: credentials.issuer || clientId, + subject: credentials.subject || clientId, + audience, + expiresIn: credentials.tokenLifetime || 300, + jwtId, + }; + + const signingOptions: JWTSigningOptions = { + algorithm: credentials.algorithm || (credentials.clientSecret ? 'HS256' : 'RS256'), + secret: credentials.clientSecret, + privateKey: credentials.privateKey, + keyId: credentials.keyId, + }; + + return this.generateAssertion(options, signingOptions); + } + + /** + * Generate a JWT assertion for bearer authorization grants + * Ensures MCP-compliant audience validation and resource binding + */ + static async generateBearerAssertion( + issuer: string, + subject: string, + audience: string, + credentials: JWTClientCredentials, + resource?: URL, + additionalClaims?: Record + ): Promise { + const jwtId = generateJwtId(issuer); + + // For MCP compliance, ensure audience properly handles resource binding + // The audience should include both the authorization server and the resource if specified + const mcpAudience = normalizeMCPAudience(audience, resource); + + // Add MCP-specific claims for resource binding if resource is provided + const mcpClaims = resource ? { + ...additionalClaims, + // Include resource URL in claims for MCP compliance + resource: resource.toString(), + } : additionalClaims; + + const options: JWTAssertionOptions = { + issuer: credentials.issuer || issuer, + subject: credentials.subject || subject, + audience: mcpAudience, + expiresIn: credentials.tokenLifetime || 300, + jwtId, + additionalClaims: mcpClaims, + }; + + const signingOptions: JWTSigningOptions = { + algorithm: credentials.algorithm || (credentials.clientSecret ? 'HS256' : 'RS256'), + secret: credentials.clientSecret, + privateKey: credentials.privateKey, + keyId: credentials.keyId, + }; + + return this.generateAssertion(options, signingOptions); + } +} + +/** + * JWT Validator + * Handles validation of JWT assertions for client authentication and bearer grants + */ +export class JWTValidator { + /** + * Validate a JWT client assertion + */ + static async validateClientAssertion( + assertion: string, + tokenEndpoint: string, + clientSecret?: string, + publicKey?: string | Buffer, + options?: Partial + ): Promise { + const validationOptions: JWTClaimVerificationOptions = { + audience: tokenEndpoint, + clockTolerance: 30, + maxTokenAge: 300, + ...options, + }; + + return this.validateAssertion(assertion, clientSecret, publicKey, validationOptions); + } + + /** + * Validate a JWT bearer assertion + */ + static async validateBearerAssertion( + assertion: string, + expectedAudience: string, + clientSecret?: string, + publicKey?: string | Buffer, + options?: Partial + ): Promise { + const validationOptions: JWTClaimVerificationOptions = { + audience: expectedAudience, + clockTolerance: 30, + maxTokenAge: 300, + ...options, + }; + + return this.validateAssertion(assertion, clientSecret, publicKey, validationOptions); + } + + /** + * Validate a JWT client assertion with client store integration + * This method is designed for server-side validation where client credentials + * are retrieved from the client store based on the JWT claims + */ + static async validateClientAssertionWithStore( + assertion: string, + tokenEndpoint: string, + clientsStore: unknown, // OAuthRegisteredClientsStore - avoiding import cycle + options?: Partial + ): Promise<{ validationResult: JWTValidationResult; client: unknown }> { + try { + // First decode the JWT to extract client ID without verification + + const header = decodeProtectedHeader(assertion); + const payload = decodeJwt(assertion); + + if (!header.alg) { + throw new InvalidJWTError('JWT header must contain algorithm (alg) claim'); + } + + // Extract client ID from issuer or subject + const clientId = payload.iss || payload.sub; + if (!clientId || typeof clientId !== 'string') { + throw new InvalidJWTError('JWT must contain valid issuer (iss) or subject (sub) claim'); + } + + // Get client information from store + const client = await (clientsStore as { getClient: (id: string) => Promise<{ client_secret?: string;[key: string]: unknown }> }).getClient(clientId); + if (!client) { + throw new InvalidJWTError(`Client not found: ${clientId}`); + } + + // Determine key material based on algorithm and client configuration + let clientSecret: string | undefined; + let publicKey: string | Buffer | undefined; + + if (header.alg.startsWith('HS')) { + // HMAC algorithms require client secret + if (!client.client_secret) { + throw new InvalidJWTError('Client secret required for HMAC signature verification'); + } + clientSecret = client.client_secret; + } else { + // RSA/ECDSA algorithms require public key + // In a real implementation, this would come from client.jwks_uri or client.jwks + // For now, we'll throw an error indicating this needs to be implemented + throw new UnsupportedJWTAlgorithmError(header.alg); + } + + // Validate the JWT with the appropriate key material + const validationOptions: JWTClaimVerificationOptions = { + audience: tokenEndpoint, + clockTolerance: 30, + maxTokenAge: 300, + ...options, + }; + + const validationResult = await this.validateAssertion( + assertion, + clientSecret, + publicKey, + validationOptions + ); + + // Additional MCP-compliant validation + if (validationResult.clientId !== clientId) { + throw new InvalidJWTError('JWT client ID mismatch'); + } + + return { validationResult, client }; + } catch (error) { + // Re-throw JWT-specific errors as-is + if (error instanceof InvalidJWTError || + error instanceof UnsupportedJWTAlgorithmError || + error instanceof ExpiredJWTError || + error instanceof InvalidJWTAudienceError) { + throw error; + } + + // Wrap other errors as InvalidJWTError + throw new InvalidJWTError(`JWT client assertion validation failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + /** + * Validate a JWT bearer assertion for authorization grants + * This method includes MCP-compliant audience and resource validation + */ + static async validateBearerAssertionForGrant( + assertion: string, + expectedAudience: string, + resource?: URL, + options?: Partial + ): Promise { + try { + // For MCP compliance, create expected audience list that includes resource if provided + const expectedAudiences = resource ? + normalizeMCPAudience(expectedAudience, resource) : + expectedAudience; + + const validationOptions: JWTClaimVerificationOptions = { + audience: expectedAudiences, + clockTolerance: 30, + maxTokenAge: 300, + ...options, + }; + + // For bearer grants, we need to validate without specific client credentials + // The JWT should be self-contained with proper signature verification + const validationResult = await this.validateAssertion( + assertion, + undefined, // No client secret for bearer grants + undefined, // Public key would come from JWKS + validationOptions + ); + + // Additional MCP resource parameter validation + if (resource) { + const resourceUrl = resource.toString(); + + // Validate that the JWT contains the resource claim for MCP compliance + const payload = validationResult.payload as JWTBearerGrantPayload; + if ('resource' in payload && payload.resource && payload.resource !== resourceUrl) { + throw new InvalidJWTAudienceError(resourceUrl, String(payload.resource)); + } + + // Ensure the JWT audience is compatible with the requested resource + if (!validationResult.audience.some(aud => + aud === resourceUrl || aud === expectedAudience || + // Check if audience matches the resource origin for MCP compatibility + (new URL(aud).origin === new URL(resourceUrl).origin) + )) { + throw new InvalidJWTAudienceError(resourceUrl, validationResult.audience.join(', ')); + } + } + + return validationResult; + } catch (error) { + // Re-throw JWT-specific errors as-is + if (error instanceof InvalidJWTError || + error instanceof UnsupportedJWTAlgorithmError || + error instanceof ExpiredJWTError || + error instanceof InvalidJWTAudienceError) { + throw error; + } + + // Wrap other errors as InvalidJWTError + throw new InvalidJWTError(`JWT bearer assertion validation failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } + + /** + * Internal method to validate JWT assertions + * Simplified to use jose library's built-in key handling + */ + private static async validateAssertion( + assertion: string, + secret?: string, + publicKey?: string | Buffer, + options?: JWTClaimVerificationOptions + ): Promise { + try { + + // Decode header to determine algorithm + const header = decodeProtectedHeader(assertion); + + if (!header.alg) { + throw new InvalidJWTError('JWT header must contain algorithm (alg) claim'); + } + + // Note: Using 'any' here because jose library accepts multiple key types (Uint8Array, KeyLike, JWK) + // and the exact type depends on the algorithm and key source (HMAC secret vs RSA/ECDSA keys) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let verificationKey: any; + + if (header.alg.startsWith('HS')) { + // HMAC verification + if (!secret) { + throw new InvalidJWTError('Secret required for HMAC signature verification'); + } + verificationKey = new TextEncoder().encode(secret); + } else { + // RSA/ECDSA verification + if (!publicKey) { + throw new InvalidJWTError('Public key required for RSA/ECDSA signature verification'); + } + + if (typeof publicKey === 'string') { + // Let jose library handle key format detection + try { + verificationKey = await importSPKI(publicKey, header.alg); + } catch { + try { + verificationKey = await importPKCS8(publicKey, header.alg); + } catch { + // Try as JWK if other formats fail + verificationKey = await importJWK(JSON.parse(publicKey), header.alg); + } + } + } else { + verificationKey = publicKey; + } + } + + // Use validation options directly (they now extend JWTClaimVerificationOptions) + const validationOptions = options ? { + ...options, + clockTolerance: options.clockTolerance || 30, + maxTokenAge: options.maxTokenAge || 300, // Default maxTokenAge if not provided + } : {}; + + const result = await jwtVerify(assertion, verificationKey, validationOptions); + + const payload = result.payload as JWTClientAssertionPayload | JWTBearerGrantPayload; + + // Extract client ID from issuer or subject + const clientId = payload.iss || payload.sub; + if (!clientId) { + throw new InvalidJWTError('JWT must contain issuer (iss) or subject (sub) claim'); + } + + // Normalize audience to array + const audience = Array.isArray(payload.aud) ? payload.aud : [payload.aud]; + + return { + payload, + header: header as JWTHeaderParameters, + clientId, + issuedAt: payload.iat, + expiresAt: payload.exp, + audience, + }; + } catch (error) { + // Re-throw JWT-specific errors as-is + if (error instanceof InvalidJWTError || + error instanceof UnsupportedJWTAlgorithmError || + error instanceof ExpiredJWTError || + error instanceof InvalidJWTAudienceError) { + throw error; + } + + // Check for specific JOSE errors that map to our JWT error types + if (error instanceof Error) { + if (error.message.includes('expired') || error.message.includes('exp')) { + throw new ExpiredJWTError(); + } + if (error.message.includes('audience') || error.message.includes('aud')) { + throw new InvalidJWTAudienceError('expected', 'actual'); + } + if (error.message.includes('algorithm') || error.message.includes('alg')) { + throw new UnsupportedJWTAlgorithmError('unknown'); + } + } + + // Wrap other errors as InvalidJWTError + throw new InvalidJWTError(`JWT validation failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + } + } +} + +/** + * Utility function to generate a unique JWT ID + * Uses crypto.randomUUID for better randomness when available + */ +export function generateJwtId(prefix?: string): string { + try { + const uuid = randomUUID(); + return prefix ? `${prefix}-${uuid}` : uuid; + } catch { + // Fallback to timestamp + random for older environments + const timestamp = Date.now(); + const random = Math.random().toString(36).substring(2, 11); + return prefix ? `${prefix}-${timestamp}-${random}` : `${timestamp}-${random}`; + } +} + +/** + * Utility function to check if a JWT algorithm is supported + * Uses a predefined list of supported algorithms that are compatible with jose library + */ +export function isSupportedJWTAlgorithm(algorithm: string): boolean { + const supportedAlgorithms = [ + 'HS256', 'HS384', 'HS512', + 'RS256', 'RS384', 'RS512', + 'ES256', 'ES384', 'ES512' + ]; + return supportedAlgorithms.includes(algorithm); +} + +/** + * Utility function to determine the appropriate algorithm based on key material + */ +export function selectJWTAlgorithm(credentials: JWTClientCredentials): string { + if (credentials.algorithm) { + return credentials.algorithm; + } + + // Default algorithm selection + if (credentials.clientSecret) { + return 'HS256'; // HMAC with SHA-256 + } else if (credentials.privateKey) { + return 'RS256'; // RSA with SHA-256 + } + + throw new InvalidJWTError('Cannot determine JWT algorithm: no credentials provided'); +} + +/** + * Normalizes audience claims for MCP compliance + * Ensures proper audience validation for MCP server URLs and resource binding + * + * @param primaryAudience - The primary audience (usually token endpoint or authorization server) + * @param resource - Optional MCP resource URL for resource binding + * @returns Normalized audience string or array for JWT claims + */ +export function normalizeMCPAudience(primaryAudience: string, resource?: URL): string | string[] { + // Always include the primary audience (token endpoint or authorization server) + const audiences = [primaryAudience]; + + // For MCP compliance, if a resource is specified, include it in the audience + // This ensures the JWT is bound to the specific MCP server resource + if (resource) { + const resourceUrl = resource.toString(); + // Only add resource URL if it's different from primary audience + if (resourceUrl !== primaryAudience) { + audiences.push(resourceUrl); + } + } + + // Return single string if only one audience, array if multiple + return audiences.length === 1 ? audiences[0] : audiences; +} \ No newline at end of file