From da27836ee7f005266c2f50f5849698202aa60b6e Mon Sep 17 00:00:00 2001 From: Ryan Slama Date: Mon, 14 Jul 2025 13:24:31 -0400 Subject: [PATCH 1/3] Add nonce support for OpenID Connect flows - Add optional nonce parameter to client startAuthorization() - Auto-generate nonce when scope includes 'openid' - Pass nonce through server authorization handler - Update AuthorizationParams type to include nonce - Add comprehensive tests for nonce handling This enables proper OpenID Connect security by preventing replay attacks on ID tokens. --- package-lock.json | 4 +- src/client/auth.test.ts | 59 ++++++++++++++++++++++ src/client/auth.ts | 13 ++++- src/server/auth/handlers/authorize.test.ts | 57 +++++++++++++++++++++ src/server/auth/handlers/authorize.ts | 4 +- src/server/auth/provider.ts | 1 + 6 files changed, 133 insertions(+), 5 deletions(-) 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..8d173868 100644 --- a/src/client/auth.test.ts +++ b/src/client/auth.test.ts @@ -728,6 +728,65 @@ 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("generates nonce when openid scope is included with other scopes", async () => { + const { authorizationUrl, nonce } = await startAuthorization( + "https://auth.example.com", + { + clientInformation: validClientInfo, + redirectUrl: "http://localhost:3000/callback", + scope: "read openid write profile", + } + ); + + expect(nonce).toBeDefined(); + expect(authorizationUrl.searchParams.get("nonce")).toBe(nonce); + }); + it("uses metadata authorization_endpoint when provided", async () => { const { authorizationUrl } = await startAuthorization( "https://auth.example.com", diff --git a/src/client/auth.ts b/src/client/auth.ts index 2bac386f..6fd2f789 100644 --- a/src/client/auth.ts +++ b/src/client/auth.ts @@ -548,6 +548,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 +558,7 @@ export async function startAuthorization( redirectUrl, scope, state, + nonce, resource, }: { metadata?: OAuthMetadata; @@ -564,9 +566,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 +628,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 }; } /** diff --git a/src/server/auth/handlers/authorize.test.ts b/src/server/auth/handlers/authorize.test.ts index 438db6a6..50127afc 100644 --- a/src/server/auth/handlers/authorize.test.ts +++ b/src/server/auth/handlers/authorize.test.ts @@ -302,6 +302,63 @@ describe('Authorization Handler', () => { expect.any(Object) ); }); + + it('propagates nonce parameter for OpenID Connect flows', async () => { + const mockProviderWithNonce = jest.spyOn(mockProvider, 'authorize'); + mockProviderWithNonce.mockClear(); + + 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'); + mockProviderWithoutNonce.mockClear(); + + 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; }; From fd335d2de2cc70bba99be57ac8fa0103a74ebd90 Mon Sep 17 00:00:00 2001 From: Ryan Slama Date: Mon, 14 Jul 2025 17:58:55 -0400 Subject: [PATCH 2/3] Improve test isolation - Add afterEach to restore all mocks - Remove unnecessary mockClear() calls - Ensures tests are properly isolated --- src/server/auth/handlers/authorize.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/server/auth/handlers/authorize.test.ts b/src/server/auth/handlers/authorize.test.ts index 50127afc..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) @@ -305,7 +309,6 @@ describe('Authorization Handler', () => { it('propagates nonce parameter for OpenID Connect flows', async () => { const mockProviderWithNonce = jest.spyOn(mockProvider, 'authorize'); - mockProviderWithNonce.mockClear(); const response = await supertest(app) .get('/authorize') @@ -334,7 +337,6 @@ describe('Authorization Handler', () => { it('handles authorization without nonce parameter', async () => { const mockProviderWithoutNonce = jest.spyOn(mockProvider, 'authorize'); - mockProviderWithoutNonce.mockClear(); const response = await supertest(app) .get('/authorize') From e85e8063253c4b267d8deb11534976651b3283ad Mon Sep 17 00:00:00 2001 From: Ryan Slama Date: Tue, 15 Jul 2025 14:01:04 -0400 Subject: [PATCH 3/3] Add nonce validation for OpenID Connect flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements proper nonce validation to prevent replay attacks when using OpenID Connect (scope includes 'openid'). Also adds audience validation for additional security. - Automatically generates nonce for OIDC flows - Validates nonce in ID tokens during token exchange - Validates audience (aud) claim matches client_id - Adds optional saveNonce/nonce methods to provider interface - Uses inline JWT decoder for better compatibility - Includes comprehensive test coverage Note: startAuthorization() now returns an optional nonce field when scope includes 'openid'. This is backward compatible for JavaScript users but may require TypeScript users to update explicit type annotations. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- src/client/auth.test.ts | 262 +++++++++++++++++++++-- src/client/auth.ts | 82 ++++++- src/examples/client/simpleOAuthClient.ts | 9 + 3 files changed, 336 insertions(+), 17 deletions(-) diff --git a/src/client/auth.test.ts b/src/client/auth.test.ts index 8d173868..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 () => { @@ -773,20 +777,6 @@ describe("OAuth Authorization", () => { expect(authorizationUrl.searchParams.has("nonce")).toBe(false); }); - it("generates nonce when openid scope is included with other scopes", async () => { - const { authorizationUrl, nonce } = await startAuthorization( - "https://auth.example.com", - { - clientInformation: validClientInfo, - redirectUrl: "http://localhost:3000/callback", - scope: "read openid write profile", - } - ); - - expect(nonce).toBeDefined(); - expect(authorizationUrl.searchParams.get("nonce")).toBe(nonce); - }); - it("uses metadata authorization_endpoint when provided", async () => { const { authorizationUrl } = await startAuthorization( "https://auth.example.com", @@ -975,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 6fd2f789..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"; } @@ -643,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, @@ -658,6 +713,7 @@ export async function exchangeAuthorization( codeVerifier, redirectUri, resource, + nonce, addClientAuthentication }: { metadata?: OAuthMetadata; @@ -666,6 +722,7 @@ export async function exchangeAuthorization( codeVerifier: string; redirectUri: string | URL; resource?: URL; + nonce?: string; addClientAuthentication?: OAuthClientProvider["addClientAuthentication"]; }, ): Promise { @@ -719,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