Visitar URL original
feat(auth): add JWT-bearer helper for WIF client conformance (SEP-1933) · coding-bobo/conformance@7e41fee · GitHub
Skip to content

Commit 7e41fee

Browse files
coding-boboclaude
andcommitted
feat(auth): add JWT-bearer helper for WIF client conformance (SEP-1933)
Adds createWorkloadJwt and generateWorkloadKeypair to provide reusable, tested JWT signing infrastructure for the upcoming wif-jwt-bearer scenario (PR modelcontextprotocol#2). Also extracts JWT_BEARER_GRANT_TYPE constant and migrates cross-app-access.ts to use it. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 0371b18 commit 7e41fee

3 files changed

Lines changed: 278 additions & 2 deletions

File tree

‎src/scenarios/client/auth/enterprise-managed-authorization.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { CryptoKey } from 'jose';
33
import express, { type Request, type Response } from 'express';
44
import type { Scenario, ConformanceCheck, ScenarioUrls } from '../../../types';
55
import { createAuthServer } from './helpers/createAuthServer';
6+
import { JWT_BEARER_GRANT_TYPE } from './helpers/createWorkloadJwt.js';
67
import { createServer } from './helpers/createServer';
78
import { MockTokenVerifier } from './helpers/mockTokenVerifier';
89
import { ServerLifecycle } from './helpers/serverLifecycle';
@@ -87,7 +88,7 @@ export class EnterpriseManagedAuthorizationScenario implements Scenario {
8788
// Start auth server with JWT bearer grant support only
8889
// Token exchange is handled by IdP
8990
const authApp = createAuthServer(this.checks, this.authServer.getUrl, {
90-
grantTypesSupported: ['urn:ietf:params:oauth:grant-type:jwt-bearer'],
91+
grantTypesSupported: [JWT_BEARER_GRANT_TYPE],
9192
tokenEndpointAuthMethodsSupported: ['client_secret_basic'],
9293
tokenVerifier,
9394
onTokenRequest: async ({
@@ -98,7 +99,7 @@ export class EnterpriseManagedAuthorizationScenario implements Scenario {
9899
authorizationHeader
99100
}) => {
100101
// Auth server only handles JWT bearer grant (ID-JAG -> access token)
101-
if (grantType === 'urn:ietf:params:oauth:grant-type:jwt-bearer') {
102+
if (grantType === JWT_BEARER_GRANT_TYPE) {
102103
const mcpResourceUrl = `${this.mcpServer.getUrl()}/mcp`;
103104
return await this.handleJwtBearerGrant(
104105
body,
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import * as jose from 'jose';
2+
import { describe, it, expect } from 'vitest';
3+
import {
4+
JWT_BEARER_GRANT_TYPE,
5+
DEFAULT_WORKLOAD_JWT_ALG,
6+
createWorkloadJwt,
7+
generateWorkloadKeypair
8+
} from './createWorkloadJwt.js';
9+
10+
describe('constants', () => {
11+
it('JWT_BEARER_GRANT_TYPE matches the IANA-registered URN format', () => {
12+
expect(JWT_BEARER_GRANT_TYPE).toMatch(/^urn:ietf:params:oauth:grant-type:/);
13+
expect(JWT_BEARER_GRANT_TYPE).toBe(
14+
'urn:ietf:params:oauth:grant-type:jwt-bearer'
15+
);
16+
});
17+
18+
it('DEFAULT_WORKLOAD_JWT_ALG is ES256', () => {
19+
expect(DEFAULT_WORKLOAD_JWT_ALG).toBe('ES256');
20+
});
21+
});
22+
23+
describe('generateWorkloadKeypair', () => {
24+
it('returns an ES256 keypair with PEM and JWK', async () => {
25+
const kp = await generateWorkloadKeypair();
26+
expect(kp.privateKeyPem).toMatch(/^-----BEGIN PRIVATE KEY-----/);
27+
expect(kp.publicJwk.kty).toBe('EC');
28+
expect(kp.publicJwk.crv).toBe('P-256');
29+
expect(kp.publicKey).toBeDefined();
30+
expect(kp.privateKey).toBeDefined();
31+
});
32+
33+
it('uses the specified algorithm', async () => {
34+
const kp = await generateWorkloadKeypair('RS256');
35+
expect(kp.publicJwk.kty).toBe('RSA');
36+
});
37+
});
38+
39+
describe('createWorkloadJwt', () => {
40+
it('produces a verifiable JWT with all standard claims', async () => {
41+
const kp = await generateWorkloadKeypair();
42+
const token = await createWorkloadJwt({
43+
issuer: 'https://issuer.example',
44+
subject: 'system:serviceaccount:prod:my-app',
45+
audience: 'https://as.example/token',
46+
privateKey: kp.privateKey
47+
});
48+
49+
const { payload } = await jose.jwtVerify(token, kp.publicKey, {
50+
issuer: 'https://issuer.example',
51+
audience: 'https://as.example/token'
52+
});
53+
54+
expect(payload.iss).toBe('https://issuer.example');
55+
expect(payload.sub).toBe('system:serviceaccount:prod:my-app');
56+
expect(payload.aud).toBe('https://as.example/token');
57+
expect(typeof payload.exp).toBe('number');
58+
expect(typeof payload.iat).toBe('number');
59+
expect(typeof payload.jti).toBe('string');
60+
});
61+
62+
it('defaults exp to approximately 5 minutes after iat', async () => {
63+
const kp = await generateWorkloadKeypair();
64+
const before = Math.floor(Date.now() / 1000);
65+
const token = await createWorkloadJwt({
66+
issuer: 'https://issuer.example',
67+
subject: 'workload',
68+
audience: 'https://as.example/token',
69+
privateKey: kp.privateKey
70+
});
71+
const after = Math.floor(Date.now() / 1000);
72+
73+
const { payload } = await jose.jwtVerify(token, kp.publicKey);
74+
const lifetime = (payload.exp as number) - (payload.iat as number);
75+
expect(lifetime).toBeGreaterThanOrEqual(5 * 60 - 2);
76+
expect(lifetime).toBeLessThanOrEqual(5 * 60 + 2);
77+
expect(payload.iat as number).toBeGreaterThanOrEqual(before);
78+
expect(payload.iat as number).toBeLessThanOrEqual(after);
79+
});
80+
81+
it('accepts a numeric absolute epoch as expiresIn for already-expired tokens', async () => {
82+
const kp = await generateWorkloadKeypair();
83+
const pastExp = Math.floor(Date.now() / 1000) - 60;
84+
const token = await createWorkloadJwt({
85+
issuer: 'https://issuer.example',
86+
subject: 'workload',
87+
audience: 'https://as.example/token',
88+
privateKey: kp.privateKey,
89+
expiresIn: pastExp
90+
});
91+
92+
const payload = jose.decodeJwt(token);
93+
expect(payload.exp).toBe(pastExp);
94+
await expect(jose.jwtVerify(token, kp.publicKey)).rejects.toThrow();
95+
});
96+
97+
it('generates a unique jti on each call with identical inputs', async () => {
98+
const kp = await generateWorkloadKeypair();
99+
const opts = {
100+
issuer: 'https://issuer.example',
101+
subject: 'workload',
102+
audience: 'https://as.example/token',
103+
privateKey: kp.privateKey
104+
};
105+
const t1 = await createWorkloadJwt(opts);
106+
const t2 = await createWorkloadJwt(opts);
107+
108+
const { payload: p1 } = await jose.jwtVerify(t1, kp.publicKey);
109+
const { payload: p2 } = await jose.jwtVerify(t2, kp.publicKey);
110+
expect(p1.jti).not.toBe(p2.jti);
111+
});
112+
113+
it('preserves an array audience as an array', async () => {
114+
const kp = await generateWorkloadKeypair();
115+
const token = await createWorkloadJwt({
116+
issuer: 'https://issuer.example',
117+
subject: 'workload',
118+
audience: ['https://as.example/token', 'https://other.example'],
119+
privateKey: kp.privateKey
120+
});
121+
122+
const { payload } = await jose.jwtVerify(token, kp.publicKey, {
123+
audience: 'https://as.example/token'
124+
});
125+
expect(Array.isArray(payload.aud)).toBe(true);
126+
expect(payload.aud).toContain('https://as.example/token');
127+
expect(payload.aud).toContain('https://other.example');
128+
});
129+
130+
it('merges additionalClaims without overriding reserved claims', async () => {
131+
const kp = await generateWorkloadKeypair();
132+
const token = await createWorkloadJwt({
133+
issuer: 'https://issuer.example',
134+
subject: 'workload',
135+
audience: 'https://as.example/token',
136+
privateKey: kp.privateKey,
137+
additionalClaims: {
138+
custom: 'value',
139+
iss: 'should-be-ignored',
140+
sub: 'should-be-ignored'
141+
}
142+
});
143+
144+
const { payload } = await jose.jwtVerify(token, kp.publicKey);
145+
expect(payload.custom).toBe('value');
146+
expect(payload.iss).toBe('https://issuer.example');
147+
expect(payload.sub).toBe('workload');
148+
});
149+
150+
it('allows caller-supplied jwtId', async () => {
151+
const kp = await generateWorkloadKeypair();
152+
const token = await createWorkloadJwt({
153+
issuer: 'https://issuer.example',
154+
subject: 'workload',
155+
audience: 'https://as.example/token',
156+
privateKey: kp.privateKey,
157+
jwtId: 'fixed-jti-for-replay-test'
158+
});
159+
160+
const { payload } = await jose.jwtVerify(token, kp.publicKey);
161+
expect(payload.jti).toBe('fixed-jti-for-replay-test');
162+
});
163+
164+
it('sets notBefore when specified', async () => {
165+
const kp = await generateWorkloadKeypair();
166+
const nbf = Math.floor(Date.now() / 1000) + 3600;
167+
const token = await createWorkloadJwt({
168+
issuer: 'https://issuer.example',
169+
subject: 'workload',
170+
audience: 'https://as.example/token',
171+
privateKey: kp.privateKey,
172+
notBefore: nbf
173+
});
174+
175+
const payload = jose.decodeJwt(token);
176+
expect(payload.nbf).toBe(nbf);
177+
});
178+
179+
it('uses the specified algorithm when signing', async () => {
180+
const kp = await generateWorkloadKeypair('RS256');
181+
const token = await createWorkloadJwt({
182+
issuer: 'https://issuer.example',
183+
subject: 'workload',
184+
audience: 'https://as.example/token',
185+
privateKey: kp.privateKey,
186+
algorithm: 'RS256'
187+
});
188+
189+
const { payload } = await jose.jwtVerify(token, kp.publicKey, {
190+
algorithms: ['RS256']
191+
});
192+
expect(payload.iss).toBe('https://issuer.example');
193+
});
194+
});
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import * as jose from 'jose';
2+
3+
export const JWT_BEARER_GRANT_TYPE =
4+
'urn:ietf:params:oauth:grant-type:jwt-bearer';
5+
export const DEFAULT_WORKLOAD_JWT_ALG = 'ES256';
6+
7+
export interface CreateWorkloadJwtOptions {
8+
issuer: string;
9+
subject: string;
10+
audience: string | string[];
11+
privateKey: jose.CryptoKey;
12+
/** Jose duration string (e.g. '5m') or absolute epoch seconds. Use a number to construct already-expired tokens for negative tests. */
13+
expiresIn?: string | number;
14+
jwtId?: string;
15+
issuedAt?: number;
16+
notBefore?: number;
17+
algorithm?: string;
18+
additionalClaims?: Record<string, unknown>;
19+
}
20+
21+
export async function createWorkloadJwt(
22+
opts: CreateWorkloadJwtOptions
23+
): Promise<string> {
24+
const {
25+
issuer,
26+
subject,
27+
audience,
28+
privateKey,
29+
expiresIn = '5m',
30+
jwtId = crypto.randomUUID(),
31+
issuedAt,
32+
notBefore,
33+
algorithm = DEFAULT_WORKLOAD_JWT_ALG,
34+
additionalClaims
35+
} = opts;
36+
37+
// additionalClaims are merged first; reserved claims set via builder methods
38+
// overwrite any same-named key already in the payload, so callers cannot
39+
// accidentally override iss/sub/aud/exp/iat/jti via additionalClaims.
40+
const extra: Record<string, unknown> = additionalClaims
41+
? { ...additionalClaims }
42+
: {};
43+
44+
let builder = new jose.SignJWT(extra)
45+
.setProtectedHeader({ alg: algorithm })
46+
.setIssuer(issuer)
47+
.setSubject(subject)
48+
.setAudience(audience)
49+
.setExpirationTime(expiresIn)
50+
.setJti(jwtId);
51+
52+
if (issuedAt !== undefined) {
53+
builder = builder.setIssuedAt(issuedAt);
54+
} else {
55+
builder = builder.setIssuedAt();
56+
}
57+
58+
if (notBefore !== undefined) {
59+
builder = builder.setNotBefore(notBefore);
60+
}
61+
62+
return builder.sign(privateKey);
63+
}
64+
65+
export interface WorkloadKeypair {
66+
publicKey: jose.CryptoKey;
67+
privateKey: jose.CryptoKey;
68+
privateKeyPem: string;
69+
publicJwk: jose.JWK;
70+
}
71+
72+
export async function generateWorkloadKeypair(
73+
alg: string = DEFAULT_WORKLOAD_JWT_ALG
74+
): Promise<WorkloadKeypair> {
75+
const { publicKey, privateKey } = await jose.generateKeyPair(alg, {
76+
extractable: true
77+
});
78+
const privateKeyPem = await jose.exportPKCS8(privateKey);
79+
const publicJwk = await jose.exportJWK(publicKey);
80+
return { publicKey, privateKey, privateKeyPem, publicJwk };
81+
}

0 commit comments

Comments
 (0)