Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b4246ea
save commit
KKonstantinov Nov 14, 2025
25b631c
merge commit
KKonstantinov Nov 22, 2025
4d4f517
SEP-1046: add jwt prebuilt assertion and signing options, add zod sch…
KKonstantinov Nov 22, 2025
7262bf2
test polyfill setup for jose on node 18
KKonstantinov Nov 22, 2025
bf895de
prettier fix
KKonstantinov Nov 22, 2025
683dfaa
Merge branch 'main' into feature/sep-1046-client-credentials
KKonstantinov Nov 25, 2025
da86864
remove jwt-bearer grant_type, add jwt-bearer client_assertion_type, r…
KKonstantinov Nov 26, 2025
3ea632f
Merge branch 'feature/sep-1046-client-credentials' of github.com:KKon…
KKonstantinov Nov 26, 2025
166dcab
Merge branch 'main' into feature/sep-1046-client-credentials
KKonstantinov Nov 26, 2025
2115343
clean up
KKonstantinov Nov 26, 2025
f1b8aef
clean up
KKonstantinov Nov 26, 2025
faa3c53
split out into extension file
pcarleton Nov 26, 2025
b58771c
fix type error
pcarleton Nov 26, 2025
b1bc454
dont commit a key
pcarleton Nov 26, 2025
86ee089
Merge branch 'main' into feature/sep-1046-client-credentials
KKonstantinov Nov 26, 2025
5f99239
add StaticPrivateKeyJwtProvider, add tests, consolidate tests in auth…
KKonstantinov Nov 27, 2025
f16bc71
Add README, add throw if crypto not available in older Node versions
KKonstantinov Nov 27, 2025
6822457
prettier fix
KKonstantinov Nov 27, 2025
8afe461
Merge branch 'main' into feature/sep-1046-client-credentials
KKonstantinov Nov 27, 2025
258a5c6
Merge branch 'main' into feature/sep-1046-client-credentials
KKonstantinov Nov 28, 2025
82ca0af
Merge branch 'main' into feature/sep-1046-client-credentials
KKonstantinov Nov 28, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@
"eventsource-parser": "^3.0.0",
"express": "^5.0.1",
"express-rate-limit": "^7.5.0",
"jose": "^6.1.1",
"pkce-challenge": "^5.0.0",
"raw-body": "^3.0.0",
"zod": "^3.25 || ^4.0",
Expand Down
351 changes: 351 additions & 0 deletions src/client/auth-extensions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,351 @@
import { describe, it, expect } from 'vitest';
import { auth } from './auth.js';
import {
ClientCredentialsProvider,
PrivateKeyJwtProvider,
StaticPrivateKeyJwtProvider,
createPrivateKeyJwtAuth
} from './auth-extensions.js';
import type { FetchLike } from '../shared/transport.js';

const RESOURCE_SERVER_URL = 'https://resource.example.com/';
const AUTH_SERVER_URL = 'https://auth.example.com';

function createMockFetch(onTokenRequest?: (url: URL, init: RequestInit | undefined) => void | Promise<void>): FetchLike {
return async (input: string | URL, init?: RequestInit): Promise<Response> => {
const url = input instanceof URL ? input : new URL(input);

// Protected resource metadata discovery
if (url.origin === RESOURCE_SERVER_URL.slice(0, -1) && url.pathname === '/.well-known/oauth-protected-resource') {
return new Response(
JSON.stringify({
resource: RESOURCE_SERVER_URL,
authorization_servers: [AUTH_SERVER_URL]
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' }
}
);
}

// Authorization server metadata discovery
if (url.origin === AUTH_SERVER_URL && url.pathname === '/.well-known/oauth-authorization-server') {
return new Response(
JSON.stringify({
issuer: AUTH_SERVER_URL,
authorization_endpoint: `${AUTH_SERVER_URL}/authorize`,
token_endpoint: `${AUTH_SERVER_URL}/token`,
response_types_supported: ['code'],
token_endpoint_auth_methods_supported: ['client_secret_basic', 'private_key_jwt']
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' }
}
);
}

// Token endpoint
if (url.origin === AUTH_SERVER_URL && url.pathname === '/token') {
if (onTokenRequest) {
await onTokenRequest(url, init);
}

return new Response(
JSON.stringify({
access_token: 'test-access-token',
token_type: 'Bearer'
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' }
}
);
}

throw new Error(`Unexpected URL in mock fetch: ${url.toString()}`);
};
}

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 = createMockFetch(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('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 = createMockFetch(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('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 = createMockFetch();

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 = createMockFetch(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');
});
});

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('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/
);
});

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/
);
});
});
Loading
Loading