diff --git a/src/alb-verifier.ts b/src/alb-verifier.ts new file mode 100644 index 0000000..4d0550a --- /dev/null +++ b/src/alb-verifier.ts @@ -0,0 +1,328 @@ +import { AwsAlbJwksCache } from "./alb"; +import { assertStringArrayContainsString } from "./assert"; +import { JwtInvalidClaimError, JwtInvalidSignatureAlgorithmError, ParameterValidationError } from "./error"; +import { Jwks, JwksCache } from "./jwk"; +import { DecomposedJwt, decomposeUnverifiedJwt } from "./jwt"; +import { JwtHeader, JwtPayload } from "./jwt-model"; +import { verifyDecomposedJwt, verifyDecomposedJwtSync } from "./jwt-verifier"; +import { JsonObject } from "./safe-json-parse"; +import { Properties } from "./typing-util"; + +type LoadBalancerArn = string; + +export const supportedSignatureAlgorithms = [ + "ES256", +] as const; + +export class AlbJwtInvalidSignerError extends JwtInvalidClaimError {} +export class AlbJwtInvalidClientIdError extends JwtInvalidClaimError {} + +export interface AlbVerifyProperties { + + /** + * The client ID that you expect to be present on the JWT + * (In the ID token's aud claim, or the Access token's client_id claim). + * If you provide a string array, that means at least one of those client IDs + * must be present on the JWT. + * Pass null explicitly to not check the JWT's client ID--if you know what you're doing + */ + clientId: string | string[] | null; + + loadBalancerArn: string; + + jwksUri?: string; + issuer: string; + + /** + * If you want to peek inside the invalid JWT when verification fails, set `includeRawJwtInErrors` to true. + * Then, if an error is thrown during verification of the invalid JWT (e.g. the JWT is invalid because it is expired), + * the Error object will include a property `rawJwt`, with the raw decoded contents of the **invalid** JWT. + * The `rawJwt` will only be included in the Error object, if the JWT's signature can at least be verified. + */ + includeRawJwtInErrors?: boolean; + +} + +/** Type for ALB JWT verifier properties, for a single ALB */ +export type AlbJwtVerifierProperties = { + + loadBalancerArn: string; + +} & Partial; + +/** + * Type for ALB JWT verifier properties, when multiple ALB are used in the verifier. + */ +export type AlbJwtVerifierMultiProperties = { + + loadBalancerArn: string; + +} & AlbVerifyProperties; + +export type AlbDataVerifierSingleAlb< +T extends AlbJwtVerifierProperties, +> = AlbDataVerifier< + Properties, + false +>; + +export type AlbDataVerifierMultiAlb< +T extends AlbJwtVerifierProperties, +> = AlbDataVerifier< + Properties, + true +>; + +type AlbVerifyParameters = { + [key: string]: never; +} extends SpecificVerifyProperties + ? [jwt: string, props?: SpecificVerifyProperties] + : [jwt: string, props: SpecificVerifyProperties]; + +export type AlbConfig = { + + loadBalancerArn: string; + defaultJwksUri: string;//Managing multi region ALB even if not possible (ALB target group are single region) + +} & Partial; + +type DataTokenPayload = { + exp:number + iss:string, +} & JsonObject; + +export class AlbDataVerifier< + SpecificVerifyProperties extends Partial, + MultiAlb extends boolean, +> { + + readonly albConfigMap: Map = new Map(); + // private readonly publicKeyCache = new KeyObjectCache(); + readonly jwksCache: JwksCache = new AwsAlbJwksCache(); + + private constructor( + props: AlbJwtVerifierProperties | AlbJwtVerifierMultiProperties[], + ) { + if(Array.isArray(props)){ + if (!props.length) { + throw new ParameterValidationError( + "Provide at least one alb configuration" + ); + } + for (const albProps of props) { + if (this.albConfigMap.has(albProps.loadBalancerArn)) { + throw new ParameterValidationError( + `loadBalancerArn ${albProps.loadBalancerArn} supplied multiple times` + ); + } + this.albConfigMap.set(albProps.loadBalancerArn, { + ...albProps, + defaultJwksUri: this.defaultJwksUri(albProps), + }); + } + }else { + this.albConfigMap.set(props.loadBalancerArn, { + ...props, + defaultJwksUri: this.defaultJwksUri(props), + }); + } + } + + private extractRegionFromLoadBalancerArn(loadBalancerArn: string): string { + const arnParts = loadBalancerArn.split(":"); + if (arnParts.length < 4) { + throw new ParameterValidationError(`Invalid load balancer ARN: ${loadBalancerArn}`); + } + return arnParts[3]; + } + + private defaultJwksUri(params:{loadBalancerArn: string}): string { + const region = this.extractRegionFromLoadBalancerArn(params.loadBalancerArn); + return `https://public-keys.auth.elb.${region}.amazonaws.com`; + } + + static create( + verifyProperties: T & Partial + ): AlbDataVerifierSingleAlb; + + + static create( + props: (T & Partial)[] + ): AlbDataVerifierMultiAlb; + + static create( + verifyProperties: + | AlbJwtVerifierProperties + | AlbJwtVerifierMultiProperties[] + ) { + return new this(verifyProperties); + } + + public async verify( + ...[jwt, properties]: AlbVerifyParameters): Promise{ + const { decomposedJwt, jwksUri, verifyProperties } = this.getVerifyParameters(jwt, properties); + await this.verifyDecomposedJwt(decomposedJwt, jwksUri, verifyProperties); + try { + this.validateDataJwtFields(decomposedJwt.header, verifyProperties); + } catch (err) { + if ( + verifyProperties.includeRawJwtInErrors && + err instanceof JwtInvalidClaimError + ) { + throw err.withRawJwt(decomposedJwt); + } + throw err; + } + return decomposedJwt.payload as DataTokenPayload; + } + + public verifySync( ...[jwt, properties]: AlbVerifyParameters): DataTokenPayload { + const { decomposedJwt, jwksUri, verifyProperties } = this.getVerifyParameters(jwt, properties); + this.verifyDecomposedJwtSync(decomposedJwt, jwksUri, verifyProperties); + try { + this.validateDataJwtFields(decomposedJwt.header, verifyProperties); + } catch (err) { + if ( + verifyProperties.includeRawJwtInErrors && + err instanceof JwtInvalidClaimError + ) { + throw err.withRawJwt(decomposedJwt); + } + throw err; + } + return decomposedJwt.payload as DataTokenPayload; + } + + protected getVerifyParameters( + jwt: string, + verifyProperties?: Partial + ): { + decomposedJwt: DecomposedJwt; + jwksUri: string, + verifyProperties: AlbJwtVerifierProperties; + } { + const decomposedJwt = decomposeUnverifiedJwt(jwt); + assertStringArrayContainsString( + "Signer", + decomposedJwt.header.signer, + this.expectedLoadBalancerArn, + AlbJwtInvalidSignerError + ); + const albConfig = this.getAlbConfig(decomposedJwt.header.signer); + return { + decomposedJwt, + jwksUri: verifyProperties?.jwksUri ?? albConfig.defaultJwksUri, + verifyProperties: { + ...albConfig, + ...verifyProperties, + } as unknown as AlbJwtVerifierProperties, + }; + } + + private validateDataJwtFields( + header:JwtHeader, + options: { + loadBalancerArn: string; + issuer?: string; + clientId?: string | string[] | null; + } + ): void { + + // Check JWT signature algorithm is one of the supported signature algorithms + assertStringArrayContainsString( + "JWT signature algorithm", + header.alg, + supportedSignatureAlgorithms, + JwtInvalidSignatureAlgorithmError + ); + + // Check client ID header + if (options.clientId !== null) { + if (options.clientId === undefined) { + throw new ParameterValidationError( + "clientId must be provided or set to null explicitly" + ); + } + assertStringArrayContainsString( + "Client ID", + header.client, + options.clientId, + AlbJwtInvalidClientIdError + ); + } + } + + public cacheJwks( + ...[jwks, loadBalancerArn]: MultiAlb extends false + ? [jwks: Jwks, loadBalancerArn?: string] + : [jwks: Jwks, loadBalancerArn: string] + ): void { + const albConfig = this.getAlbConfig(loadBalancerArn); + this.jwksCache.addJwks(albConfig.jwksUri ?? albConfig.defaultJwksUri, jwks); + // this.publicKeyCache.clearCache(albConfig.loadBalancerArn); + } + + protected getAlbConfig( + loadBalancerArn?: string + ): AlbConfig { + if (!loadBalancerArn) { + if (this.albConfigMap.size !== 1) { + throw new ParameterValidationError("loadBalancerArn must be provided"); + } + loadBalancerArn = this.albConfigMap.keys().next().value; + } + const config = this.albConfigMap.get(loadBalancerArn!); + if (!config) { + throw new ParameterValidationError(`loadBalancerArn not configured: ${loadBalancerArn}`); + } + return config; + } + + protected get expectedLoadBalancerArn(): string[] { + return Array.from(this.albConfigMap.keys()); + } + + protected verifyDecomposedJwt( + decomposedJwt: DecomposedJwt, + jwksUri: string, + verifyProperties: AlbJwtVerifierProperties + ): Promise { + return verifyDecomposedJwt( + decomposedJwt, + jwksUri, + { + includeRawJwtInErrors: verifyProperties.includeRawJwtInErrors, + issuer: verifyProperties.issuer, + audience:null + }, + this.jwksCache.getJwk.bind(this.jwksCache), + // (jwk, alg, _issuer) => { + // // Use the load balancer ARN instead of the issuer for the public key cache + // const loadBalancerArn = decomposedJwt.header.signer as string; + // return this.publicKeyCache.transformJwkToKeyObjectAsync(jwk, alg, loadBalancerArn); + // } + ); + } + + protected verifyDecomposedJwtSync( + decomposedJwt: DecomposedJwt, + jwksUri: string, + verifyProperties: AlbJwtVerifierProperties + ): JwtPayload { + const jwk = this.jwksCache.getCachedJwk(jwksUri, decomposedJwt); + return verifyDecomposedJwtSync( + decomposedJwt, + jwk, + { + includeRawJwtInErrors: verifyProperties.includeRawJwtInErrors, + issuer: verifyProperties.issuer, + audience:null + }, + // (jwk, alg, _issuer) => { + // // Use the load balancer ARN instead of the issuer for the public key cache + // const loadBalancerArn = decomposedJwt.header.signer as string; + // return this.publicKeyCache.transformJwkToKeyObjectSync(jwk, alg, loadBalancerArn); + // } + ); + } +} diff --git a/src/alb.ts b/src/alb.ts new file mode 100644 index 0000000..59c4874 --- /dev/null +++ b/src/alb.ts @@ -0,0 +1,193 @@ +import { createPublicKey } from "crypto"; +import { + JwkInvalidKtyError, + JwksNotAvailableInCacheError, + JwtBaseError, + JwtWithoutValidKidError, +} from "./error"; +import { + JwkWithKid, + Jwks, + JwksCache, +} from "./jwk"; +import { JwtHeader, JwtPayload } from "./jwt-model"; +import { Fetcher, SimpleFetcher } from "./https"; +import { SimpleLruCache } from "./cache"; +import { assertStringEquals } from "./assert"; + +interface DecomposedJwt { + header: JwtHeader; + payload: JwtPayload; +} + +type JwksUri = string; + +export class AlbUriError extends JwtBaseError {} + +/** + * + * Security considerations: + * It's important that the application protected by this library run in a secure environment. This application should be behind the ALB and deployed in a private subnet, or a public subnet but with no access from a untrusted network. + * This security requierement is essential to be respected otherwise the application is exposed to several security risks. This class can be subject to a DoS attack if the attacker can control the kid. + * + */ +export class AwsAlbJwksCache implements JwksCache { + + fetcher: Fetcher; + // penaltyBox:PenaltyBox; + + private jwkCache: SimpleLruCache = new SimpleLruCache(2); + private fetchingJwks: Map> = new Map(); + + constructor(props?: { + fetcher?: Fetcher; + // penaltyBox?: PenaltyBox; + }) { + this.fetcher = props?.fetcher ?? new SimpleFetcher(); + // this.penaltyBox = props?.penaltyBox ?? new SimplePenaltyBox(); + } + + private expandWithKid(jwksUri: string, kid: string): string { + return `${jwksUri}/${encodeURIComponent(kid)}`; + } + + private getKid(decomposedJwt: DecomposedJwt): string { + const kid = decomposedJwt.header.kid; + if (typeof kid !== "string" || !this.isValidAlbKid(kid)) { + throw new JwtWithoutValidKidError( + "JWT header does not have valid kid claim" + ); + } + return kid; + } + + private isValidAlbKid(kid:string) { + if (kid.length !== 36) { + return false; + } + + const part1 = kid.slice(0, 8); + const part2 = kid.slice(9, 13); + const part3 = kid.slice(14, 18); + const part4 = kid.slice(19, 23); + const part5 = kid.slice(24, 36); + + if (kid[8] !== '-' || kid[13] !== '-' || kid[18] !== '-' || kid[23] !== '-') { + return false; + } + + const isHex = (str: string) => { + for (let i = 0; i < str.length; i++) { + const code = str.charCodeAt(i); + if (!(code >= 48 && code <= 57) && // 0-9 + !(code >= 97 && code <= 102) && // a-f + !(code >= 65 && code <= 70)) { // A-F + return false; + } + } + return true; + }; + + return isHex(part1) && isHex(part2) && isHex(part3) && isHex(part4) && isHex(part5); + }; + + public async getJwk( + jwksUri: string, + decomposedJwt: DecomposedJwt + ): Promise { + const kid = this.getKid(decomposedJwt); + const jwksUriWithKid = this.expandWithKid(jwksUri, kid); + const jwk = this.jwkCache.get(jwksUriWithKid); + if(jwk){ + //cache hit + return jwk; + }else{ + //cache miss + const fetchPromise = this.fetchingJwks.get(jwksUriWithKid); + if(fetchPromise){ + return fetchPromise; + }else{ + // await this.penaltyBox.wait(jwksUriWithKid, kid); + const newFetchPromise = this.fetcher + .fetch(jwksUriWithKid) + .then(pem =>this.pemToJwk(kid,pem)) + .then(jwk=>{ + // this.penaltyBox.registerSuccessfulAttempt(jwksUriWithKid, kid); + this.jwkCache.set(jwksUriWithKid,jwk); + return jwk; + }) + .catch(error=>{ + // this.penaltyBox.registerFailedAttempt(jwksUriWithKid, kid); + throw error; + }).finally(()=>{ + this.fetchingJwks.delete(jwksUriWithKid); + }); + + this.fetchingJwks.set(jwksUriWithKid,newFetchPromise) + + return newFetchPromise; + } + } + } + + private pemToJwk(kid:string, pem:ArrayBuffer):JwkWithKid{ + const jwk = createPublicKey({ + key: Buffer.from(pem), + format: "pem", + type: "spki", + }).export({ + format: "jwk", + }); + + assertStringEquals("JWK kty", jwk.kty, "EC", JwkInvalidKtyError); + + return { + kid: kid, + use: "sig", + ...jwk, + } as JwkWithKid + } + + /** + * + * @param Ex: https://public-keys.auth.elb.eu-west-1.amazonaws.com + * @param decomposedJwt + * @returns + */ + public getCachedJwk( + jwksUri: string, + decomposedJwt: DecomposedJwt + ): JwkWithKid { + const kid = this.getKid(decomposedJwt); + const jwksUriWithKid = this.expandWithKid(jwksUri, kid); + const jwk = this.jwkCache.get(jwksUriWithKid); + if(jwk){ + return jwk; + }else{ + throw new JwksNotAvailableInCacheError( + `JWKS for uri ${jwksUri} not yet available in cache` + ); + } + } + + public addJwks(jwksUri: string, jwks: Jwks): void { + if(jwks.keys.length===1){ + const jwk = jwks.keys[0]; + if(jwk.kid){ + const jwkWithKid = jwk as JwkWithKid; + const kid = jwk.kid; + const jwksUriWithKid = this.expandWithKid(jwksUri, kid); + this.jwkCache.set(jwksUriWithKid,jwkWithKid); + }else{ + throw new Error("TODO"); + } + }else{ + throw new Error("TODO"); + } + } + + async getJwks(): Promise { + throw new Error("Method not implemented."); + } + +} diff --git a/src/cache.ts b/src/cache.ts new file mode 100644 index 0000000..cb5afa1 --- /dev/null +++ b/src/cache.ts @@ -0,0 +1,58 @@ +import assert from "assert"; + +export class SimpleLruCache { + + private index:Map; + + constructor(public readonly capacity:number){ + assert(capacity>0); + this.index = new Map(); + } + + public get size(){ + return this.index.size; + } + + public get(key:Key):Value|undefined{ + const value = this.index.get(key); + if(value){ + this.moveFirst(key,value); + + return value; + }else{ + return undefined; + } + } + + public set(key:Key, value:Value):this{ + if(this.size>=this.capacity){ + this.removeLast(); + } + + this.moveFirst(key,value); + + return this; + } + + private moveFirst(key:Key, value:Value){ + this.index.delete(key); + this.index.set(key,value); + } + + private removeLast(){ + const last = this.index.keys().next().value; + if(last){ + this.index.delete(last) + } + } + + /** + * + * @returns array ordered from the least recent to the most recent + */ + public toArray():Array<[Key,Value]>{ + return Array.from(this.index); + } + +} + diff --git a/src/jwk.ts b/src/jwk.ts index 09318f6..67c735c 100644 --- a/src/jwk.ts +++ b/src/jwk.ts @@ -99,7 +99,7 @@ export interface JwksCache { * @param jwksBin * @returns Jwks */ -export type JwksParser = (jwksBin: ArrayBuffer) => Jwks; +export type JwksParser = (jwksBin: ArrayBuffer, jwksUri: string) => Jwks; /** * UTF-8 decode binary data and then JSON parse it @@ -122,7 +122,7 @@ const parseJwks: JwksParser = function (jwksBin: ArrayBuffer) { }; export async function fetchJwks(jwksUri: string): Promise { - return fetch(jwksUri).then(parseJwks); + return fetch(jwksUri).then((jwksBin) => parseJwks(jwksBin, jwksUri)); } export async function fetchJwk( @@ -323,7 +323,9 @@ export class SimpleJwksCache implements JwksCache { if (existingFetch) { return existingFetch; } - const jwksPromise = this.fetcher.fetch(jwksUri).then(this.jwksParser); + const jwksPromise = this.fetcher + .fetch(jwksUri) + .then((jwksBin) => this.jwksParser(jwksBin, jwksUri)); this.fetchingJwks.set(jwksUri, jwksPromise); let jwks: Jwks; try { diff --git a/src/jwt-verifier.ts b/src/jwt-verifier.ts index d3294b0..de43b36 100644 --- a/src/jwt-verifier.ts +++ b/src/jwt-verifier.ts @@ -229,7 +229,7 @@ export async function verifyJwt( * @param transformJwkToKeyObjectFn A function that can transform a JWK into a crypto native key object * @returns Promise that resolves to the payload of the JWT––if the JWT is valid, otherwise the promise rejects */ -async function verifyDecomposedJwt( +export async function verifyDecomposedJwt( decomposedJwt: DecomposedJwt, jwksUri: string, options: { @@ -333,7 +333,7 @@ export function verifyJwtSync( * @param transformJwkToKeyObjectFn A function that can transform a JWK into a crypto native key object * @returns The (JSON parsed) payload of the JWT––if the JWT is valid, otherwise an error is thrown */ -function verifyDecomposedJwtSync( +export function verifyDecomposedJwtSync( decomposedJwt: DecomposedJwt, jwkOrJwks: JsonObject, options: { @@ -348,7 +348,7 @@ function verifyDecomposedJwtSync( }) => void; includeRawJwtInErrors?: boolean; }, - transformJwkToKeyObjectFn: JwkToKeyObjectTransformerSync + transformJwkToKeyObjectFn: JwkToKeyObjectTransformerSync = nodeWebCompat.transformJwkToKeyObjectSync ) { const { header, headerB64, payload, payloadB64, signatureB64 } = decomposedJwt; diff --git a/tests/unit/alb-jwks-test.pem b/tests/unit/alb-jwks-test.pem new file mode 100644 index 0000000..7ca2e7f --- /dev/null +++ b/tests/unit/alb-jwks-test.pem @@ -0,0 +1,4 @@ +-----BEGIN PUBLIC KEY----- +MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEGBJCbjNusVteS//606LS3fgYrhQy +vfAh+GbOfy2n7rWgG433Rtb4C/Gxyh6xVoTuvI8hKOqx4qCKjoflk7nGaQ== +-----END PUBLIC KEY----- \ No newline at end of file diff --git a/tests/unit/alb-verifier.test.ts b/tests/unit/alb-verifier.test.ts new file mode 100644 index 0000000..7e6bab2 --- /dev/null +++ b/tests/unit/alb-verifier.test.ts @@ -0,0 +1,543 @@ +import { + generateKeyPair, + signJwt, + allowAllRealNetworkTraffic, + disallowAllRealNetworkTraffic, + mockHttpsUri, +} from "./test-util"; +import { AlbJwtInvalidClientIdError, AlbJwtInvalidSignerError, AlbDataVerifier } from "../../src/alb-verifier"; +import { createPublicKey } from "crypto"; +import { JwtInvalidIssuerError } from "../../src/error"; + +describe("unit tests alb verifier", () => { + let keypair: ReturnType; + beforeAll(() => { + keypair = generateKeyPair({ + kid:"00000000-0000-0000-0000-000000000000", + kty:"EC", + alg:"ES256", + }); + disallowAllRealNetworkTraffic(); + }); + afterAll(() => { + allowAllRealNetworkTraffic(); + }); + + describe("AlbJwtVerifier", () => { + describe("verify", () => { + test("happy flow with cached public key", async () => { + + const region = "us-east-1"; + const userPoolId = "us-east-1_123456"; + const loadBalancerArn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; + const clientId = "my-client-id"; + const issuer = `https://cognito-idp.${region}.amazonaws.com/${userPoolId}`; + const jwksUri = `https://public-keys.auth.elb.${region}.amazonaws.com`; + const kid = keypair.jwk.kid; + const exp = 4000000000;// nock and jest.useFakeTimers do not work well together. Used of a long expired date instead + + const signedJwt = signJwt( + { + typ:"JWT", + kid, + alg:"ES256", + iss:issuer, + client:clientId, + signer:loadBalancerArn, + exp + }, + { + hello: "world", + exp, + iss:issuer, + }, + keypair.privateKey + ); + const albVerifier = AlbDataVerifier.create({ + issuer, + clientId, + loadBalancerArn, + jwksUri + }); + albVerifier.cacheJwks(keypair.jwks); + expect.assertions(1); + expect( + await albVerifier.verify(signedJwt) + ).toMatchObject({ hello: "world" }); + }); + + test("happy flow with public key fetching", async () => { + + const region = "us-east-1"; + const userPoolId = "us-east-1_123456"; + const loadBalancerArn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; + const clientId = "my-client-id"; + const issuer = `https://cognito-idp.${region}.amazonaws.com/${userPoolId}`; + const jwksUri = `https://public-keys.auth.elb.${region}.amazonaws.com`; + const jwk = keypair.jwk; + const kid = jwk.kid; + const exp = 4000000000;// nock and jest.useFakeTimers do not work well together. Used of a long expired date instead + const pem = createPublicKey({ + key: jwk, + format: "jwk", + }).export({ + format: "pem", + type: "spki", + });//pem with -----BEGIN PUBLIC KEY----- and -----END PUBLIC KEY-----. + + + mockHttpsUri(`${jwksUri}/${kid}`, { + responsePayload: pem, + }); + + const signedJwt = signJwt( + { + typ:"JWT", + kid, + alg:"ES256", + iss:issuer, + client:clientId, + signer:loadBalancerArn, + exp + }, + { + hello: "world", + exp, + iss:issuer, + }, + keypair.privateKey + ); + const albVerifier = AlbDataVerifier.create({ + issuer, + clientId, + loadBalancerArn, + jwksUri, + }); + expect.assertions(1); + expect( + await albVerifier.verify(signedJwt) + ).toMatchObject({ hello: "world" }); + }); + + + test("happy flow with lazy property", async () => { + + const region = "us-east-1"; + const userPoolId = "us-east-1_123456"; + const loadBalancerArn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; + const clientId = "my-client-id"; + const issuer = `https://cognito-idp.${region}.amazonaws.com/${userPoolId}`; + const jwksUri = `https://public-keys.auth.elb.${region}.amazonaws.com`; + const jwk = keypair.jwk; + const kid = jwk.kid; + const exp = 4000000000;// nock and jest.useFakeTimers do not work well together. Used of a long expired date instead + const pem = createPublicKey({ + key: jwk, + format: "jwk", + }).export({ + format: "pem", + type: "spki", + });//pem with -----BEGIN PUBLIC KEY----- and -----END PUBLIC KEY-----. + + + mockHttpsUri(`${jwksUri}/${kid}`, { + responsePayload: pem, + }); + + const signedJwt = signJwt( + { + typ:"JWT", + kid, + alg:"ES256", + iss:issuer, + client:clientId, + signer:loadBalancerArn, + exp + }, + { + hello: "world", + exp, + iss:issuer, + }, + keypair.privateKey + ); + const albVerifier = AlbDataVerifier.create({ + loadBalancerArn + }); + expect.assertions(1); + expect( + await albVerifier.verify(signedJwt,{ + issuer, + clientId, + jwksUri, + }) + ).toMatchObject({ hello: "world" }); + }); + + + test("flow with no jwksUri", async () => { + + const region = "us-east-1"; + const userPoolId = "us-east-1_123456"; + const loadBalancerArn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; + const clientId = "my-client-id"; + const issuer = `https://cognito-idp.${region}.amazonaws.com/${userPoolId}`; + const jwksUri = `https://public-keys.auth.elb.${region}.amazonaws.com`; + const jwk = keypair.jwk; + const kid = jwk.kid; + const exp = 4000000000;// nock and jest.useFakeTimers do not work well together. Used of a long expired date instead + const pem = createPublicKey({ + key: jwk, + format: "jwk", + }).export({ + format: "pem", + type: "spki", + });//pem with -----BEGIN PUBLIC KEY----- and -----END PUBLIC KEY-----. + + + mockHttpsUri(`${jwksUri}/${kid}`, { + responsePayload: pem, + }); + + const signedJwt = signJwt( + { + typ:"JWT", + kid, + alg:"ES256", + iss:issuer, + client:clientId, + signer:loadBalancerArn, + exp + }, + { + hello: "world", + exp, + iss:issuer, + }, + keypair.privateKey + ); + const albVerifier = AlbDataVerifier.create({ + issuer, + clientId, + loadBalancerArn, + }); + expect.assertions(1); + expect( + await albVerifier.verify(signedJwt) + ).toMatchObject({ hello: "world" }); + }); + + test("happy flow with multi properties", async () => { + + const keypair1 = generateKeyPair({kty:"EC",alg:"ES256",kid:"11111111-1111-1111-1111-111111111111"}); + const keypair2 = generateKeyPair({kty:"EC",alg:"ES256",kid:"22222222-2222-2222-2222-222222222222"}); + + const region = "us-east-1"; + const userPoolId = "us-east-1_123456"; + const loadBalancerArn1 = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/AAAAAAAAAAAAAAAA"; + const loadBalancerArn2 = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/BBBBBBBBBBBBBBBB"; + const clientId = "my-client-id"; + const issuer = `https://cognito-idp.${region}.amazonaws.com/${userPoolId}`; + const jwksUri = `https://public-keys.auth.elb.${region}.amazonaws.com`; + const exp = 4000000000;// nock and jest.useFakeTimers do not work well together. Used of a long expired date instead + + mockHttpsUri(`${jwksUri}/${keypair1.jwk.kid}`, { + responsePayload: createPublicKey({ + key: keypair1.jwk, + format: "jwk", + }).export({ + format: "pem", + type: "spki", + }), + }); + + mockHttpsUri(`${jwksUri}/${keypair2.jwk.kid}`, { + responsePayload: createPublicKey({ + key: keypair2.jwk, + format: "jwk", + }).export({ + format: "pem", + type: "spki", + }), + }); + + const signedJwt1 = signJwt( + { + typ:"JWT", + kid:keypair1.jwk.kid, + alg:"ES256", + iss:issuer, + client:clientId, + signer:loadBalancerArn1, + exp + }, + { + hello: "world1", + exp, + iss:issuer, + }, + keypair1.privateKey + ); + + const signedJwt2 = signJwt( + { + typ:"JWT", + kid:keypair2.jwk.kid, + alg:"ES256", + iss:issuer, + client:clientId, + signer:loadBalancerArn2, + exp + }, + { + hello: "world2", + exp, + iss:issuer, + }, + keypair2.privateKey + ); + const albVerifier = AlbDataVerifier.create([{ + issuer, + clientId, + loadBalancerArn:loadBalancerArn1, + jwksUri, + },{ + issuer, + clientId, + loadBalancerArn:loadBalancerArn2, + jwksUri, + }]); + + expect.assertions(2); + + expect( + await albVerifier.verify(signedJwt1) + ).toMatchObject({ hello: "world1" }); + + expect( + await albVerifier.verify(signedJwt2) + ).toMatchObject({ hello: "world2" }); + }); + + test("happy flow with default jwksUri", async () => { + + const region = "us-east-1"; + const userPoolId = "us-east-1_123456"; + const loadBalancerArn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/AAAAAAAAAAAAAAAA"; + const clientId = "my-client-id"; + const issuer = `https://cognito-idp.${region}.amazonaws.com/${userPoolId}`; + const exp = 4000000000;// nock and jest.useFakeTimers do not work well together. Used of a long expired date instead + + const signedJwt = signJwt( + { + typ:"JWT", + kid:keypair.jwk.kid, + alg:"ES256", + iss:issuer, + client:clientId, + signer:loadBalancerArn, + exp + }, + { + hello: "world1", + exp, + iss:issuer, + }, + keypair.privateKey + ); + + const albVerifier = AlbDataVerifier.create({ + issuer, + clientId, + loadBalancerArn:loadBalancerArn, + }); + + albVerifier.cacheJwks(keypair.jwks,loadBalancerArn); + + expect.assertions(1); + + expect( + await albVerifier.verify(signedJwt) + ).toMatchObject({ hello: "world1" }); + + }); + + test("invalid issuer", () => { + + const region = "us-east-1"; + const userPoolId = "us-east-1_123456"; + const loadBalancerArn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; + const clientId = "my-client-id"; + const badIssuer = `https://badissuer.amazonaws.com`; + const issuer = `https://cognito-idp.${region}.amazonaws.com/${userPoolId}`; + const jwksUri = `https://public-keys.auth.elb.${region}.amazonaws.com`; + const kid = keypair.jwk.kid; + const exp = 4000000000;// nock and jest.useFakeTimers do not work well together. Used of a long expired date instead + + const signedJwt = signJwt( + { + typ:"JWT", + kid, + alg:"ES256", + iss:badIssuer, + client:clientId, + signer:loadBalancerArn, + exp + }, + { + hello: "world", + exp, + iss:badIssuer, + }, + keypair.privateKey + ); + const albVerifier = AlbDataVerifier.create({ + issuer, + clientId, + loadBalancerArn, + jwksUri + }); + + albVerifier.cacheJwks(keypair.jwks); + + expect.assertions(1); + expect( + albVerifier.verify(signedJwt) + ).rejects.toThrow(JwtInvalidIssuerError); + }); + + test("invalid signer", () => { + + const region = "us-east-1"; + const userPoolId = "us-east-1_123456"; + const loadBalancerArn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; + const badSigner = "arn:aws:elasticloadbalancing:us-east-1:badaccount:loadbalancer/app/badloadbalancer/50dc6c495c0c9188"; + const clientId = "my-client-id"; + const issuer = `https://cognito-idp.${region}.amazonaws.com/${userPoolId}`; + const jwksUri = `https://public-keys.auth.elb.${region}.amazonaws.com`; + const kid = keypair.jwk.kid; + const exp = 4000000000;// nock and jest.useFakeTimers do not work well together. Used of a long expired date instead + + const signedJwt = signJwt( + { + typ:"JWT", + kid, + alg:"ES256", + iss:issuer, + client:clientId, + signer:badSigner, + exp + }, + { + hello: "world", + exp, + iss:issuer, + }, + keypair.privateKey + ); + const albVerifier = AlbDataVerifier.create({ + issuer, + clientId, + loadBalancerArn, + jwksUri + }); + + albVerifier.cacheJwks(keypair.jwks); + + expect.assertions(1); + expect( + albVerifier.verify(signedJwt) + ).rejects.toThrow(AlbJwtInvalidSignerError); + }); + + + test("invalid client id", () => { + + const region = "us-east-1"; + const userPoolId = "us-east-1_123456"; + const loadBalancerArn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; + const clientId = "my-client-id"; + const badClientId = "bad-client-id"; + const issuer = `https://cognito-idp.${region}.amazonaws.com/${userPoolId}`; + const jwksUri = `https://public-keys.auth.elb.${region}.amazonaws.com`; + const kid = keypair.jwk.kid; + const exp = 4000000000;// nock and jest.useFakeTimers do not work well together. Used of a long expired date instead + + const signedJwt = signJwt( + { + typ:"JWT", + kid, + alg:"ES256", + iss:issuer, + client:badClientId, + signer:loadBalancerArn, + exp + }, + { + hello: "world", + exp, + iss:issuer, + }, + keypair.privateKey + ); + const albVerifier = AlbDataVerifier.create({ + issuer, + clientId, + loadBalancerArn, + jwksUri + }); + + albVerifier.cacheJwks(keypair.jwks); + + expect.assertions(1); + expect( + albVerifier.verify(signedJwt) + ).rejects.toThrow(AlbJwtInvalidClientIdError); + }); + + test("null client id", async () => { + + const region = "us-east-1"; + const userPoolId = "us-east-1_123456"; + const loadBalancerArn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; + const clientId = "my-client-id"; + const issuer = `https://cognito-idp.${region}.amazonaws.com/${userPoolId}`; + const jwksUri = `https://public-keys.auth.elb.${region}.amazonaws.com`; + const kid = keypair.jwk.kid; + const exp = 4000000000;// nock and jest.useFakeTimers do not work well together. Used of a long expired date instead + + const signedJwt = signJwt( + { + typ:"JWT", + kid, + alg:"ES256", + iss:issuer, + client:clientId, + signer:loadBalancerArn, + exp + }, + { + hello: "world", + exp, + iss:issuer, + }, + keypair.privateKey + ); + const albVerifier = AlbDataVerifier.create({ + issuer, + clientId:null, + loadBalancerArn, + jwksUri + }); + + albVerifier.cacheJwks(keypair.jwks); + + expect.assertions(1); + expect( + await albVerifier.verify(signedJwt) + ).toMatchObject({ hello: "world" }); + }); + + }); + }); +}); diff --git a/tests/unit/alb.test.ts b/tests/unit/alb.test.ts new file mode 100644 index 0000000..3962037 --- /dev/null +++ b/tests/unit/alb.test.ts @@ -0,0 +1,68 @@ +import { AwsAlbJwksCache } from "../../src/alb"; +import { JwtWithoutValidKidError } from "../../src/error"; +import { + allowAllRealNetworkTraffic, + disallowAllRealNetworkTraffic, + mockHttpsUri, +} from "./test-util"; +import { readFileSync } from "fs"; +import { join } from "path"; + +describe("alb", () => { + const kid = "00000000-0000-0000-0000-000000000000"; + const jwksUri = "https://public-keys.auth.elb.eu-west-1.amazonaws.com"; + + const albResponse = readFileSync(join(__dirname, "alb-jwks-test.pem")); + + const decomposedJwt ={ + header: { + alg: "ES256", + kid + }, + payload: {}, + }; + + beforeAll(() => { + disallowAllRealNetworkTraffic(); + }); + afterAll(() => { + allowAllRealNetworkTraffic(); + }); + + test("ALB JWKS cache fetches URI one attempt at a time", async () => { + /** + * Test what happens when the the JWKS URI is requested multiple times in parallel + * (e.g. in parallel promises). When this happens only 1 actual HTTPS request should + * be made to the JWKS URI. + */ + + mockHttpsUri(`${jwksUri}/${kid}`, { + responsePayload: albResponse, + }); + + const jwksCache = new AwsAlbJwksCache(); + + const promise1 = jwksCache.getJwk(jwksUri, decomposedJwt); + const promise2 = jwksCache.getJwk(jwksUri, decomposedJwt); + expect.assertions(1); + expect(promise1).toEqual(promise2); + await Promise.all([promise1, promise2]); + }); + + test("Invalid kid", () => { + + const decomposedJwt ={ + header: { + alg: "ES256", + kid: "invalid-kid" + }, + payload: {}, + }; + + const jwksCache = new AwsAlbJwksCache(); + + expect.assertions(1); + expect(()=>jwksCache.getJwk(jwksUri, decomposedJwt)).rejects.toThrow(JwtWithoutValidKidError); + }); + +}); diff --git a/tests/unit/cache.test.ts b/tests/unit/cache.test.ts new file mode 100644 index 0000000..6b6f491 --- /dev/null +++ b/tests/unit/cache.test.ts @@ -0,0 +1,129 @@ +import { + SimpleLruCache, +} from "../../src/cache"; + +describe("unit tests cache", () => { + + test("CacheLru get undefined", () => { + const cache = new SimpleLruCache(2); + return expect(cache.get("key1")).toBeUndefined(); + }); + + test("CacheLru get value1 with 1 element", () => { + const cache = new SimpleLruCache(3); + cache.set("key1","value1"); + + expect(cache.size).toBe(1); + expect(cache.toArray()).toStrictEqual([["key1","value1"]]); + + return expect(cache.get("key1")).toBe("value1"); + }); + + test("CacheLru get value1 with 2 elements", () => { + const cache = new SimpleLruCache(3); + + cache.set("key1","value1"); + cache.set("key2","value2"); + + expect(cache.capacity).toBe(3); + expect(cache.size).toBe(2); + expect(cache.toArray()).toStrictEqual([["key1","value1"],["key2","value2"]]); + + return expect(cache.get("key1")).toBe("value1"); + }); + + + test("CacheLru get value1 with 3 elements", () => { + const cache = new SimpleLruCache(3); + + cache.set("key1","value1"); + cache.set("key2","value2"); + cache.set("key3","value3"); + + expect(cache.capacity).toBe(3); + expect(cache.size).toBe(3); + expect(cache.toArray()).toStrictEqual([["key1","value1"],["key2","value2"],["key3","value3"]]); + + return expect(cache.get("key1")).toBe("value1"); + }); + + + test("CacheLru get value 1 with 4 elements", () => { + const cache = new SimpleLruCache(3); + + cache.set("key1","value1"); + cache.set("key2","value2"); + cache.set("key3","value3"); + cache.set("key4","value4"); + + expect(cache.capacity).toBe(3); + expect(cache.size).toBe(3); + expect(cache.toArray()).toStrictEqual([["key2","value2"],["key3","value3"],["key4","value4"]]); + + return expect(cache.get("key1")).toBeUndefined(); + }); + + + test("CacheLru change priority value1 with 2 elements", () => { + const cache = new SimpleLruCache(3); + + cache.set("key1","value1"); + cache.set("key2","value2"); + + const value = cache.get("key1"); + + expect(cache.capacity).toBe(3); + expect(cache.size).toBe(2); + expect(cache.toArray()).toStrictEqual([["key2","value2"],["key1","value1"]]); + + return expect(value).toBe("value1"); + }); + + test("CacheLru change priority value2 with 3 elements", () => { + const cache = new SimpleLruCache(3); + + cache.set("key1","value1"); + cache.set("key2","value2"); + cache.set("key3","value3"); + + const value = cache.get("key2"); + + expect(cache.capacity).toBe(3); + expect(cache.size).toBe(3); + expect(cache.toArray()).toStrictEqual([["key1","value1"],["key3","value3"],["key2","value2"]]); + + return expect(value).toBe("value2"); + + }); + + test("CacheLru change priority value3 with 4 elements", () => { + const cache = new SimpleLruCache(3); + + cache.set("key1","value1"); + cache.set("key2","value2"); + cache.set("key3","value3"); + cache.set("key4","value4"); + + const value = cache.get("key3"); + + expect(cache.capacity).toBe(3); + expect(cache.size).toBe(3); + expect(cache.toArray()).toStrictEqual([["key2","value2"],["key4","value4"],["key3","value3"]]); + + return expect(value).toBe("value3"); + + }); + + test("CacheLru update key1", () => { + const cache = new SimpleLruCache(3); + + cache.set("key1","value1"); + cache.set("key1","value2"); + + expect(cache.size).toBe(1); + expect(cache.toArray()).toStrictEqual([["key1","value2"]]); + + return expect(cache.get("key1")).toBe("value2"); + }); + +});