Skip to content

Commit d27bb98

Browse files
fix(client): allow custom claims to override reserved JWT claims in createPrivateKeyJwtAuth
Remove redundant jose setter calls (.setIssuer, .setSubject, .setAudience, .setIssuedAt, .setExpirationTime, .setJti) that silently overwrote values from options.claims after they had been merged into the claims object. The six reserved claims are already present in the merged object via baseClaims, so the setter calls were redundant for the default case and harmful when users provided overrides -- contradicting the documented "custom claims taking precedence" contract. Fixes #1914
1 parent b8886e7 commit d27bb98

3 files changed

Lines changed: 44 additions & 6 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@modelcontextprotocol/client": patch
3+
---
4+
5+
Fix custom claims not overriding reserved JWT claims in createPrivateKeyJwtAuth
6+
7+
Remove redundant jose setter calls (setIssuer, setSubject, setAudience, setIssuedAt,
8+
setExpirationTime, setJti) that silently overwrote user-provided custom claims, making
9+
the runtime behavior match the documented contract that custom claims take precedence
10+
for overlapping keys.

packages/client/src/client/authExtensions.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -79,14 +79,13 @@ export function createPrivateKeyJwtAuth(options: {
7979
}
8080

8181
// Sign JWT
82+
// All six reserved claims (iss, sub, aud, iat, exp, jti) are already
83+
// present in `claims` via `baseClaims`, and custom overrides from
84+
// `options.claims` are merged on top. We intentionally do NOT call
85+
// the jose setter helpers here so the documented "custom claims take
86+
// precedence" contract is honored.
8287
const assertion = await new jose.SignJWT(claims)
8388
.setProtectedHeader({ alg, typ: 'JWT' })
84-
.setIssuer(options.issuer)
85-
.setSubject(options.subject)
86-
.setAudience(audience)
87-
.setIssuedAt(now)
88-
.setExpirationTime(now + lifetimeSeconds)
89-
.setJti(jti)
9089
.sign(key as unknown as Uint8Array | CryptoKey);
9190

9291
params.set('client_assertion', assertion);

packages/client/test/client/authExtensions.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,35 @@ describe('createPrivateKeyJwtAuth', () => {
448448
expect(decoded.sub).toBe('client-id');
449449
});
450450

451+
it('allows custom claims to override reserved JWT claims', async () => {
452+
const addClientAuth = createPrivateKeyJwtAuth({
453+
issuer: 'client-id',
454+
subject: 'client-id',
455+
privateKey: 'a-string-secret-at-least-256-bits-long',
456+
alg: 'HS256',
457+
audience: 'https://aud.example.com',
458+
claims: {
459+
iss: 'override-issuer',
460+
sub: 'override-subject',
461+
aud: 'https://override.example.com',
462+
tenant_id: 'org-123'
463+
}
464+
});
465+
466+
const params = new URLSearchParams();
467+
await addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined);
468+
469+
const assertion = params.get('client_assertion');
470+
expect(assertion).toBeTruthy();
471+
472+
const jose = await import('jose');
473+
const decoded = jose.decodeJwt(assertion!);
474+
expect(decoded.iss).toBe('override-issuer');
475+
expect(decoded.sub).toBe('override-subject');
476+
expect(decoded.aud).toBe('https://override.example.com');
477+
expect(decoded.tenant_id).toBe('org-123');
478+
});
479+
451480
it('passes custom claims through PrivateKeyJwtProvider', async () => {
452481
const provider = new PrivateKeyJwtProvider({
453482
clientId: 'client-id',

0 commit comments

Comments
 (0)