import { describe, it, expect } from 'vitest'; import { auth } from '../../src/client/auth.js'; import { ClientCredentialsProvider, PrivateKeyJwtProvider, StaticPrivateKeyJwtProvider, createPrivateKeyJwtAuth } from '../../src/client/auth-extensions.js'; import { createMockOAuthFetch } from '../helpers/oauth.js'; const RESOURCE_SERVER_URL = 'https://resource.example.com/'; const AUTH_SERVER_URL = 'https://auth.example.com'; describe('auth-extensions providers (end-to-end with auth())', () => { it('authenticates using ClientCredentialsProvider with client_secret_basic', async () => { const provider = new ClientCredentialsProvider({ clientId: 'my-client', clientSecret: 'my-secret', clientName: 'test-client' }); const fetchMock = createMockOAuthFetch({ resourceServerUrl: RESOURCE_SERVER_URL, authServerUrl: AUTH_SERVER_URL, onTokenRequest: async (_url, init) => { const params = init?.body as URLSearchParams; expect(params).toBeInstanceOf(URLSearchParams); expect(params.get('grant_type')).toBe('client_credentials'); expect(params.get('resource')).toBe(RESOURCE_SERVER_URL); expect(params.get('client_assertion')).toBeNull(); const headers = new Headers(init?.headers); const authHeader = headers.get('Authorization'); expect(authHeader).toBeTruthy(); const expectedCredentials = Buffer.from('my-client:my-secret').toString('base64'); expect(authHeader).toBe(`Basic ${expectedCredentials}`); } }); const result = await auth(provider, { serverUrl: RESOURCE_SERVER_URL, fetchFn: fetchMock }); expect(result).toBe('AUTHORIZED'); const tokens = provider.tokens(); expect(tokens).toBeTruthy(); expect(tokens?.access_token).toBe('test-access-token'); }); it('sends scope in token request when ClientCredentialsProvider is configured with scope', async () => { const provider = new ClientCredentialsProvider({ clientId: 'my-client', clientSecret: 'my-secret', clientName: 'test-client', scope: 'read write' }); expect(provider.clientMetadata.scope).toBe('read write'); const fetchMock = createMockOAuthFetch({ resourceServerUrl: RESOURCE_SERVER_URL, authServerUrl: AUTH_SERVER_URL, onTokenRequest: async (_url, init) => { const params = init?.body as URLSearchParams; expect(params).toBeInstanceOf(URLSearchParams); expect(params.get('grant_type')).toBe('client_credentials'); expect(params.get('scope')).toBe('read write'); } }); const result = await auth(provider, { serverUrl: RESOURCE_SERVER_URL, fetchFn: fetchMock }); expect(result).toBe('AUTHORIZED'); }); it('authenticates using PrivateKeyJwtProvider with private_key_jwt', async () => { const provider = new PrivateKeyJwtProvider({ clientId: 'client-id', privateKey: 'a-string-secret-at-least-256-bits-long', algorithm: 'HS256', clientName: 'private-key-jwt-client' }); let assertionFromRequest: string | null = null; const fetchMock = createMockOAuthFetch({ resourceServerUrl: RESOURCE_SERVER_URL, authServerUrl: AUTH_SERVER_URL, onTokenRequest: async (_url, init) => { const params = init?.body as URLSearchParams; expect(params).toBeInstanceOf(URLSearchParams); expect(params.get('grant_type')).toBe('client_credentials'); expect(params.get('resource')).toBe(RESOURCE_SERVER_URL); assertionFromRequest = params.get('client_assertion'); expect(assertionFromRequest).toBeTruthy(); expect(params.get('client_assertion_type')).toBe('urn:ietf:params:oauth:client-assertion-type:jwt-bearer'); const parts = assertionFromRequest!.split('.'); expect(parts).toHaveLength(3); const headers = new Headers(init?.headers); expect(headers.get('Authorization')).toBeNull(); } }); const result = await auth(provider, { serverUrl: RESOURCE_SERVER_URL, fetchFn: fetchMock }); expect(result).toBe('AUTHORIZED'); const tokens = provider.tokens(); expect(tokens).toBeTruthy(); expect(tokens?.access_token).toBe('test-access-token'); expect(assertionFromRequest).toBeTruthy(); }); it('sends scope in token request when PrivateKeyJwtProvider is configured with scope', async () => { const provider = new PrivateKeyJwtProvider({ clientId: 'client-id', privateKey: 'a-string-secret-at-least-256-bits-long', algorithm: 'HS256', clientName: 'private-key-jwt-client', scope: 'openid profile' }); expect(provider.clientMetadata.scope).toBe('openid profile'); const fetchMock = createMockOAuthFetch({ resourceServerUrl: RESOURCE_SERVER_URL, authServerUrl: AUTH_SERVER_URL, onTokenRequest: async (_url, init) => { const params = init?.body as URLSearchParams; expect(params).toBeInstanceOf(URLSearchParams); expect(params.get('grant_type')).toBe('client_credentials'); expect(params.get('scope')).toBe('openid profile'); expect(params.get('client_assertion')).toBeTruthy(); expect(params.get('client_assertion_type')).toBe('urn:ietf:params:oauth:client-assertion-type:jwt-bearer'); } }); const result = await auth(provider, { serverUrl: RESOURCE_SERVER_URL, fetchFn: fetchMock }); expect(result).toBe('AUTHORIZED'); }); it('fails when PrivateKeyJwtProvider is configured with an unsupported algorithm', async () => { const provider = new PrivateKeyJwtProvider({ clientId: 'client-id', privateKey: 'a-string-secret-at-least-256-bits-long', algorithm: 'none', clientName: 'private-key-jwt-client' }); const fetchMock = createMockOAuthFetch({ resourceServerUrl: RESOURCE_SERVER_URL, authServerUrl: AUTH_SERVER_URL }); await expect( auth(provider, { serverUrl: RESOURCE_SERVER_URL, fetchFn: fetchMock }) ).rejects.toThrow('Unsupported algorithm none'); }); it('authenticates using StaticPrivateKeyJwtProvider with static client assertion', async () => { const staticAssertion = 'header.payload.signature'; const provider = new StaticPrivateKeyJwtProvider({ clientId: 'static-client', jwtBearerAssertion: staticAssertion, clientName: 'static-private-key-jwt-client' }); const fetchMock = createMockOAuthFetch({ resourceServerUrl: RESOURCE_SERVER_URL, authServerUrl: AUTH_SERVER_URL, onTokenRequest: async (_url, init) => { const params = init?.body as URLSearchParams; expect(params).toBeInstanceOf(URLSearchParams); expect(params.get('grant_type')).toBe('client_credentials'); expect(params.get('resource')).toBe(RESOURCE_SERVER_URL); expect(params.get('client_assertion')).toBe(staticAssertion); expect(params.get('client_assertion_type')).toBe('urn:ietf:params:oauth:client-assertion-type:jwt-bearer'); const headers = new Headers(init?.headers); expect(headers.get('Authorization')).toBeNull(); } }); const result = await auth(provider, { serverUrl: RESOURCE_SERVER_URL, fetchFn: fetchMock }); expect(result).toBe('AUTHORIZED'); const tokens = provider.tokens(); expect(tokens).toBeTruthy(); expect(tokens?.access_token).toBe('test-access-token'); }); it('sends scope in token request when StaticPrivateKeyJwtProvider is configured with scope', async () => { const staticAssertion = 'header.payload.signature'; const provider = new StaticPrivateKeyJwtProvider({ clientId: 'static-client', jwtBearerAssertion: staticAssertion, clientName: 'static-private-key-jwt-client', scope: 'api:read api:write' }); expect(provider.clientMetadata.scope).toBe('api:read api:write'); const fetchMock = createMockOAuthFetch({ resourceServerUrl: RESOURCE_SERVER_URL, authServerUrl: AUTH_SERVER_URL, onTokenRequest: async (_url, init) => { const params = init?.body as URLSearchParams; expect(params).toBeInstanceOf(URLSearchParams); expect(params.get('grant_type')).toBe('client_credentials'); expect(params.get('scope')).toBe('api:read api:write'); expect(params.get('client_assertion')).toBe(staticAssertion); expect(params.get('client_assertion_type')).toBe('urn:ietf:params:oauth:client-assertion-type:jwt-bearer'); } }); const result = await auth(provider, { serverUrl: RESOURCE_SERVER_URL, fetchFn: fetchMock }); expect(result).toBe('AUTHORIZED'); }); }); describe('createPrivateKeyJwtAuth', () => { const baseOptions = { issuer: 'client-id', subject: 'client-id', privateKey: 'a-string-secret-at-least-256-bits-long', alg: 'HS256' }; it('creates an addClientAuthentication function that sets JWT assertion params', async () => { const addClientAuth = createPrivateKeyJwtAuth(baseOptions); const headers = new Headers(); const params = new URLSearchParams(); await addClientAuth(headers, params, 'https://auth.example.com/token', undefined); expect(params.get('client_assertion')).toBeTruthy(); expect(params.get('client_assertion_type')).toBe('urn:ietf:params:oauth:client-assertion-type:jwt-bearer'); // Verify JWT structure (three dot-separated segments) const assertion = params.get('client_assertion')!; const parts = assertion.split('.'); expect(parts).toHaveLength(3); }); it('throws when globalThis.crypto is not available', async () => { // Temporarily remove globalThis.crypto to simulate older Node.js runtimes // eslint-disable-next-line @typescript-eslint/no-explicit-any const globalAny = globalThis as any; const originalCrypto = globalAny.crypto; // Use delete so that typeof globalThis.crypto === 'undefined' // eslint-disable-next-line @typescript-eslint/no-dynamic-delete delete globalAny.crypto; try { const addClientAuth = createPrivateKeyJwtAuth(baseOptions); const params = new URLSearchParams(); await expect(addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined)).rejects.toThrow( 'crypto is not available, please ensure you add have Web Crypto API support for older Node.js versions' ); } finally { // Restore original crypto to avoid affecting other tests globalAny.crypto = originalCrypto; } }); it('creates a signed JWT when using a Uint8Array HMAC key', async () => { const secret = new TextEncoder().encode('a-string-secret-at-least-256-bits-long'); const addClientAuth = createPrivateKeyJwtAuth({ issuer: 'client-id', subject: 'client-id', privateKey: secret, alg: 'HS256' }); const params = new URLSearchParams(); await addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined); const assertion = params.get('client_assertion')!; const parts = assertion.split('.'); expect(parts).toHaveLength(3); }); it('creates a signed JWT when using a symmetric JWK key', async () => { const jwk: Record = { kty: 'oct', // "a-string-secret-at-least-256-bits-long" base64url-encoded k: 'YS1zdHJpbmctc2VjcmV0LWF0LWxlYXN0LTI1Ni1iaXRzLWxvbmc', alg: 'HS256' }; const addClientAuth = createPrivateKeyJwtAuth({ issuer: 'client-id', subject: 'client-id', privateKey: jwk, alg: 'HS256' }); const params = new URLSearchParams(); await addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined); const assertion = params.get('client_assertion')!; const parts = assertion.split('.'); expect(parts).toHaveLength(3); }); it('creates a signed JWT when using an RSA PEM private key', async () => { // Generate an RSA key pair on the fly const jose = await import('jose'); const { privateKey } = await jose.generateKeyPair('RS256', { extractable: true }); const pem = await jose.exportPKCS8(privateKey); const addClientAuth = createPrivateKeyJwtAuth({ issuer: 'client-id', subject: 'client-id', privateKey: pem, alg: 'RS256' }); const params = new URLSearchParams(); await addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined); const assertion = params.get('client_assertion')!; const parts = assertion.split('.'); expect(parts).toHaveLength(3); }); it('uses metadata.issuer as audience when available', async () => { const addClientAuth = createPrivateKeyJwtAuth(baseOptions); const params = new URLSearchParams(); await addClientAuth(new Headers(), params, 'https://auth.example.com/token', { issuer: 'https://issuer.example.com', authorization_endpoint: 'https://auth.example.com/authorize', token_endpoint: 'https://auth.example.com/token', response_types_supported: ['code'] }); const assertion = params.get('client_assertion')!; // Decode the payload to verify audience const [, payloadB64] = assertion.split('.'); const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString()); expect(payload.aud).toBe('https://issuer.example.com'); }); it('throws when using an unsupported algorithm', async () => { const addClientAuth = createPrivateKeyJwtAuth({ issuer: 'client-id', subject: 'client-id', privateKey: 'a-string-secret-at-least-256-bits-long', alg: 'none' }); const params = new URLSearchParams(); await expect(addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined)).rejects.toThrow( 'Unsupported algorithm none' ); }); it('throws when jose cannot import an invalid RSA PEM key', async () => { const badPem = '-----BEGIN PRIVATE KEY-----\nnot-a-valid-key\n-----END PRIVATE KEY-----'; const addClientAuth = createPrivateKeyJwtAuth({ issuer: 'client-id', subject: 'client-id', privateKey: badPem, alg: 'RS256' }); const params = new URLSearchParams(); await expect(addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined)).rejects.toThrow( /Invalid character|cannot be part of a valid base64/ ); }); it('throws when jose cannot import a mismatched JWK key', async () => { const jwk: Record = { kty: 'oct', k: 'c2VjcmV0LWtleQ', // "secret-key" base64url alg: 'HS256' }; const addClientAuth = createPrivateKeyJwtAuth({ issuer: 'client-id', subject: 'client-id', privateKey: jwk, // Ask for an RSA algorithm with an octet key, which should cause jose.importJWK to fail alg: 'RS256' }); const params = new URLSearchParams(); await expect(addClientAuth(new Headers(), params, 'https://auth.example.com/token', undefined)).rejects.toThrow( /Key for the RS256 algorithm must be one of type CryptoKey, KeyObject, or JSON Web Key/ ); }); });