mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +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",
|
"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",
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@@ -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: {}
|
||||||
|
|||||||
Reference in New Issue
Block a user