mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 18:48:11 +00:00
feat(passenger-api): add pkce and client-assertion utilities for fayda
This commit is contained in:
@@ -36,7 +36,7 @@
|
||||
"bcrypt": "^5.1.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.0",
|
||||
"jose": "^6.2.3",
|
||||
"jose": "^5.10.0",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"qrcode": "^1.5.3",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user