diff --git a/.changeset/fix-custom-claims-override.md b/.changeset/fix-custom-claims-override.md new file mode 100644 index 000000000..c1158d5d8 --- /dev/null +++ b/.changeset/fix-custom-claims-override.md @@ -0,0 +1,10 @@ +--- +"@modelcontextprotocol/client": patch +--- + +Fix custom claims not overriding reserved JWT claims in createPrivateKeyJwtAuth + +Remove redundant jose setter calls (setIssuer, setSubject, setAudience, setIssuedAt, +setExpirationTime, setJti) that silently overwrote user-provided custom claims, making +the runtime behavior match the documented contract that custom claims take precedence +for overlapping keys. diff --git a/packages/client/src/client/authExtensions.ts b/packages/client/src/client/authExtensions.ts index cb476c12f..6148a2ce6 100644 --- a/packages/client/src/client/authExtensions.ts +++ b/packages/client/src/client/authExtensions.ts @@ -79,14 +79,13 @@ export function createPrivateKeyJwtAuth(options: { } // Sign JWT + // All six reserved claims (iss, sub, aud, iat, exp, jti) are already + // present in `claims` via `baseClaims`, and custom overrides from + // `options.claims` are merged on top. We intentionally do NOT call + // the jose setter helpers here so the documented "custom claims take + // precedence" contract is honored. const assertion = await new jose.SignJWT(claims) .setProtectedHeader({ alg, typ: 'JWT' }) - .setIssuer(options.issuer) - .setSubject(options.subject) - .setAudience(audience) - .setIssuedAt(now) - .setExpirationTime(now + lifetimeSeconds) - .setJti(jti) .sign(key as unknown as Uint8Array | CryptoKey); params.set('client_assertion', assertion); diff --git a/packages/client/test/client/authExtensions.test.ts b/packages/client/test/client/authExtensions.test.ts index 16c3ea33e..9e6eb9b62 100644 --- a/packages/client/test/client/authExtensions.test.ts +++ b/packages/client/test/client/authExtensions.test.ts @@ -448,6 +448,35 @@ describe('createPrivateKeyJwtAuth', () => { expect(decoded.sub).toBe('client-id'); }); + it('allows custom claims to override reserved JWT claims', async () => { + const addClientAuth = createPrivateKeyJwtAuth({ + issuer: 'client-id', + subject: 'client-id', + privateKey: 'a-string-secret-at-least-256-bits-long', + alg: 'HS256', + audience: 'https://aud.example.com', + claims: { + iss: 'override-issuer', + sub: 'override-subject', + aud: 'https://override.example.com', + tenant_id: 'org-123' + } + }); + + const params = new URLSearchParams(); + await addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined); + + const assertion = params.get('client_assertion'); + expect(assertion).toBeTruthy(); + + const jose = await import('jose'); + const decoded = jose.decodeJwt(assertion!); + expect(decoded.iss).toBe('override-issuer'); + expect(decoded.sub).toBe('override-subject'); + expect(decoded.aud).toBe('https://override.example.com'); + expect(decoded.tenant_id).toBe('org-123'); + }); + it('passes custom claims through PrivateKeyJwtProvider', async () => { const provider = new PrivateKeyJwtProvider({ clientId: 'client-id',