feat(passenger-api): add pkce and client-assertion utilities for fayda

This commit is contained in:
Abubeker Yasin
2026-05-25 17:03:44 +03:00
parent 71c5534618
commit 28448c0268
6 changed files with 182 additions and 8 deletions

View File

@@ -36,7 +36,7 @@
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
"class-validator": "^0.14.0", "class-validator": "^0.14.0",
"jose": "^6.2.3", "jose": "^5.10.0",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"qrcode": "^1.5.3", "qrcode": "^1.5.3",

View File

@@ -0,0 +1,71 @@
import { exportJWK, generateKeyPair, importJWK, jwtVerify, type JWK } from 'jose';
import { generateClientAssertion } from './client-assertion.util';
describe('generateClientAssertion', () => {
let privateJwk: JWK;
let publicJwk: JWK;
beforeAll(async () => {
const kp = await generateKeyPair('RS256', { extractable: true });
privateJwk = await exportJWK(kp.privateKey);
publicJwk = await exportJWK(kp.publicKey);
});
it('produces a JWT verifiable with the matching public key', async () => {
const jwt = await generateClientAssertion({
clientId: 'edr-passenger-test',
audience: 'https://esignet.example.com/token',
privateJwk,
});
const verifier = await importJWK(publicJwk, 'RS256');
const { payload, protectedHeader } = await jwtVerify(jwt, verifier, {
issuer: 'edr-passenger-test',
subject: 'edr-passenger-test',
audience: 'https://esignet.example.com/token',
});
expect(protectedHeader.alg).toBe('RS256');
expect(protectedHeader.typ).toBe('JWT');
expect(payload.iss).toBe('edr-passenger-test');
expect(payload.sub).toBe('edr-passenger-test');
expect(payload.aud).toBe('https://esignet.example.com/token');
expect(typeof payload.iat).toBe('number');
expect(typeof payload.exp).toBe('number');
});
it('defaults exp to 120 seconds after iat', async () => {
const jwt = await generateClientAssertion({
clientId: 'c',
audience: 'https://a/token',
privateJwk,
});
const verifier = await importJWK(publicJwk, 'RS256');
const { payload } = await jwtVerify(jwt, verifier);
expect(payload.exp! - payload.iat!).toBe(120);
});
it('honors a custom expiresIn', async () => {
const jwt = await generateClientAssertion({
clientId: 'c',
audience: 'https://a/token',
privateJwk,
expiresIn: '5m',
});
const verifier = await importJWK(publicJwk, 'RS256');
const { payload } = await jwtVerify(jwt, verifier);
expect(payload.exp! - payload.iat!).toBe(300);
});
it('fails verification against a wrong audience', async () => {
const jwt = await generateClientAssertion({
clientId: 'c',
audience: 'https://a/token',
privateJwk,
});
const verifier = await importJWK(publicJwk, 'RS256');
await expect(
jwtVerify(jwt, verifier, { audience: 'https://other/token' }),
).rejects.toThrow();
});
});

View File

@@ -0,0 +1,22 @@
import { SignJWT, importJWK, type JWK } from 'jose';
export interface GenerateClientAssertionInput {
clientId: string;
audience: string;
privateJwk: JWK;
expiresIn?: string;
}
export async function generateClientAssertion(
input: GenerateClientAssertionInput,
): Promise<string> {
const privateKey = await importJWK(input.privateJwk, 'RS256');
return new SignJWT({})
.setProtectedHeader({ alg: 'RS256', typ: 'JWT' })
.setIssuer(input.clientId)
.setSubject(input.clientId)
.setAudience(input.audience)
.setIssuedAt()
.setExpirationTime(input.expiresIn ?? '2m')
.sign(privateKey);
}

View File

@@ -0,0 +1,65 @@
import { createHash } from 'crypto';
import {
base64Url,
generateCodeChallenge,
generateCodeVerifier,
generateState,
} from './pkce.util';
describe('pkce.util', () => {
describe('base64Url', () => {
it('strips padding and replaces + and / with - and _', () => {
const input = Buffer.from([0xfb, 0xff, 0xbf, 0xfe]);
const out = base64Url(input);
expect(out).not.toMatch(/[+/=]/);
});
});
describe('generateCodeVerifier', () => {
it('returns a base64url-safe string', () => {
expect(generateCodeVerifier()).toMatch(/^[A-Za-z0-9_-]+$/);
});
it('produces unique values across calls', () => {
const a = generateCodeVerifier();
const b = generateCodeVerifier();
expect(a).not.toEqual(b);
});
it('produces at least 43 characters (RFC 7636 minimum)', () => {
expect(generateCodeVerifier().length).toBeGreaterThanOrEqual(43);
});
});
describe('generateCodeChallenge', () => {
it('equals base64url(sha256(verifier))', () => {
const verifier = 'fixed-test-verifier';
const expected = createHash('sha256')
.update(verifier)
.digest('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
expect(generateCodeChallenge(verifier)).toBe(expected);
});
it('is deterministic for the same verifier', () => {
const verifier = generateCodeVerifier();
expect(generateCodeChallenge(verifier)).toBe(generateCodeChallenge(verifier));
});
it('differs for different verifiers', () => {
expect(generateCodeChallenge('a')).not.toBe(generateCodeChallenge('b'));
});
});
describe('generateState', () => {
it('returns a base64url-safe string', () => {
expect(generateState()).toMatch(/^[A-Za-z0-9_-]+$/);
});
it('produces unique values across calls', () => {
expect(generateState()).not.toEqual(generateState());
});
});
});

View File

@@ -0,0 +1,21 @@
import { createHash, randomBytes } from 'crypto';
export function base64Url(buffer: Buffer): string {
return buffer
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
export function generateCodeVerifier(): string {
return base64Url(randomBytes(64));
}
export function generateCodeChallenge(codeVerifier: string): string {
return base64Url(createHash('sha256').update(codeVerifier).digest());
}
export function generateState(): string {
return base64Url(randomBytes(32));
}

9
pnpm-lock.yaml generated
View File

@@ -321,8 +321,8 @@ importers:
specifier: ^0.14.0 specifier: ^0.14.0
version: 0.14.4 version: 0.14.4
jose: jose:
specifier: ^6.2.3 specifier: ^5.10.0
version: 6.2.3 version: 5.10.0
passport: passport:
specifier: ^0.7.0 specifier: ^0.7.0
version: 0.7.0 version: 0.7.0
@@ -7232,9 +7232,6 @@ packages:
jose@5.10.0: jose@5.10.0:
resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==}
jose@6.2.3:
resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==}
jquery@3.7.1: jquery@3.7.1:
resolution: {integrity: sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==} resolution: {integrity: sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==}
@@ -19462,8 +19459,6 @@ snapshots:
jose@5.10.0: {} jose@5.10.0: {}
jose@6.2.3: {}
jquery@3.7.1: {} jquery@3.7.1: {}
js-cookie@3.0.7: {} js-cookie@3.0.7: {}