425 lines
17 KiB
TypeScript
425 lines
17 KiB
TypeScript
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<string, unknown> = {
|
|
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<string, unknown> = {
|
|
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/
|
|
);
|
|
});
|
|
});
|