diff --git a/package-lock.json b/package-lock.json index 01bc0953..fa1bde0e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@modelcontextprotocol/sdk", - "version": "1.15.0", + "version": "1.15.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@modelcontextprotocol/sdk", - "version": "1.15.0", + "version": "1.15.1", "license": "MIT", "dependencies": { "ajv": "^6.12.6", diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 3d2f18d7..2aad86de 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -20,6 +20,10 @@ describe("OAuth Authorization", () => { beforeEach(() => { mockFetch.mockReset(); }); + + afterEach(() => { + jest.restoreAllMocks(); + }); describe("extractResourceMetadataUrl", () => { it("returns resource metadata url when present", async () => { @@ -728,6 +732,51 @@ describe("OAuth Authorization", () => { expect(authorizationUrl.searchParams.get("prompt")).toBe("consent"); }); + it("generates nonce automatically for OpenID Connect flows", async () => { + const { authorizationUrl, nonce } = await startAuthorization( + "https://auth.example.com", + { + clientInformation: validClientInfo, + redirectUrl: "http://localhost:3000/callback", + scope: "openid profile email", + } + ); + + expect(nonce).toBeDefined(); + expect(authorizationUrl.searchParams.get("nonce")).toBe(nonce); + expect(nonce).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); + }); + + it("uses provided nonce for OpenID Connect flows", async () => { + const providedNonce = "test-nonce-123"; + const { authorizationUrl, nonce } = await startAuthorization( + "https://auth.example.com", + { + clientInformation: validClientInfo, + redirectUrl: "http://localhost:3000/callback", + scope: "openid profile", + nonce: providedNonce, + } + ); + + expect(nonce).toBe(providedNonce); + expect(authorizationUrl.searchParams.get("nonce")).toBe(providedNonce); + }); + + it("does not include nonce for non-OpenID Connect flows", async () => { + const { authorizationUrl, nonce } = await startAuthorization( + "https://auth.example.com", + { + clientInformation: validClientInfo, + redirectUrl: "http://localhost:3000/callback", + scope: "read write", + } + ); + + expect(nonce).toBeUndefined(); + expect(authorizationUrl.searchParams.has("nonce")).toBe(false); + }); + it("uses metadata authorization_endpoint when provided", async () => { const { authorizationUrl } = await startAuthorization( "https://auth.example.com", @@ -916,6 +965,250 @@ describe("OAuth Authorization", () => { }) ).rejects.toThrow("Token exchange failed"); }); + + it("validates nonce in ID token when present", async () => { + const idToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJub25jZSI6InRlc3Qtbm9uY2UtMTIzIiwiYXVkIjoiY2xpZW50MTIzIiwic3ViIjoiMTIzNDU2Nzg5MCIsIm5hbWUiOiJKb2huIERvZSIsImlhdCI6MTUxNjIzOTAyMn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; + const tokensWithIdToken = { + ...validTokens, + id_token: idToken, + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => tokensWithIdToken, + }); + + const tokens = await exchangeAuthorization("https://auth.example.com", { + clientInformation: validClientInfo, + authorizationCode: "code123", + codeVerifier: "verifier123", + redirectUri: "http://localhost:3000/callback", + nonce: "test-nonce-123", + }); + + expect(tokens).toEqual(tokensWithIdToken); + }); + + it("throws error when nonce in ID token doesn't match", async () => { + // ID token with different nonce + const idToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJub25jZSI6ImRpZmZlcmVudC1ub25jZSIsImF1ZCI6ImNsaWVudDEyMyIsInN1YiI6IjEyMzQ1Njc4OTAiLCJuYW1lIjoiSm9obiBEb2UiLCJpYXQiOjE1MTYyMzkwMjJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; + const tokensWithIdToken = { + ...validTokens, + id_token: idToken, + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => tokensWithIdToken, + }); + + await expect( + exchangeAuthorization("https://auth.example.com", { + clientInformation: validClientInfo, + authorizationCode: "code123", + codeVerifier: "verifier123", + redirectUri: "http://localhost:3000/callback", + nonce: "test-nonce-123", + }) + ).rejects.toThrow("ID token nonce mismatch - possible replay attack"); + }); + + it("throws error when nonce is expected but missing in ID token", async () => { + // ID token without nonce claim + const idToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJjbGllbnQxMjMiLCJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; + const tokensWithIdToken = { + ...validTokens, + id_token: idToken, + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => tokensWithIdToken, + }); + + await expect( + exchangeAuthorization("https://auth.example.com", { + clientInformation: validClientInfo, + authorizationCode: "code123", + codeVerifier: "verifier123", + redirectUri: "http://localhost:3000/callback", + nonce: "test-nonce-123", + }) + ).rejects.toThrow("ID token nonce mismatch - possible replay attack"); + }); + + it("skips nonce validation when no nonce was provided", async () => { + // ID token with nonce claim + const idToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJub25jZSI6InRlc3Qtbm9uY2UtMTIzIiwiYXVkIjoiY2xpZW50MTIzIiwic3ViIjoiMTIzNDU2Nzg5MCIsIm5hbWUiOiJKb2huIERvZSIsImlhdCI6MTUxNjIzOTAyMn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; + const tokensWithIdToken = { + ...validTokens, + id_token: idToken, + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => tokensWithIdToken, + }); + + // No nonce parameter provided + const tokens = await exchangeAuthorization("https://auth.example.com", { + clientInformation: validClientInfo, + authorizationCode: "code123", + codeVerifier: "verifier123", + redirectUri: "http://localhost:3000/callback", + }); + + expect(tokens).toEqual(tokensWithIdToken); + }); + + it("validates audience in ID token", async () => { + // ID token with correct audience + const idToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJjbGllbnQxMjMiLCJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; + const tokensWithIdToken = { + ...validTokens, + id_token: idToken, + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => tokensWithIdToken, + }); + + const tokens = await exchangeAuthorization("https://auth.example.com", { + clientInformation: validClientInfo, + authorizationCode: "code123", + codeVerifier: "verifier123", + redirectUri: "http://localhost:3000/callback", + }); + + expect(tokens).toEqual(tokensWithIdToken); + }); + + it("validates audience when ID token has array audience", async () => { + // ID token with array audience containing our client_id + // Payload: {"aud":["client123","other-client"],"sub":"1234567890"} + const idToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsiY2xpZW50MTIzIiwib3RoZXItY2xpZW50Il0sInN1YiI6IjEyMzQ1Njc4OTAifQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; + const tokensWithIdToken = { + ...validTokens, + id_token: idToken, + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => tokensWithIdToken, + }); + + const tokens = await exchangeAuthorization("https://auth.example.com", { + clientInformation: validClientInfo, + authorizationCode: "code123", + codeVerifier: "verifier123", + redirectUri: "http://localhost:3000/callback", + }); + + expect(tokens).toEqual(tokensWithIdToken); + }); + + it("throws error when audience in ID token doesn't match", async () => { + // ID token with wrong audience + const idToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJ3cm9uZy1jbGllbnQiLCJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; + const tokensWithIdToken = { + ...validTokens, + id_token: idToken, + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => tokensWithIdToken, + }); + + await expect( + exchangeAuthorization("https://auth.example.com", { + clientInformation: validClientInfo, + authorizationCode: "code123", + codeVerifier: "verifier123", + redirectUri: "http://localhost:3000/callback", + }) + ).rejects.toThrow("ID token audience mismatch"); + }); + + it("throws error when ID token is malformed (not 3 parts)", async () => { + // Malformed ID token with only 2 parts + const idToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJjbGllbnQxMjMiLCJzdWIiOiIxMjM0NTY3ODkwIn0"; + const tokensWithIdToken = { + ...validTokens, + id_token: idToken, + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => tokensWithIdToken, + }); + + await expect( + exchangeAuthorization("https://auth.example.com", { + clientInformation: validClientInfo, + authorizationCode: "code123", + codeVerifier: "verifier123", + redirectUri: "http://localhost:3000/callback", + }) + ).rejects.toThrow("Invalid JWT format"); + }); + + it("throws error when ID token has invalid base64 in payload", async () => { + // ID token with invalid base64 characters in payload + const idToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.!!!invalid-base64!!!.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; + const tokensWithIdToken = { + ...validTokens, + id_token: idToken, + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => tokensWithIdToken, + }); + + await expect( + exchangeAuthorization("https://auth.example.com", { + clientInformation: validClientInfo, + authorizationCode: "code123", + codeVerifier: "verifier123", + redirectUri: "http://localhost:3000/callback", + }) + ).rejects.toThrow(); + }); + + it("throws error when ID token payload is not valid JSON", async () => { + // ID token with invalid JSON in payload (base64 of "not json") + const idToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.bm90IGpzb24.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; + const tokensWithIdToken = { + ...validTokens, + id_token: idToken, + }; + + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => tokensWithIdToken, + }); + + await expect( + exchangeAuthorization("https://auth.example.com", { + clientInformation: validClientInfo, + authorizationCode: "code123", + codeVerifier: "verifier123", + redirectUri: "http://localhost:3000/callback", + }) + ).rejects.toThrow(); + }); }); describe("refreshAuthorization", () => { diff --git a/src/client/auth.ts b/src/client/auth.ts index 2bac386f..2d07fc30 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -73,6 +73,18 @@ export interface OAuthClientProvider { */ codeVerifier(): string | Promise; + /** + * Saves the nonce for the current session, before redirecting to + * the authorization flow (for OpenID Connect). + */ + saveNonce?(nonce: string): void | Promise; + + /** + * Loads the nonce for the current session, necessary to validate + * the ID token (for OpenID Connect). + */ + nonce?(): string | undefined | Promise; + /** * Adds custom client authentication to OAuth token requests. * @@ -113,6 +125,43 @@ export class UnauthorizedError extends Error { type ClientAuthMethod = 'client_secret_basic' | 'client_secret_post' | 'none'; +/** + * Standard JWT claims that may appear in ID tokens. + * Based on OpenID Connect Core 1.0 specification. + */ +interface JwtClaims { + // Standard OIDC claims + aud?: string | string[]; // Audience - who the token is for + exp?: number; // Expiration time (seconds since epoch) + iat?: number; // Issued at time (seconds since epoch) + iss?: string; // Issuer - who created the token + sub?: string; // Subject - who the token is about + nonce?: string; // Nonce for replay protection + + // Additional claims can exist + [key: string]: unknown; +} + +/** + * Decodes a JWT without verifying the signature. + * Only extracts the payload for claim validation. + */ +function decodeJwt(jwt: string): JwtClaims { + const parts = jwt.split('.'); + if (parts.length !== 3) { + throw new Error('Invalid JWT format'); + } + + const base64 = parts[1] + .replace(/-/g, '+') + .replace(/_/g, '/'); + + const padded = base64 + '='.repeat((4 - base64.length % 4) % 4); + + const decoded = atob(padded); + return JSON.parse(decoded); +} + /** * Determines the best client authentication method to use based on server support and client configuration. * @@ -278,6 +327,7 @@ export async function auth( // Exchange authorization code for tokens if (authorizationCode !== undefined) { const codeVerifier = await provider.codeVerifier(); + const nonce = provider.nonce ? await provider.nonce() : undefined; const tokens = await exchangeAuthorization(authorizationServerUrl, { metadata, clientInformation, @@ -285,6 +335,7 @@ export async function auth( codeVerifier, redirectUri: provider.redirectUrl, resource, + nonce, addClientAuthentication: provider.addClientAuthentication, }); @@ -316,7 +367,7 @@ export async function auth( const state = provider.state ? await provider.state() : undefined; // Start new authorization flow - const { authorizationUrl, codeVerifier } = await startAuthorization(authorizationServerUrl, { + const { authorizationUrl, codeVerifier, nonce } = await startAuthorization(authorizationServerUrl, { metadata, clientInformation, state, @@ -326,6 +377,9 @@ export async function auth( }); await provider.saveCodeVerifier(codeVerifier); + if (nonce && provider.saveNonce) { + await provider.saveNonce(nonce); + } await provider.redirectToAuthorization(authorizationUrl); return "REDIRECT"; } @@ -548,6 +602,7 @@ export async function discoverOAuthMetadata( /** * Begins the authorization flow with the given server, by generating a PKCE challenge and constructing the authorization URL. + * For OpenID Connect flows (when scope includes 'openid'), automatically generates a nonce if not provided. */ export async function startAuthorization( authorizationServerUrl: string | URL, @@ -557,6 +612,7 @@ export async function startAuthorization( redirectUrl, scope, state, + nonce, resource, }: { metadata?: OAuthMetadata; @@ -564,9 +620,10 @@ export async function startAuthorization( redirectUrl: string | URL; scope?: string; state?: string; + nonce?: string; resource?: URL; }, -): Promise<{ authorizationUrl: URL; codeVerifier: string }> { +): Promise<{ authorizationUrl: URL; codeVerifier: string; nonce?: string }> { const responseType = "code"; const codeChallengeMethod = "S256"; @@ -625,7 +682,13 @@ export async function startAuthorization( authorizationUrl.searchParams.set("resource", resource.href); } - return { authorizationUrl, codeVerifier }; + let generatedNonce: string | undefined; + if (scope?.includes("openid")) { + generatedNonce = nonce ?? crypto.randomUUID(); + authorizationUrl.searchParams.set("nonce", generatedNonce); + } + + return { authorizationUrl, codeVerifier, nonce: generatedNonce }; } /** @@ -634,11 +697,12 @@ export async function startAuthorization( * 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 + * - Validates nonce in ID token if provided (for OpenID Connect flows) * * @param authorizationServerUrl - The authorization server's base URL * @param options - Configuration object containing client info, auth code, etc. * @returns Promise resolving to OAuth tokens - * @throws {Error} When token exchange fails or authentication is invalid + * @throws {Error} When token exchange fails, authentication is invalid, or nonce doesn't match */ export async function exchangeAuthorization( authorizationServerUrl: string | URL, @@ -649,6 +713,7 @@ export async function exchangeAuthorization( codeVerifier, redirectUri, resource, + nonce, addClientAuthentication }: { metadata?: OAuthMetadata; @@ -657,6 +722,7 @@ export async function exchangeAuthorization( codeVerifier: string; redirectUri: string | URL; resource?: URL; + nonce?: string; addClientAuthentication?: OAuthClientProvider["addClientAuthentication"]; }, ): Promise { @@ -710,7 +776,26 @@ export async function exchangeAuthorization( throw new Error(`Token exchange failed: HTTP ${response.status}`); } - return OAuthTokensSchema.parse(await response.json()); + const tokens = OAuthTokensSchema.parse(await response.json()); + + if (tokens.id_token) { + const claims = decodeJwt(tokens.id_token); + + if (nonce && claims.nonce !== nonce) { + throw new Error('ID token nonce mismatch - possible replay attack'); + } + + const audience = claims.aud; + const validAudience = Array.isArray(audience) + ? audience.includes(clientInformation.client_id) + : audience === clientInformation.client_id; + + if (!validAudience) { + throw new Error('ID token audience mismatch'); + } + } + + return tokens; } /** diff --git a/src/examples/client/simpleOAuthClient.ts b/src/examples/client/simpleOAuthClient.ts index 4531f4c2..e5d0b0ed 100644 --- a/src/examples/client/simpleOAuthClient.ts +++ b/src/examples/client/simpleOAuthClient.ts @@ -28,6 +28,7 @@ class InMemoryOAuthClientProvider implements OAuthClientProvider { private _clientInformation?: OAuthClientInformationFull; private _tokens?: OAuthTokens; private _codeVerifier?: string; + private _nonce?: string; constructor( private readonly _redirectUrl: string | URL, @@ -79,6 +80,14 @@ class InMemoryOAuthClientProvider implements OAuthClientProvider { } return this._codeVerifier; } + + saveNonce(nonce: string): void { + this._nonce = nonce; + } + + nonce(): string | undefined { + return this._nonce; + } } /** * Interactive MCP client with OAuth authentication diff --git a/src/server/auth/handlers/authorize.test.ts b/src/server/auth/handlers/authorize.test.ts index 438db6a6..92773791 100644 --- a/src/server/auth/handlers/authorize.test.ts +++ b/src/server/auth/handlers/authorize.test.ts @@ -102,6 +102,10 @@ describe('Authorization Handler', () => { app.use('/authorize', handler); }); + afterEach(() => { + jest.restoreAllMocks(); + }); + describe('HTTP method validation', () => { it('rejects non-GET/POST methods', async () => { const response = await supertest(app) @@ -302,6 +306,61 @@ describe('Authorization Handler', () => { expect.any(Object) ); }); + + it('propagates nonce parameter for OpenID Connect flows', async () => { + const mockProviderWithNonce = jest.spyOn(mockProvider, 'authorize'); + + const response = await supertest(app) + .get('/authorize') + .query({ + client_id: 'valid-client', + redirect_uri: 'https://example.com/callback', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'S256', + scope: 'profile email', + nonce: 'test-nonce-123' + }); + + expect(response.status).toBe(302); + expect(mockProviderWithNonce).toHaveBeenCalledWith( + validClient, + expect.objectContaining({ + nonce: 'test-nonce-123', + redirectUri: 'https://example.com/callback', + codeChallenge: 'challenge123', + scopes: ['profile', 'email'] + }), + expect.any(Object) + ); + }); + + it('handles authorization without nonce parameter', async () => { + const mockProviderWithoutNonce = jest.spyOn(mockProvider, 'authorize'); + + const response = await supertest(app) + .get('/authorize') + .query({ + client_id: 'valid-client', + redirect_uri: 'https://example.com/callback', + response_type: 'code', + code_challenge: 'challenge123', + code_challenge_method: 'S256', + scope: 'profile email' + }); + + expect(response.status).toBe(302); + expect(mockProviderWithoutNonce).toHaveBeenCalledWith( + validClient, + expect.objectContaining({ + nonce: undefined, + redirectUri: 'https://example.com/callback', + codeChallenge: 'challenge123', + scopes: ['profile', 'email'] + }), + expect.any(Object) + ); + }); }); describe('Successful authorization', () => { diff --git a/src/server/auth/handlers/authorize.ts b/src/server/auth/handlers/authorize.ts index 126ce006..397f95e5 100644 --- a/src/server/auth/handlers/authorize.ts +++ b/src/server/auth/handlers/authorize.ts @@ -35,6 +35,7 @@ const RequestAuthorizationParamsSchema = z.object({ code_challenge_method: z.literal("S256"), scope: z.string().optional(), state: z.string().optional(), + nonce: z.string().optional(), resource: z.string().url().optional(), }); @@ -115,7 +116,7 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A throw new InvalidRequestError(parseResult.error.message); } - const { scope, code_challenge, resource } = parseResult.data; + const { scope, code_challenge, nonce, resource } = parseResult.data; state = parseResult.data.state; // Validate scopes @@ -138,6 +139,7 @@ export function authorizationHandler({ provider, rateLimit: rateLimitConfig }: A scopes: requestedScopes, redirectUri: redirect_uri, codeChallenge: code_challenge, + nonce, resource: resource ? new URL(resource) : undefined, }, res); } catch (error) { diff --git a/src/server/auth/provider.ts b/src/server/auth/provider.ts index 18beb216..9617479a 100644 --- a/src/server/auth/provider.ts +++ b/src/server/auth/provider.ts @@ -8,6 +8,7 @@ export type AuthorizationParams = { scopes?: string[]; codeChallenge: string; redirectUri: string; + nonce?: string; resource?: URL; };