Skip to content

Commit 5f99239

Browse files
committed
add StaticPrivateKeyJwtProvider, add tests, consolidate tests in auth-extensions.ts
1 parent 86ee089 commit 5f99239

File tree

3 files changed

+448
-159
lines changed

3 files changed

+448
-159
lines changed

src/client/auth-extensions.test.ts

Lines changed: 351 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,351 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { auth } from './auth.js';
3+
import {
4+
ClientCredentialsProvider,
5+
PrivateKeyJwtProvider,
6+
StaticPrivateKeyJwtProvider,
7+
createPrivateKeyJwtAuth
8+
} from './auth-extensions.js';
9+
import type { FetchLike } from '../shared/transport.js';
10+
11+
const RESOURCE_SERVER_URL = 'https://resource.example.com/';
12+
const AUTH_SERVER_URL = 'https://auth.example.com';
13+
14+
function createMockFetch(onTokenRequest?: (url: URL, init: RequestInit | undefined) => void | Promise<void>): FetchLike {
15+
return async (input: string | URL, init?: RequestInit): Promise<Response> => {
16+
const url = input instanceof URL ? input : new URL(input);
17+
18+
// Protected resource metadata discovery
19+
if (url.origin === RESOURCE_SERVER_URL.slice(0, -1) && url.pathname === '/.well-known/oauth-protected-resource') {
20+
return new Response(
21+
JSON.stringify({
22+
resource: RESOURCE_SERVER_URL,
23+
authorization_servers: [AUTH_SERVER_URL]
24+
}),
25+
{
26+
status: 200,
27+
headers: { 'Content-Type': 'application/json' }
28+
}
29+
);
30+
}
31+
32+
// Authorization server metadata discovery
33+
if (url.origin === AUTH_SERVER_URL && url.pathname === '/.well-known/oauth-authorization-server') {
34+
return new Response(
35+
JSON.stringify({
36+
issuer: AUTH_SERVER_URL,
37+
authorization_endpoint: `${AUTH_SERVER_URL}/authorize`,
38+
token_endpoint: `${AUTH_SERVER_URL}/token`,
39+
response_types_supported: ['code'],
40+
token_endpoint_auth_methods_supported: ['client_secret_basic', 'private_key_jwt']
41+
}),
42+
{
43+
status: 200,
44+
headers: { 'Content-Type': 'application/json' }
45+
}
46+
);
47+
}
48+
49+
// Token endpoint
50+
if (url.origin === AUTH_SERVER_URL && url.pathname === '/token') {
51+
if (onTokenRequest) {
52+
await onTokenRequest(url, init);
53+
}
54+
55+
return new Response(
56+
JSON.stringify({
57+
access_token: 'test-access-token',
58+
token_type: 'Bearer'
59+
}),
60+
{
61+
status: 200,
62+
headers: { 'Content-Type': 'application/json' }
63+
}
64+
);
65+
}
66+
67+
throw new Error(`Unexpected URL in mock fetch: ${url.toString()}`);
68+
};
69+
}
70+
71+
describe('auth-extensions providers (end-to-end with auth())', () => {
72+
it('authenticates using ClientCredentialsProvider with client_secret_basic', async () => {
73+
const provider = new ClientCredentialsProvider({
74+
clientId: 'my-client',
75+
clientSecret: 'my-secret',
76+
clientName: 'test-client'
77+
});
78+
79+
const fetchMock = createMockFetch(async (_url, init) => {
80+
const params = init?.body as URLSearchParams;
81+
expect(params).toBeInstanceOf(URLSearchParams);
82+
expect(params.get('grant_type')).toBe('client_credentials');
83+
expect(params.get('resource')).toBe(RESOURCE_SERVER_URL);
84+
expect(params.get('client_assertion')).toBeNull();
85+
86+
const headers = new Headers(init?.headers);
87+
const authHeader = headers.get('Authorization');
88+
expect(authHeader).toBeTruthy();
89+
90+
const expectedCredentials = Buffer.from('my-client:my-secret').toString('base64');
91+
expect(authHeader).toBe(`Basic ${expectedCredentials}`);
92+
});
93+
94+
const result = await auth(provider, {
95+
serverUrl: RESOURCE_SERVER_URL,
96+
fetchFn: fetchMock
97+
});
98+
99+
expect(result).toBe('AUTHORIZED');
100+
const tokens = provider.tokens();
101+
expect(tokens).toBeTruthy();
102+
expect(tokens?.access_token).toBe('test-access-token');
103+
});
104+
105+
it('authenticates using PrivateKeyJwtProvider with private_key_jwt', async () => {
106+
const provider = new PrivateKeyJwtProvider({
107+
clientId: 'client-id',
108+
privateKey: 'a-string-secret-at-least-256-bits-long',
109+
algorithm: 'HS256',
110+
clientName: 'private-key-jwt-client'
111+
});
112+
113+
let assertionFromRequest: string | null = null;
114+
115+
const fetchMock = createMockFetch(async (_url, init) => {
116+
const params = init?.body as URLSearchParams;
117+
expect(params).toBeInstanceOf(URLSearchParams);
118+
expect(params.get('grant_type')).toBe('client_credentials');
119+
expect(params.get('resource')).toBe(RESOURCE_SERVER_URL);
120+
121+
assertionFromRequest = params.get('client_assertion');
122+
expect(assertionFromRequest).toBeTruthy();
123+
expect(params.get('client_assertion_type')).toBe('urn:ietf:params:oauth:client-assertion-type:jwt-bearer');
124+
125+
const parts = assertionFromRequest!.split('.');
126+
expect(parts).toHaveLength(3);
127+
128+
const headers = new Headers(init?.headers);
129+
expect(headers.get('Authorization')).toBeNull();
130+
});
131+
132+
const result = await auth(provider, {
133+
serverUrl: RESOURCE_SERVER_URL,
134+
fetchFn: fetchMock
135+
});
136+
137+
expect(result).toBe('AUTHORIZED');
138+
const tokens = provider.tokens();
139+
expect(tokens).toBeTruthy();
140+
expect(tokens?.access_token).toBe('test-access-token');
141+
expect(assertionFromRequest).toBeTruthy();
142+
});
143+
144+
it('fails when PrivateKeyJwtProvider is configured with an unsupported algorithm', async () => {
145+
const provider = new PrivateKeyJwtProvider({
146+
clientId: 'client-id',
147+
privateKey: 'a-string-secret-at-least-256-bits-long',
148+
algorithm: 'none',
149+
clientName: 'private-key-jwt-client'
150+
});
151+
152+
const fetchMock = createMockFetch();
153+
154+
await expect(
155+
auth(provider, {
156+
serverUrl: RESOURCE_SERVER_URL,
157+
fetchFn: fetchMock
158+
})
159+
).rejects.toThrow('Unsupported algorithm none');
160+
});
161+
162+
it('authenticates using StaticPrivateKeyJwtProvider with static client assertion', async () => {
163+
const staticAssertion = 'header.payload.signature';
164+
165+
const provider = new StaticPrivateKeyJwtProvider({
166+
clientId: 'static-client',
167+
jwtBearerAssertion: staticAssertion,
168+
clientName: 'static-private-key-jwt-client'
169+
});
170+
171+
const fetchMock = createMockFetch(async (_url, init) => {
172+
const params = init?.body as URLSearchParams;
173+
expect(params).toBeInstanceOf(URLSearchParams);
174+
expect(params.get('grant_type')).toBe('client_credentials');
175+
expect(params.get('resource')).toBe(RESOURCE_SERVER_URL);
176+
177+
expect(params.get('client_assertion')).toBe(staticAssertion);
178+
expect(params.get('client_assertion_type')).toBe('urn:ietf:params:oauth:client-assertion-type:jwt-bearer');
179+
180+
const headers = new Headers(init?.headers);
181+
expect(headers.get('Authorization')).toBeNull();
182+
});
183+
184+
const result = await auth(provider, {
185+
serverUrl: RESOURCE_SERVER_URL,
186+
fetchFn: fetchMock
187+
});
188+
189+
expect(result).toBe('AUTHORIZED');
190+
const tokens = provider.tokens();
191+
expect(tokens).toBeTruthy();
192+
expect(tokens?.access_token).toBe('test-access-token');
193+
});
194+
});
195+
196+
describe('createPrivateKeyJwtAuth', () => {
197+
const baseOptions = {
198+
issuer: 'client-id',
199+
subject: 'client-id',
200+
privateKey: 'a-string-secret-at-least-256-bits-long',
201+
alg: 'HS256'
202+
};
203+
204+
it('creates an addClientAuthentication function that sets JWT assertion params', async () => {
205+
const addClientAuth = createPrivateKeyJwtAuth(baseOptions);
206+
207+
const headers = new Headers();
208+
const params = new URLSearchParams();
209+
210+
await addClientAuth(headers, params, 'https://auth.example.com/token', undefined);
211+
212+
expect(params.get('client_assertion')).toBeTruthy();
213+
expect(params.get('client_assertion_type')).toBe('urn:ietf:params:oauth:client-assertion-type:jwt-bearer');
214+
215+
// Verify JWT structure (three dot-separated segments)
216+
const assertion = params.get('client_assertion')!;
217+
const parts = assertion.split('.');
218+
expect(parts).toHaveLength(3);
219+
});
220+
221+
it('creates a signed JWT when using a Uint8Array HMAC key', async () => {
222+
const secret = new TextEncoder().encode('a-string-secret-at-least-256-bits-long');
223+
224+
const addClientAuth = createPrivateKeyJwtAuth({
225+
issuer: 'client-id',
226+
subject: 'client-id',
227+
privateKey: secret,
228+
alg: 'HS256'
229+
});
230+
231+
const params = new URLSearchParams();
232+
await addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined);
233+
234+
const assertion = params.get('client_assertion')!;
235+
const parts = assertion.split('.');
236+
expect(parts).toHaveLength(3);
237+
});
238+
239+
it('creates a signed JWT when using a symmetric JWK key', async () => {
240+
const jwk: Record<string, unknown> = {
241+
kty: 'oct',
242+
// "a-string-secret-at-least-256-bits-long" base64url-encoded
243+
k: 'YS1zdHJpbmctc2VjcmV0LWF0LWxlYXN0LTI1Ni1iaXRzLWxvbmc',
244+
alg: 'HS256'
245+
};
246+
247+
const addClientAuth = createPrivateKeyJwtAuth({
248+
issuer: 'client-id',
249+
subject: 'client-id',
250+
privateKey: jwk,
251+
alg: 'HS256'
252+
});
253+
254+
const params = new URLSearchParams();
255+
await addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined);
256+
257+
const assertion = params.get('client_assertion')!;
258+
const parts = assertion.split('.');
259+
expect(parts).toHaveLength(3);
260+
});
261+
262+
it('creates a signed JWT when using an RSA PEM private key', async () => {
263+
// Generate an RSA key pair on the fly
264+
const jose = await import('jose');
265+
const { privateKey } = await jose.generateKeyPair('RS256', { extractable: true });
266+
const pem = await jose.exportPKCS8(privateKey);
267+
268+
const addClientAuth = createPrivateKeyJwtAuth({
269+
issuer: 'client-id',
270+
subject: 'client-id',
271+
privateKey: pem,
272+
alg: 'RS256'
273+
});
274+
275+
const params = new URLSearchParams();
276+
await addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined);
277+
278+
const assertion = params.get('client_assertion')!;
279+
const parts = assertion.split('.');
280+
expect(parts).toHaveLength(3);
281+
});
282+
283+
it('uses metadata.issuer as audience when available', async () => {
284+
const addClientAuth = createPrivateKeyJwtAuth(baseOptions);
285+
286+
const params = new URLSearchParams();
287+
await addClientAuth(new Headers(), params, 'https://auth.example.com/token', {
288+
issuer: 'https://issuer.example.com',
289+
authorization_endpoint: 'https://auth.example.com/authorize',
290+
token_endpoint: 'https://auth.example.com/token',
291+
response_types_supported: ['code']
292+
});
293+
294+
const assertion = params.get('client_assertion')!;
295+
// Decode the payload to verify audience
296+
const [, payloadB64] = assertion.split('.');
297+
const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString());
298+
expect(payload.aud).toBe('https://issuer.example.com');
299+
});
300+
301+
it('throws when using an unsupported algorithm', async () => {
302+
const addClientAuth = createPrivateKeyJwtAuth({
303+
issuer: 'client-id',
304+
subject: 'client-id',
305+
privateKey: 'a-string-secret-at-least-256-bits-long',
306+
alg: 'none'
307+
});
308+
309+
const params = new URLSearchParams();
310+
await expect(addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined)).rejects.toThrow(
311+
'Unsupported algorithm none'
312+
);
313+
});
314+
315+
it('throws when jose cannot import an invalid RSA PEM key', async () => {
316+
const badPem = '-----BEGIN PRIVATE KEY-----\nnot-a-valid-key\n-----END PRIVATE KEY-----';
317+
318+
const addClientAuth = createPrivateKeyJwtAuth({
319+
issuer: 'client-id',
320+
subject: 'client-id',
321+
privateKey: badPem,
322+
alg: 'RS256'
323+
});
324+
325+
const params = new URLSearchParams();
326+
await expect(addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined)).rejects.toThrow(
327+
/Invalid character/
328+
);
329+
});
330+
331+
it('throws when jose cannot import a mismatched JWK key', async () => {
332+
const jwk: Record<string, unknown> = {
333+
kty: 'oct',
334+
k: 'c2VjcmV0LWtleQ', // "secret-key" base64url
335+
alg: 'HS256'
336+
};
337+
338+
const addClientAuth = createPrivateKeyJwtAuth({
339+
issuer: 'client-id',
340+
subject: 'client-id',
341+
privateKey: jwk,
342+
// Ask for an RSA algorithm with an octet key, which should cause jose.importJWK to fail
343+
alg: 'RS256'
344+
});
345+
346+
const params = new URLSearchParams();
347+
await expect(addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined)).rejects.toThrow(
348+
/Key for the RS256 algorithm must be one of type CryptoKey, KeyObject, or JSON Web Key/
349+
);
350+
});
351+
});

0 commit comments

Comments
 (0)