From e7bd1546b91e86bae1b41f6ea1e35e81ec5ad209 Mon Sep 17 00:00:00 2001 From: Nicolas Viaud Date: Mon, 13 Jan 2025 18:48:51 +0000 Subject: [PATCH 01/18] add the alb verifier and alb cache --- src/alb-cache.ts | 169 ++++++++++ src/alb-verifier.ts | 321 ++++++++++++++++++ src/cache.ts | 59 ++++ tests/unit/alb-verifier.test.ts | 577 ++++++++++++++++++++++++++++++++ 4 files changed, 1126 insertions(+) create mode 100644 src/alb-cache.ts create mode 100644 src/alb-verifier.ts create mode 100644 src/cache.ts create mode 100644 tests/unit/alb-verifier.test.ts diff --git a/src/alb-cache.ts b/src/alb-cache.ts new file mode 100644 index 0000000..7deb2b2 --- /dev/null +++ b/src/alb-cache.ts @@ -0,0 +1,169 @@ +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"; + +const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +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): boolean { + return uuidRegex.test(kid); +} + + 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/alb-verifier.ts b/src/alb-verifier.ts new file mode 100644 index 0000000..bea047c --- /dev/null +++ b/src/alb-verifier.ts @@ -0,0 +1,321 @@ +import { AwsAlbJwksCache } from "./alb-cache"; +import { assertStringArrayContainsString } from "./assert"; +import { JwtInvalidClaimError, ParameterValidationError } from "./error"; +import { Jwk, JwksCache } from "./jwk"; +import { JwtHeader, JwtPayload } from "./jwt-model"; // todo consider creating a specific type for AWS ALB JWT Payload +import { JwtVerifierBase, JwtVerifierProperties } from "./jwt-verifier"; +import { Properties } from "./typing-util"; + +export interface AlbVerifyProperties { + /** + * The client ID that you expect to be present in the JWT's client claim (in the JWT header). + * If you provide a string array, that means at least one of those client IDs + * must be present in the JWT's client claim. + * Pass null explicitly to not check the JWT's client ID--if you know what you're doing + */ + clientId: string | string[] | null; + /** + * The number of seconds after expiration (exp claim) or before not-before (nbf claim) that you will allow + * (use this to account for clock differences between systems) + */ + graceSeconds?: number; + /** + * Your custom function with checks. It will be called, at the end of the verification, + * after standard verification checks have all passed. + * Throw an error in this function if you want to reject the JWT for whatever reason you deem fit. + * Your function will be called with a properties object that contains: + * - the decoded JWT header + * - the decoded JWT payload + * - the JWK that was used to verify the JWT's signature + */ + customJwtCheck?: (props: { + header: JwtHeader; + payload: JwtPayload; + jwk: Jwk; + }) => Promise | void; + /** + * 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 JWT verifier properties, for a single issuer */ +export type AlbJwtVerifierProperties = { + /** + * URI where the JWKS (JSON Web Key Set) can be downloaded from. + * The JWKS contains one or more JWKs, which represent the public keys with which + * JWTs have been signed. + */ + jwksUri?: string; + /** + * The issuer of the JWTs you want to verify. + * Set this to the expected value of the `iss` claim in the JWT. + */ + issuer: string; + /** + * The ARN of the Application Load Balancer (ALB) that signs the JWT. + * Set this to the expected value of the `signer` claim in the JWT (JWT header). + * If you provide a string array, that means at least one of those ALB ARNs + * must be present in the JWT's signer claim. + * Pass null explicitly to not check the JWT's signer--if you know what you're doing + */ + albArn: string | string[] | null; +} & Partial; + +/** + * Type for JWT verifier properties, when multiple issuers are used in the verifier. + * In this case, you should be explicit in mapping audience to issuer. + */ +export type AlbJwtVerifierMultiProperties = { + /** + * URI where the JWKS (JSON Web Key Set) can be downloaded from. + * The JWKS contains one or more JWKs, which represent the public keys with which + * JWTs have been signed. + */ + jwksUri?: string; + /** + * The issuer of the JWTs you want to verify. + * Set this to the expected value of the `iss` claim in the JWT. + */ + issuer: string; + /** + * The ARN of the Application Load Balancer (ALB) that signs the JWT. + * Set this to the expected value of the `signer` claim in the JWT (JWT header). + * If you provide a string array, that means at least one of those ALB ARNs + * must be present in the JWT's signer claim. + * Pass null explicitly to not check the JWT's signer--if you know what you're doing + */ + albArn: string | string[] | null; +} & AlbVerifyProperties; + +/** + * ALB JWT Verifier for a single issuer + */ +export type AlbJwtVerifierSingleUserPool = + AlbJwtVerifier< + Properties, + T & + JwtVerifierProperties & { + albArn: string | string[] | null; + audience: null; + }, + false + >; + +/** + * ALB JWT Verifier for multiple issuer + */ +export type AlbJwtVerifierMultiUserPool< + T extends AlbJwtVerifierMultiProperties, +> = AlbJwtVerifier< + Properties, + T & + JwtVerifierProperties & { + albArn: string | string[] | null; + audience: null; + }, + true +>; + +/** + * Parameters used for verification of a JWT. + * The first parameter is the JWT, which is (of course) mandatory. + * The second parameter is an object with specific properties to use during verification. + * The second parameter is only mandatory if its mandatory members (e.g. client_id) were not + * yet provided at verifier level. In that case, they must now be provided. + */ +type AlbVerifyParameters = { + [key: string]: never; +} extends SpecificVerifyProperties + ? [jwt: string, props?: SpecificVerifyProperties] + : [jwt: string, props: SpecificVerifyProperties]; + +/** + * Class representing a verifier for JWTs signed by AWS ALB + */ +export class AlbJwtVerifier< + SpecificVerifyProperties extends Partial, + IssuerConfig extends JwtVerifierProperties & { + audience: null; + albArn: string | string[] | null; + }, + MultiIssuer extends boolean, +> extends JwtVerifierBase { + private constructor( + props: AlbJwtVerifierProperties | AlbJwtVerifierMultiProperties[], + jwksCache: JwksCache = new AwsAlbJwksCache() + ) { + const issuerConfig = Array.isArray(props) + ? (props.map((p) => ({ + jwksUri: "TODO", + ...p, + audience: null, + })) as IssuerConfig[]) + : ({ + jwksUri: "TODO", + ...props, + audience: null, + } as IssuerConfig); + super(issuerConfig, jwksCache); + } + + private defaultJwksUri(params:{albArn: string | string[] | null}): string { + let region: string; + if(params.albArn === null){ + throw new ParameterValidationError("ALB ARN cannot be null"); + }else if(Array.isArray(params.albArn)){ + const regions = params.albArn.map(this.extractRegionFromLoadBalancerArn); + const uniqueRegions = Array.from(new Set(regions)); + if (uniqueRegions.length > 2) { + throw new ParameterValidationError("More than 2 distinct regions found in ALB ARNs"); + } + region = uniqueRegions[0]; + }else { + region = this.extractRegionFromLoadBalancerArn(params.albArn); + } + return `https://public-keys.auth.elb.${region}.amazonaws.com`; + } + + private extractRegionFromLoadBalancerArn(albArn: string): string { + const arnParts = albArn.split(":"); + if (arnParts.length < 4) { + throw new ParameterValidationError(`Invalid load balancer ARN: ${albArn}`); + } + return arnParts[3]; + } + + /** + * Create an JWT verifier for a single issuer + * + * @param verifyProperties The verification properties for your issuer + * @param additionalProperties Additional properties + * @param additionalProperties.jwksCache Overriding JWKS cache that you want to use + * @returns An JWT Verifier instance, that you can use to verify JWTs with + */ + static create( + verifyProperties: T & Partial, + additionalProperties?: { jwksCache: JwksCache } + ): AlbJwtVerifierSingleUserPool; + + /** + * Create a JWT verifier for multiple issuer + * + * @param verifyProperties An array of verification properties, one for each issuer + * @param additionalProperties Additional properties + * @param additionalProperties.jwksCache Overriding JWKS cache that you want to use + * @returns A JWT Verifier instance, that you can use to verify JWTs with + */ + static create( + props: (T & Partial)[], + additionalProperties?: { jwksCache: JwksCache } + ): AlbJwtVerifierMultiUserPool; + + // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types + static create( + verifyProperties: + | AlbJwtVerifierProperties + | AlbJwtVerifierMultiProperties[], + additionalProperties?: { jwksCache: JwksCache } + ) { + return new this( + verifyProperties, + additionalProperties?.jwksCache // todo by default we should select the ALB specific cache here + ); + } + + /** + * Verify (synchronously) a JWT that is signed by AWS Application Load Balancer. + * + * @param jwt The JWT, as string + * @param props Verification properties + * @returns The payload of the JWT––if the JWT is valid, otherwise an error is thrown + */ + public verifySync( + ...[jwt, properties]: AlbVerifyParameters + ): JwtPayload { + const { decomposedJwt, jwksUri, verifyProperties } = + this.getVerifyParameters(jwt, properties); + this.verifyDecomposedJwtSync(decomposedJwt, jwksUri, verifyProperties); + try { + validateAlbJwtFields(decomposedJwt.header, verifyProperties); + } catch (err) { + if ( + verifyProperties.includeRawJwtInErrors && + err instanceof JwtInvalidClaimError + ) { + throw err.withRawJwt(decomposedJwt); + } + throw err; + } + return decomposedJwt.payload; + } + + /** + * Verify (asynchronously) a JWT that is signed by AWS Application Load Balancer. + * This call is asynchronous, and the JWKS will be fetched from the JWKS uri, + * in case it is not yet available in the cache. + * + * @param jwt The JWT, as string + * @param props Verification properties + * @returns Promise that resolves to the payload of the JWT––if the JWT is valid, otherwise the promise rejects + */ + public async verify( + ...[jwt, properties]: AlbVerifyParameters + ): Promise { + const { decomposedJwt, jwksUri, verifyProperties } = + this.getVerifyParameters(jwt, properties); + await this.verifyDecomposedJwt(decomposedJwt, jwksUri, verifyProperties); + try { + validateAlbJwtFields(decomposedJwt.header, verifyProperties); + } catch (err) { + if ( + verifyProperties.includeRawJwtInErrors && + err instanceof JwtInvalidClaimError + ) { + throw err.withRawJwt(decomposedJwt); + } + throw err; + } + return decomposedJwt.payload; + } +} + +export function validateAlbJwtFields( + header: JwtHeader, + options: { + clientId?: string | string[] | null; + albArn?: string | string[] | null; + } +): void { + // Check ALB ARN (signer) + if (options.albArn !== null) { + if (options.albArn === undefined) { + throw new ParameterValidationError( + "albArn must be provided or set to null explicitly" + ); + } + assertStringArrayContainsString( + "ALB ARN", + header.signer, + options.albArn + // todo create new error type + ); + } + // Check clientId + 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 + // todo create new error type + ); + } +} diff --git a/src/cache.ts b/src/cache.ts new file mode 100644 index 0000000..324c37d --- /dev/null +++ b/src/cache.ts @@ -0,0 +1,59 @@ + +export class SimpleLruCache { + + private index:Map; + + constructor(public readonly capacity:number){ + if(capacity<1){ + throw new Error(`capacity must be greater than 0, but got ${capacity}`); + } + 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/tests/unit/alb-verifier.test.ts b/tests/unit/alb-verifier.test.ts new file mode 100644 index 0000000..40fd143 --- /dev/null +++ b/tests/unit/alb-verifier.test.ts @@ -0,0 +1,577 @@ +import { + generateKeyPair, + signJwt, + allowAllRealNetworkTraffic, + disallowAllRealNetworkTraffic, + mockHttpsUri, +} from "./test-util"; +import { AlbJwtVerifier } 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 albArn = "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:albArn, + exp + }, + { + hello: "world", + exp, + iss:issuer, + }, + keypair.privateKey + ); + const albVerifier = AlbJwtVerifier.create({ + issuer, + clientId, + albArn, + 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 albArn = "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:albArn, + exp + }, + { + hello: "world", + exp, + iss:issuer, + }, + keypair.privateKey + ); + const albVerifier = AlbJwtVerifier.create({ + issuer, + clientId, + albArn, + jwksUri, + }); + expect.assertions(1); + expect( + await albVerifier.verify(signedJwt) + ).toMatchObject({ hello: "world" }); + }); + + test("flow with no jwksUri", async () => { + + const region = "us-east-1"; + const userPoolId = "us-east-1_123456"; + const albArn = "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:albArn, + exp + }, + { + hello: "world", + exp, + iss:issuer, + }, + keypair.privateKey + ); + const albVerifier = AlbJwtVerifier.create({ + issuer, + clientId, + albArn, + }); + 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 userPoolId1 = "us-east-1_123456"; + const userPoolId2 = "us-east-1_654321"; + const albArn1 = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/AAAAAAAAAAAAAAAA"; + const albArn2 = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/BBBBBBBBBBBBBBBB"; + const clientId1 = "my-client-id1"; + const clientId2 = "my-client-id2"; + const issuer1 = `https://cognito-idp.${region}.amazonaws.com/${userPoolId1}`; + const issuer2 = `https://cognito-idp.${region}.amazonaws.com/${userPoolId2}`; + 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:issuer1, + client:clientId1, + signer:albArn1, + exp + }, + { + hello: "world1", + exp, + iss:issuer1, + }, + keypair1.privateKey + ); + + const signedJwt2 = signJwt( + { + typ:"JWT", + kid:keypair2.jwk.kid, + alg:"ES256", + iss:issuer2, + client:clientId2, + signer:albArn2, + exp + }, + { + hello: "world2", + exp, + iss:issuer2, + }, + keypair2.privateKey + ); + const albVerifier = AlbJwtVerifier.create([{ + issuer:issuer1, + clientId:clientId1, + albArn:albArn1, + jwksUri, + },{ + issuer:issuer2, + clientId:clientId2, + albArn:albArn2, + jwksUri, + }]); + + expect.assertions(2); + + expect( + await albVerifier.verify(signedJwt1) + ).toMatchObject({ hello: "world1" }); + + expect( + await albVerifier.verify(signedJwt2) + ).toMatchObject({ hello: "world2" }); + }); + + test("happy flow with multi alb", 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 albArn1 = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/AAAAAAAAAAAAAAAA"; + const albArn2 = "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:albArn1, + exp + }, + { + hello: "world1", + exp, + iss:issuer, + }, + keypair1.privateKey + ); + + const signedJwt2 = signJwt( + { + typ:"JWT", + kid:keypair2.jwk.kid, + alg:"ES256", + iss:issuer, + client:clientId, + signer:albArn2, + exp + }, + { + hello: "world2", + exp, + iss:issuer, + }, + keypair2.privateKey + ); + const albVerifier = AlbJwtVerifier.create({ + issuer, + clientId, + albArn:[albArn1,albArn2], + 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 albArn = "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:albArn, + exp + }, + { + hello: "world1", + exp, + iss:issuer, + }, + keypair.privateKey + ); + + const albVerifier = AlbJwtVerifier.create({ + issuer, + clientId, + albArn:albArn, + }); + + albVerifier.cacheJwks(keypair.jwks,albArn); + + 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 albArn = "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:albArn, + exp + }, + { + hello: "world", + exp, + iss:badIssuer, + }, + keypair.privateKey + ); + const albVerifier = AlbJwtVerifier.create({ + issuer, + clientId, + albArn, + 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 albArn = "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 = AlbJwtVerifier.create({ + issuer, + clientId, + albArn, + jwksUri + }); + + albVerifier.cacheJwks(keypair.jwks); + + expect.assertions(1); + expect( + albVerifier.verify(signedJwt) + ).rejects.toThrow(); + }); + + + test("invalid client id", () => { + + const region = "us-east-1"; + const userPoolId = "us-east-1_123456"; + const albArn = "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:albArn, + exp + }, + { + hello: "world", + exp, + iss:issuer, + }, + keypair.privateKey + ); + const albVerifier = AlbJwtVerifier.create({ + issuer, + clientId, + albArn, + jwksUri + }); + + albVerifier.cacheJwks(keypair.jwks); + + expect.assertions(1); + expect( + albVerifier.verify(signedJwt) + ).rejects.toThrow(); + }); + + test("null client id", async () => { + + const region = "us-east-1"; + const userPoolId = "us-east-1_123456"; + const albArn = "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:albArn, + exp + }, + { + hello: "world", + exp, + iss:issuer, + }, + keypair.privateKey + ); + const albVerifier = AlbJwtVerifier.create({ + issuer, + clientId:null, + albArn, + jwksUri + }); + + albVerifier.cacheJwks(keypair.jwks); + + expect.assertions(1); + expect( + await albVerifier.verify(signedJwt) + ).toMatchObject({ hello: "world" }); + }); + + }); + }); +}); From abaa61be238468cf0710d1873d84072ad1543838 Mon Sep 17 00:00:00 2001 From: Nicolas Viaud Date: Tue, 14 Jan 2025 22:27:30 +0000 Subject: [PATCH 02/18] add default jwks uri + add unit tests --- src/alb-verifier.ts | 58 +-- tests/unit/alb-verifier.test.ts | 820 ++++++++++++++++++++++---------- 2 files changed, 592 insertions(+), 286 deletions(-) diff --git a/src/alb-verifier.ts b/src/alb-verifier.ts index bea047c..ab34281 100644 --- a/src/alb-verifier.ts +++ b/src/alb-verifier.ts @@ -148,45 +148,21 @@ export class AlbJwtVerifier< props: AlbJwtVerifierProperties | AlbJwtVerifierMultiProperties[], jwksCache: JwksCache = new AwsAlbJwksCache() ) { + const issuerConfig = Array.isArray(props) ? (props.map((p) => ({ - jwksUri: "TODO", + jwksUri: p.jwksUri ?? defaultJwksUri(p.albArn), ...p, audience: null, })) as IssuerConfig[]) : ({ - jwksUri: "TODO", + jwksUri: props.jwksUri ?? defaultJwksUri(props.albArn), ...props, audience: null, } as IssuerConfig); super(issuerConfig, jwksCache); } - private defaultJwksUri(params:{albArn: string | string[] | null}): string { - let region: string; - if(params.albArn === null){ - throw new ParameterValidationError("ALB ARN cannot be null"); - }else if(Array.isArray(params.albArn)){ - const regions = params.albArn.map(this.extractRegionFromLoadBalancerArn); - const uniqueRegions = Array.from(new Set(regions)); - if (uniqueRegions.length > 2) { - throw new ParameterValidationError("More than 2 distinct regions found in ALB ARNs"); - } - region = uniqueRegions[0]; - }else { - region = this.extractRegionFromLoadBalancerArn(params.albArn); - } - return `https://public-keys.auth.elb.${region}.amazonaws.com`; - } - - private extractRegionFromLoadBalancerArn(albArn: string): string { - const arnParts = albArn.split(":"); - if (arnParts.length < 4) { - throw new ParameterValidationError(`Invalid load balancer ARN: ${albArn}`); - } - return arnParts[3]; - } - /** * Create an JWT verifier for a single issuer * @@ -281,6 +257,7 @@ export class AlbJwtVerifier< } return decomposedJwt.payload; } + } export function validateAlbJwtFields( @@ -319,3 +296,30 @@ export function validateAlbJwtFields( ); } } + +function defaultJwksUri(albArn: string | string[] | null): string { + if (albArn === null) { + throw new ParameterValidationError("ALB ARN cannot be null"); + } + + const extractRegion = (arn: string): string => { + const arnParts = arn.split(":"); + if (arnParts.length < 4) { + throw new ParameterValidationError(`Invalid load balancer ARN: ${arn}`); + } + return arnParts[3]; + }; + + if (Array.isArray(albArn)) { + const regions = albArn.map(extractRegion); + const uniqueRegions = Array.from(new Set(regions)); + if (uniqueRegions.length > 1) { + throw new ParameterValidationError("Multiple regions found in ALB ARNs"); + } + return `https://public-keys.auth.elb.${uniqueRegions[0]}.amazonaws.com`; + } else { + const region = extractRegion(albArn); + return `https://public-keys.auth.elb.${region}.amazonaws.com`; + } + +} diff --git a/tests/unit/alb-verifier.test.ts b/tests/unit/alb-verifier.test.ts index 40fd143..5996a70 100644 --- a/tests/unit/alb-verifier.test.ts +++ b/tests/unit/alb-verifier.test.ts @@ -5,9 +5,18 @@ import { disallowAllRealNetworkTraffic, mockHttpsUri, } from "./test-util"; +import { decomposeUnverifiedJwt } from "../../src/jwt"; +import { JwksCache, Jwks } from "../../src/jwk"; import { AlbJwtVerifier } from "../../src/alb-verifier"; +import { + ParameterValidationError, + JwtInvalidClaimError, + JwtParseError, + JwtInvalidIssuerError, + JwtInvalidSignatureAlgorithmError, + FailedAssertionError, +} from "../../src/error"; import { createPublicKey } from "crypto"; -import { JwtInvalidIssuerError } from "../../src/error"; describe("unit tests alb verifier", () => { let keypair: ReturnType; @@ -25,15 +34,12 @@ describe("unit tests alb verifier", () => { describe("AlbJwtVerifier", () => { describe("verify", () => { - test("happy flow with cached public key", async () => { - - const region = "us-east-1"; + test("happy flow", async () => { + const kid = keypair.jwk.kid; const userPoolId = "us-east-1_123456"; + const issuer = `https://cognito-idp.us-east-1.amazonaws.com/${userPoolId}`; const albArn = "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( @@ -53,43 +59,240 @@ describe("unit tests alb verifier", () => { }, keypair.privateKey ); + const decomposedJwt = decomposeUnverifiedJwt(signedJwt); + const customJwtCheck = jest.fn(); const albVerifier = AlbJwtVerifier.create({ - issuer, - clientId, albArn, - jwksUri + issuer, + customJwtCheck, }); albVerifier.cacheJwks(keypair.jwks); - expect.assertions(1); + expect.assertions(2); expect( - await albVerifier.verify(signedJwt) + await albVerifier.verify(signedJwt, { + clientId: null, + }) ).toMatchObject({ hello: "world" }); + expect(customJwtCheck).toHaveBeenCalledWith({ + header: decomposedJwt.header, + payload: decomposedJwt.payload, + jwk: keypair.jwk, + }); }); - - test("happy flow with public key fetching", async () => { + }); + describe("includeRawJwtInErrors", () => { + test("verify - flag set at statement level", () => { + const kid = keypair.jwk.kid; + const userPoolId = "us-east-1_123456"; + const issuer = `https://cognito-idp.us-east-1.amazonaws.com/${userPoolId}`; + const albArn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; + const clientId = "my-client-id"; + const exp = 4000000000;// nock and jest.useFakeTimers do not work well together. Used of a long expired date instead - const region = "us-east-1"; + const header = { + typ:"JWT", + kid, + alg:"ES256", + iss:"badissuer", + client:clientId, + signer:albArn, + exp + }; + const payload = { + hello: "world", + exp, + iss:"badissuer", + }; + const signedJwt = signJwt(header, payload, keypair.privateKey); + const albVerifier = AlbJwtVerifier.create({ + albArn, + issuer, + }); + albVerifier.cacheJwks(keypair.jwks); + const statement = () => + albVerifier.verify(signedJwt, { + clientId: null, + includeRawJwtInErrors: true, + }); + expect.assertions(2); + expect(statement).rejects.toThrow(JwtInvalidIssuerError); + return statement().catch((err) => { + expect((err as JwtInvalidClaimError).rawJwt).toMatchObject({ + header, + payload, + }); + }); + }); + test("verify - flag set at verifier level", () => { + const kid = keypair.jwk.kid; const userPoolId = "us-east-1_123456"; + const issuer = `https://cognito-idp.us-east-1.amazonaws.com/${userPoolId}`; const albArn = "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 header = { + typ:"JWT", + kid, + alg:"ES256", + iss:"badissuer", + client:clientId, + signer:albArn, + exp + }; + const payload = { + hello: "world", + exp, + iss:"badissuer", + }; + const signedJwt = signJwt(header, payload, keypair.privateKey); + const albVerifier = AlbJwtVerifier.create({ + albArn, + issuer, + includeRawJwtInErrors: true, }); - + albVerifier.cacheJwks(keypair.jwks); + const statement = () => + albVerifier.verify(signedJwt, { + clientId: null, + }); + expect.assertions(2); + expect(statement).rejects.toThrow(); + return statement().catch((err) => { + expect((err as JwtInvalidClaimError).rawJwt).toMatchObject({ + header, + payload, + }); + }); + }); + test("verify - flag NOT set", () => { + const kid = keypair.jwk.kid; + const userPoolId = "us-east-1_123456"; + const issuer = `https://cognito-idp.us-east-1.amazonaws.com/${userPoolId}`; + const albArn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; + const clientId = "my-client-id"; + const exp = 4000000000;// nock and jest.useFakeTimers do not work well together. Used of a long expired date instead + const header = { + typ:"JWT", + kid, + alg:"ES256", + iss:"badissuer", + client:clientId, + signer:albArn, + exp + }; + const payload = { + hello: "world", + exp, + iss:"badissuer", + }; + const signedJwt = signJwt(header, payload, keypair.privateKey); + const albVerifier = AlbJwtVerifier.create({ + albArn, + issuer, + }); + albVerifier.cacheJwks(keypair.jwks); + const statement = () => + albVerifier.verify(signedJwt, { + clientId: null, + }); + expect.assertions(2); + expect(statement).rejects.toThrow(JwtInvalidIssuerError); + return statement().catch((err) => { + expect((err as JwtInvalidClaimError).rawJwt).toBe(undefined); + }); + }); + test("verifySync - flag set at verifier level", () => { + const kid = keypair.jwk.kid; + const userPoolId = "us-east-1_123456"; + const issuer = `https://cognito-idp.us-east-1.amazonaws.com/${userPoolId}`; + const albArn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; + const clientId = "my-client-id"; + const exp = 4000000000;// nock and jest.useFakeTimers do not work well together. Used of a long expired date instead + const header = { + typ:"JWT", + kid, + alg:"ES256", + iss:"badissuer", + client:clientId, + signer:albArn, + exp + }; + const payload = { + hello: "world", + exp, + iss:"badissuer", + }; + const signedJwt = signJwt(header, payload, keypair.privateKey); + const albVerifier = AlbJwtVerifier.create({ + albArn, + issuer, + includeRawJwtInErrors: true, + }); + albVerifier.cacheJwks(keypair.jwks); + const statement = () => + albVerifier.verifySync(signedJwt, { + clientId: null, + }); + expect.assertions(2); + expect(statement).toThrow(JwtInvalidIssuerError); + try { + statement(); + } catch (err) { + expect((err as JwtInvalidClaimError).rawJwt).toMatchObject({ + header, + payload, + }); + } + }); + test("verifySync - flag NOT set", () => { + const kid = keypair.jwk.kid; + const userPoolId = "us-east-1_123456"; + const issuer = `https://cognito-idp.us-east-1.amazonaws.com/${userPoolId}`; + const albArn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; + const clientId = "my-client-id"; + const exp = 4000000000;// nock and jest.useFakeTimers do not work well together. Used of a long expired date instead + const header = { + typ:"JWT", + kid, + alg:"ES256", + iss:"badissuer", + client:clientId, + signer:albArn, + exp + }; + const payload = { + hello: "world", + exp, + iss:"badissuer", + }; + const signedJwt = signJwt(header, payload, keypair.privateKey); + const albVerifier = AlbJwtVerifier.create({ + albArn, + issuer, + }); + albVerifier.cacheJwks(keypair.jwks); + const statement = () => + albVerifier.verifySync(signedJwt, { + clientId: null, + }); + expect.assertions(2); + expect(statement).toThrow(JwtInvalidIssuerError); + try { + statement(); + } catch (err) { + expect((err as JwtInvalidClaimError).rawJwt).toEqual(undefined); + } + }); + }); + describe("verifySync", () => { + test("happy flow", () => { + const kid = keypair.jwk.kid; + const userPoolId = "us-east-1_123456"; + const issuer = `https://cognito-idp.us-east-1.amazonaws.com/${userPoolId}`; + const albArn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; + const clientId = "my-client-id"; + const exp = 4000000000;// nock and jest.useFakeTimers do not work well together. Used of a long expired date instead + const signedJwt = signJwt( { typ:"JWT", @@ -107,19 +310,19 @@ describe("unit tests alb verifier", () => { }, keypair.privateKey ); - const albVerifier = AlbJwtVerifier.create({ - issuer, - clientId, + const albVerifier = AlbJwtVerifier.create({ albArn, - jwksUri, + issuer, }); - expect.assertions(1); + albVerifier.cacheJwks(keypair.jwks); expect( - await albVerifier.verify(signedJwt) + albVerifier.verifySync(signedJwt, { + clientId: null, + }) ).toMatchObject({ hello: "world" }); }); - test("flow with no jwksUri", async () => { + test("clientId null", async () => { const region = "us-east-1"; const userPoolId = "us-east-1_123456"; @@ -127,22 +330,9 @@ describe("unit tests alb verifier", () => { 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 kid = keypair.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", @@ -162,247 +352,155 @@ describe("unit tests alb verifier", () => { ); const albVerifier = AlbJwtVerifier.create({ issuer, - clientId, + clientId:null, albArn, + jwksUri }); + + albVerifier.cacheJwks(keypair.jwks); + expect.assertions(1); expect( - await albVerifier.verify(signedJwt) + albVerifier.verifySync(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 userPoolId1 = "us-east-1_123456"; - const userPoolId2 = "us-east-1_654321"; - const albArn1 = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/AAAAAAAAAAAAAAAA"; - const albArn2 = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/BBBBBBBBBBBBBBBB"; - const clientId1 = "my-client-id1"; - const clientId2 = "my-client-id2"; - const issuer1 = `https://cognito-idp.${region}.amazonaws.com/${userPoolId1}`; - const issuer2 = `https://cognito-idp.${region}.amazonaws.com/${userPoolId2}`; - const jwksUri = `https://public-keys.auth.elb.${region}.amazonaws.com`; + test("clientId undefined", () => { + const kid = keypair.jwk.kid; + const userPoolId = "us-east-1_123456"; + const issuer = `https://cognito-idp.us-east-1.amazonaws.com/${userPoolId}`; + const albArn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; + const clientId = "my-client-id"; 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:issuer1, - client:clientId1, - signer:albArn1, - exp - }, - { - hello: "world1", - exp, - iss:issuer1, - }, - keypair1.privateKey - ); - - const signedJwt2 = signJwt( + const signedJwt = signJwt( { typ:"JWT", - kid:keypair2.jwk.kid, + kid, alg:"ES256", - iss:issuer2, - client:clientId2, - signer:albArn2, + iss:issuer, + client:clientId, + signer:albArn, exp }, { - hello: "world2", + hello: "world", exp, - iss:issuer2, + iss:issuer, }, - keypair2.privateKey + keypair.privateKey ); - const albVerifier = AlbJwtVerifier.create([{ - issuer:issuer1, - clientId:clientId1, - albArn:albArn1, - jwksUri, - },{ - issuer:issuer2, - clientId:clientId2, - albArn:albArn2, - jwksUri, - }]); + const verifier = AlbJwtVerifier.create({ + albArn, + issuer, + clientId: undefined as unknown as null, + }); + verifier.cacheJwks(keypair.jwks); expect.assertions(2); - - expect( - await albVerifier.verify(signedJwt1) - ).toMatchObject({ hello: "world1" }); - - expect( - await albVerifier.verify(signedJwt2) - ).toMatchObject({ hello: "world2" }); + expect(() => verifier.verifySync(signedJwt)).toThrow( + "clientId must be provided or set to null explicitly" + ); + expect(() => verifier.verifySync(signedJwt)).toThrow( + ParameterValidationError + ); }); - test("happy flow with multi alb", 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"}); + test("invalid issuer", () => { const region = "us-east-1"; const userPoolId = "us-east-1_123456"; - const albArn1 = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/AAAAAAAAAAAAAAAA"; - const albArn2 = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/BBBBBBBBBBBBBBBB"; + const albArn = "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 - - 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:albArn1, - exp - }, - { - hello: "world1", - exp, - iss:issuer, - }, - keypair1.privateKey - ); - - const signedJwt2 = signJwt( + const signedJwt = signJwt( { typ:"JWT", - kid:keypair2.jwk.kid, + kid, alg:"ES256", - iss:issuer, + iss:badIssuer, client:clientId, - signer:albArn2, + signer:albArn, exp }, { - hello: "world2", + hello: "world", exp, - iss:issuer, + iss:badIssuer, }, - keypair2.privateKey + keypair.privateKey ); const albVerifier = AlbJwtVerifier.create({ issuer, clientId, - albArn:[albArn1,albArn2], - jwksUri, + albArn, + jwksUri }); - expect.assertions(2); - - expect( - await albVerifier.verify(signedJwt1) - ).toMatchObject({ hello: "world1" }); + albVerifier.cacheJwks(keypair.jwks); + expect.assertions(1); expect( - await albVerifier.verify(signedJwt2) - ).toMatchObject({ hello: "world2" }); + () => albVerifier.verifySync(signedJwt) + ).toThrow(JwtInvalidIssuerError); }); - test("happy flow with default jwksUri", async () => { - + test("invalid signer", () => { + const region = "us-east-1"; const userPoolId = "us-east-1_123456"; - const albArn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/AAAAAAAAAAAAAAAA"; + const albArn = "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:keypair.jwk.kid, + kid, alg:"ES256", iss:issuer, client:clientId, - signer:albArn, + signer:badSigner, exp }, { - hello: "world1", + hello: "world", exp, iss:issuer, }, keypair.privateKey ); - const albVerifier = AlbJwtVerifier.create({ issuer, clientId, - albArn:albArn, + albArn, + jwksUri }); - - albVerifier.cacheJwks(keypair.jwks,albArn); + + albVerifier.cacheJwks(keypair.jwks); expect.assertions(1); - expect( - await albVerifier.verify(signedJwt) - ).toMatchObject({ hello: "world1" }); - + () => albVerifier.verifySync(signedJwt) + ).toThrow(FailedAssertionError); }); - - test("invalid issuer", () => { + + test("invalid clientId", () => { const region = "us-east-1"; const userPoolId = "us-east-1_123456"; const albArn = "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 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; @@ -413,15 +511,15 @@ describe("unit tests alb verifier", () => { typ:"JWT", kid, alg:"ES256", - iss:badIssuer, - client:clientId, + iss:issuer, + client:badClientId, signer:albArn, exp }, { hello: "world", exp, - iss:badIssuer, + iss:issuer, }, keypair.privateKey ); @@ -436,22 +534,37 @@ describe("unit tests alb verifier", () => { expect.assertions(1); expect( - albVerifier.verify(signedJwt) - ).rejects.toThrow(JwtInvalidIssuerError); + () => albVerifier.verifySync(signedJwt) + ).toThrow(FailedAssertionError); }); - test("invalid signer", () => { - + + }); + describe("jwksUri", () => { + test("default jwksUri", async () => { + const region = "us-east-1"; const userPoolId = "us-east-1_123456"; const albArn = "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 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", @@ -459,7 +572,7 @@ describe("unit tests alb verifier", () => { alg:"ES256", iss:issuer, client:clientId, - signer:badSigner, + signer:albArn, exp }, { @@ -473,37 +586,44 @@ describe("unit tests alb verifier", () => { issuer, clientId, albArn, - jwksUri }); - - albVerifier.cacheJwks(keypair.jwks); - expect.assertions(1); expect( - albVerifier.verify(signedJwt) - ).rejects.toThrow(); + await albVerifier.verify(signedJwt) + ).toMatchObject({ hello: "world" }); }); - - test("invalid client id", () => { - + test("custom jwksUri", async () => { + const region = "us-east-1"; const userPoolId = "us-east-1_123456"; const albArn = "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 jwksUri = `https://s3-us-gov-west-1.amazonaws.com/aws-elb-public-keys-prod-us-gov-west-1`; + 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:badClientId, + client:clientId, signer:albArn, exp }, @@ -520,58 +640,240 @@ describe("unit tests alb verifier", () => { albArn, jwksUri }); + expect.assertions(1); + expect( + await albVerifier.verify(signedJwt) + ).toMatchObject({ hello: "world" }); + }); + + test("can't extract region when null albArn and undefined jwksUri", async () => { - albVerifier.cacheJwks(keypair.jwks); + const region = "us-east-1"; + const userPoolId = "us-east-1_123456"; + const clientId = "my-client-id"; + const issuer = `https://cognito-idp.${region}.amazonaws.com/${userPoolId}`; - expect.assertions(1); expect( - albVerifier.verify(signedJwt) - ).rejects.toThrow(); + ()=>{ + AlbJwtVerifier.create({ + issuer, + clientId, + albArn:null + }) + } + ).toThrow(ParameterValidationError); + }); + }); + }); + + describe("AlbJwtVerifier with multiple alb", () => { + describe("verifySync", () => { + test("happy flow with 2 albs and 2 issuers", async () => { + const exp = 4000000000;// nock and jest.useFakeTimers do not work well together. Used of a long expired date instead + + const identityProviders = [ + { + config: { + albArn: "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/60dc6c495c0c9188", + issuer:"https://cognito-idp.us-east-1.amazonaws.com/us-east-1_qbc", + clientId: "client1", + }, + keypair: generateKeyPair({ + kid:"00000000-0000-0000-0000-000000000000", + kty:"EC", + alg:"ES256", + }), + }, + { + config: { + albArn: "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/", + issuer: "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_def", + clientId: "client2", + }, + keypair: generateKeyPair({ + kid:"11111111-0000-0000-0000-000000000000", + kty:"EC", + alg:"ES256", + }), + }, + ]; + const verifier = AlbJwtVerifier.create( + identityProviders.map((idp) => idp.config) + ); + + expect.assertions(identityProviders.length); + for (const idp of identityProviders) { + verifier.cacheJwks(idp.keypair.jwks, idp.config.issuer); + const signedJwt = signJwt( + { + typ:"JWT", + kid:idp.keypair.jwk.kid, + alg:"ES256", + iss:idp.config.issuer, + client:idp.config.clientId, + signer:idp.config.albArn, + exp + }, + { + hello: "world", + exp, + iss:idp.config.issuer, + }, + idp.keypair.privateKey + ); + expect(verifier.verify(signedJwt)).resolves.toMatchObject({ + hello: "world", + }); + } }); - test("null client id", async () => { + test("happy flow with 2 albs and 1 issuer", 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 albArn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; + const albArn1 = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/AAAAAAAAAAAAAAAA"; + const albArn2 = "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 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( + + const signedJwt1 = signJwt( { typ:"JWT", - kid, + kid:keypair1.jwk.kid, alg:"ES256", iss:issuer, client:clientId, - signer:albArn, + signer:albArn1, exp }, { - hello: "world", + hello: "world1", exp, iss:issuer, }, - keypair.privateKey + keypair1.privateKey + ); + + const signedJwt2 = signJwt( + { + typ:"JWT", + kid:keypair2.jwk.kid, + alg:"ES256", + iss:issuer, + client:clientId, + signer:albArn2, + exp + }, + { + hello: "world2", + exp, + iss:issuer, + }, + keypair2.privateKey ); + const albVerifier = AlbJwtVerifier.create({ issuer, - clientId:null, - albArn, - jwksUri + clientId, + albArn:[albArn1,albArn2], + jwksUri, }); - - albVerifier.cacheJwks(keypair.jwks); - expect.assertions(1); + albVerifier.cacheJwks(keypair1.jwks); + albVerifier.cacheJwks(keypair2.jwks); + expect.assertions(2); + expect( - await albVerifier.verify(signedJwt) - ).toMatchObject({ hello: "world" }); + await albVerifier.verify(signedJwt1) + ).toMatchObject({ hello: "world1" }); + + expect( + await albVerifier.verify(signedJwt2) + ).toMatchObject({ hello: "world2" }); }); + test("cache jwks with multiple IDPs needs issuer", () => { + const identityProviders = [ + { + config: { + albArn: "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/60dc6c495c0c9188", + issuer:"https://cognito-idp.us-east-1.amazonaws.com/us-east-1_qbc", + clientId: "client1", + }, + keypair: generateKeyPair({ + kid:"00000000-0000-0000-0000-000000000000", + kty:"EC", + alg:"ES256", + }), + }, + { + config: { + albArn: "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/", + issuer: "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_def", + clientId: "client2", + }, + keypair: generateKeyPair({ + kid:"11111111-0000-0000-0000-000000000000", + kty:"EC", + alg:"ES256", + }), + }, + ]; + const verifier = AlbJwtVerifier.create( + identityProviders.map((idp) => idp.config) + ); + const issuer: any = undefined; + const statement = () => + verifier.cacheJwks(keypair.jwks, issuer); + expect(statement).toThrow( + new ParameterValidationError("issuer must be provided") + ); + }); + test("custom JWKS cache", () => { + class CustomJwksCache implements JwksCache { + getJwks = jest + .fn() + .mockImplementation(async (_jwksUri?: string) => keypair.jwks); + addJwks = jest + .fn() + .mockImplementation((_jwksUri: string, _jwks: Jwks) => { + // This is intentional + }); + getCachedJwk = jest + .fn() + .mockImplementation( + (_jwksUri: string, _kid: string) => keypair.jwk + ); + getJwk = jest + .fn() + .mockImplementation( + async (_jwksUri: string, _kid: string) => keypair.jwk + ); + } + const customJwksCache = new CustomJwksCache(); + const userPoolId = "us-east-1_123456"; + const issuer = `https://cognito-idp.us-east-1.amazonaws.com/${userPoolId}`; + const albArn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; + + const jwksUri = "https://public-keys.auth.elb.us-east-1.amazonaws.com"; + const verifier = AlbJwtVerifier.create( + { + albArn, + issuer, + tokenUse: "id", + }, + { jwksCache: customJwksCache } + ); + verifier.cacheJwks(keypair.jwks); + expect(customJwksCache.addJwks).toHaveBeenCalledWith( + jwksUri, + keypair.jwks + ); + }); }); }); }); From 8678e7d129c7577f84bd5dab45c0e8c987f1d593 Mon Sep 17 00:00:00 2001 From: Nicolas Viaud Date: Fri, 17 Jan 2025 14:13:40 +0000 Subject: [PATCH 03/18] run prettier and linter --- src/alb-cache.ts | 94 +++--- src/alb-verifier.ts | 43 ++- src/cache.ts | 90 +++--- tests/unit/alb-verifier.test.ts | 558 ++++++++++++++++---------------- 4 files changed, 396 insertions(+), 389 deletions(-) diff --git a/src/alb-cache.ts b/src/alb-cache.ts index 7deb2b2..96182d4 100644 --- a/src/alb-cache.ts +++ b/src/alb-cache.ts @@ -5,17 +5,14 @@ import { JwtBaseError, JwtWithoutValidKidError, } from "./error"; -import { - JwkWithKid, - Jwks, - JwksCache, -} from "./jwk"; +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"; -const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const uuidRegex = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; interface DecomposedJwt { header: JwtHeader; @@ -27,19 +24,18 @@ type JwksUri = string; export class AlbUriError extends JwtBaseError {} /** - * - * Security considerations: + * + * 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(); + private jwkCache: SimpleLruCache = new SimpleLruCache(2); + private fetchingJwks: Map> = new Map(); constructor(props?: { fetcher?: Fetcher; @@ -63,9 +59,9 @@ export class AwsAlbJwksCache implements JwksCache { return kid; } -private isValidAlbKid(kid: string): boolean { + private isValidAlbKid(kid: string): boolean { return uuidRegex.test(kid); -} + } public async getJwk( jwksUri: string, @@ -74,39 +70,40 @@ private isValidAlbKid(kid: string): boolean { const kid = this.getKid(decomposedJwt); const jwksUriWithKid = this.expandWithKid(jwksUri, kid); const jwk = this.jwkCache.get(jwksUriWithKid); - if(jwk){ + if (jwk) { //cache hit return jwk; - }else{ + } else { //cache miss const fetchPromise = this.fetchingJwks.get(jwksUriWithKid); - if(fetchPromise){ + if (fetchPromise) { return fetchPromise; - }else{ + } 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) - + .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{ + + private pemToJwk(kid: string, pem: ArrayBuffer): JwkWithKid { const jwk = createPublicKey({ key: Buffer.from(pem), format: "pem", @@ -117,18 +114,18 @@ private isValidAlbKid(kid: string): boolean { assertStringEquals("JWK kty", jwk.kty, "EC", JwkInvalidKtyError); - return { + return { kid: kid, use: "sig", ...jwk, - } as JwkWithKid + } as JwkWithKid; } /** - * + * * @param Ex: https://public-keys.auth.elb.eu-west-1.amazonaws.com - * @param decomposedJwt - * @returns + * @param decomposedJwt + * @returns */ public getCachedJwk( jwksUri: string, @@ -137,9 +134,9 @@ private isValidAlbKid(kid: string): boolean { const kid = this.getKid(decomposedJwt); const jwksUriWithKid = this.expandWithKid(jwksUri, kid); const jwk = this.jwkCache.get(jwksUriWithKid); - if(jwk){ + if (jwk) { return jwk; - }else{ + } else { throw new JwksNotAvailableInCacheError( `JWKS for uri ${jwksUri} not yet available in cache` ); @@ -147,17 +144,17 @@ private isValidAlbKid(kid: string): boolean { } public addJwks(jwksUri: string, jwks: Jwks): void { - if(jwks.keys.length===1){ + if (jwks.keys.length === 1) { const jwk = jwks.keys[0]; - if(jwk.kid){ + if (jwk.kid) { const jwkWithKid = jwk as JwkWithKid; const kid = jwk.kid; const jwksUriWithKid = this.expandWithKid(jwksUri, kid); - this.jwkCache.set(jwksUriWithKid,jwkWithKid); - }else{ + this.jwkCache.set(jwksUriWithKid, jwkWithKid); + } else { throw new Error("TODO"); } - }else{ + } else { throw new Error("TODO"); } } @@ -165,5 +162,4 @@ private isValidAlbKid(kid: string): boolean { async getJwks(): Promise { throw new Error("Method not implemented."); } - } diff --git a/src/alb-verifier.ts b/src/alb-verifier.ts index ab34281..6d41577 100644 --- a/src/alb-verifier.ts +++ b/src/alb-verifier.ts @@ -148,7 +148,6 @@ export class AlbJwtVerifier< props: AlbJwtVerifierProperties | AlbJwtVerifierMultiProperties[], jwksCache: JwksCache = new AwsAlbJwksCache() ) { - const issuerConfig = Array.isArray(props) ? (props.map((p) => ({ jwksUri: p.jwksUri ?? defaultJwksUri(p.albArn), @@ -257,7 +256,6 @@ export class AlbJwtVerifier< } return decomposedJwt.payload; } - } export function validateAlbJwtFields( @@ -298,28 +296,27 @@ export function validateAlbJwtFields( } function defaultJwksUri(albArn: string | string[] | null): string { - if (albArn === null) { - throw new ParameterValidationError("ALB ARN cannot be null"); - } - - const extractRegion = (arn: string): string => { - const arnParts = arn.split(":"); - if (arnParts.length < 4) { - throw new ParameterValidationError(`Invalid load balancer ARN: ${arn}`); - } - return arnParts[3]; - }; + if (albArn === null) { + throw new ParameterValidationError("ALB ARN cannot be null"); + } - if (Array.isArray(albArn)) { - const regions = albArn.map(extractRegion); - const uniqueRegions = Array.from(new Set(regions)); - if (uniqueRegions.length > 1) { - throw new ParameterValidationError("Multiple regions found in ALB ARNs"); - } - return `https://public-keys.auth.elb.${uniqueRegions[0]}.amazonaws.com`; - } else { - const region = extractRegion(albArn); - return `https://public-keys.auth.elb.${region}.amazonaws.com`; + const extractRegion = (arn: string): string => { + const arnParts = arn.split(":"); + if (arnParts.length < 4) { + throw new ParameterValidationError(`Invalid load balancer ARN: ${arn}`); } + return arnParts[3]; + }; + if (Array.isArray(albArn)) { + const regions = albArn.map(extractRegion); + const uniqueRegions = Array.from(new Set(regions)); + if (uniqueRegions.length > 1) { + throw new ParameterValidationError("Multiple regions found in ALB ARNs"); + } + return `https://public-keys.auth.elb.${uniqueRegions[0]}.amazonaws.com`; + } else { + const region = extractRegion(albArn); + return `https://public-keys.auth.elb.${region}.amazonaws.com`; + } } diff --git a/src/cache.ts b/src/cache.ts index 324c37d..232c65f 100644 --- a/src/cache.ts +++ b/src/cache.ts @@ -1,59 +1,55 @@ +export class SimpleLruCache { + private index: Map; -export class SimpleLruCache { - - private index:Map; - - constructor(public readonly capacity:number){ - if(capacity<1){ - throw new Error(`capacity must be greater than 0, but got ${capacity}`); - } - this.index = new Map(); - } - - public get size(){ - return this.index.size; + constructor(public readonly capacity: number) { + if (capacity < 1) { + throw new Error(`capacity must be greater than 0, but got ${capacity}`); } + this.index = new Map(); + } - public get(key:Key):Value|undefined{ - const value = this.index.get(key); - if(value){ - this.moveFirst(key,value); - - return value; - }else{ - return undefined; - } - } + public get size() { + return this.index.size; + } - public set(key:Key, value:Value):this{ - if(this.size>=this.capacity){ - this.removeLast(); - } + public get(key: Key): Value | undefined { + const value = this.index.get(key); + if (value) { + this.moveFirst(key, value); - this.moveFirst(key,value); - - return this; + return value; + } else { + return undefined; } + } - private moveFirst(key:Key, value:Value){ - this.index.delete(key); - this.index.set(key,value); + public set(key: Key, value: Value): this { + if (this.size >= this.capacity) { + this.removeLast(); } - private removeLast(){ - const last = this.index.keys().next().value; - if(last){ - this.index.delete(last) - } - } + this.moveFirst(key, value); + + return this; + } - /** - * - * @returns array ordered from the least recent to the most recent - */ - public toArray():Array<[Key,Value]>{ - return Array.from(this.index); + 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/tests/unit/alb-verifier.test.ts b/tests/unit/alb-verifier.test.ts index 5996a70..3f5efb1 100644 --- a/tests/unit/alb-verifier.test.ts +++ b/tests/unit/alb-verifier.test.ts @@ -11,9 +11,7 @@ import { AlbJwtVerifier } from "../../src/alb-verifier"; import { ParameterValidationError, JwtInvalidClaimError, - JwtParseError, JwtInvalidIssuerError, - JwtInvalidSignatureAlgorithmError, FailedAssertionError, } from "../../src/error"; import { createPublicKey } from "crypto"; @@ -22,9 +20,9 @@ describe("unit tests alb verifier", () => { let keypair: ReturnType; beforeAll(() => { keypair = generateKeyPair({ - kid:"00000000-0000-0000-0000-000000000000", - kty:"EC", - alg:"ES256", + kid: "00000000-0000-0000-0000-000000000000", + kty: "EC", + alg: "ES256", }); disallowAllRealNetworkTraffic(); }); @@ -38,24 +36,25 @@ describe("unit tests alb verifier", () => { const kid = keypair.jwk.kid; const userPoolId = "us-east-1_123456"; const issuer = `https://cognito-idp.us-east-1.amazonaws.com/${userPoolId}`; - const albArn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; + const albArn = + "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; const clientId = "my-client-id"; - const exp = 4000000000;// nock and jest.useFakeTimers do not work well together. Used of a long expired date instead - + const exp = 4000000000; // nock and jest.useFakeTimers do not work well together. Used of a long expired date instead + const signedJwt = signJwt( { - typ:"JWT", + typ: "JWT", kid, - alg:"ES256", - iss:issuer, - client:clientId, - signer:albArn, - exp + alg: "ES256", + iss: issuer, + client: clientId, + signer: albArn, + exp, }, { hello: "world", exp, - iss:issuer, + iss: issuer, }, keypair.privateKey ); @@ -85,23 +84,24 @@ describe("unit tests alb verifier", () => { const kid = keypair.jwk.kid; const userPoolId = "us-east-1_123456"; const issuer = `https://cognito-idp.us-east-1.amazonaws.com/${userPoolId}`; - const albArn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; + const albArn = + "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; const clientId = "my-client-id"; - const exp = 4000000000;// nock and jest.useFakeTimers do not work well together. Used of a long expired date instead - + const exp = 4000000000; // nock and jest.useFakeTimers do not work well together. Used of a long expired date instead + const header = { - typ:"JWT", + typ: "JWT", kid, - alg:"ES256", - iss:"badissuer", - client:clientId, - signer:albArn, - exp + alg: "ES256", + iss: "badissuer", + client: clientId, + signer: albArn, + exp, }; const payload = { hello: "world", exp, - iss:"badissuer", + iss: "badissuer", }; const signedJwt = signJwt(header, payload, keypair.privateKey); const albVerifier = AlbJwtVerifier.create({ @@ -127,22 +127,23 @@ describe("unit tests alb verifier", () => { const kid = keypair.jwk.kid; const userPoolId = "us-east-1_123456"; const issuer = `https://cognito-idp.us-east-1.amazonaws.com/${userPoolId}`; - const albArn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; + const albArn = + "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; const clientId = "my-client-id"; - const exp = 4000000000;// nock and jest.useFakeTimers do not work well together. Used of a long expired date instead + const exp = 4000000000; // nock and jest.useFakeTimers do not work well together. Used of a long expired date instead const header = { - typ:"JWT", + typ: "JWT", kid, - alg:"ES256", - iss:"badissuer", - client:clientId, - signer:albArn, - exp + alg: "ES256", + iss: "badissuer", + client: clientId, + signer: albArn, + exp, }; const payload = { hello: "world", exp, - iss:"badissuer", + iss: "badissuer", }; const signedJwt = signJwt(header, payload, keypair.privateKey); const albVerifier = AlbJwtVerifier.create({ @@ -168,22 +169,23 @@ describe("unit tests alb verifier", () => { const kid = keypair.jwk.kid; const userPoolId = "us-east-1_123456"; const issuer = `https://cognito-idp.us-east-1.amazonaws.com/${userPoolId}`; - const albArn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; + const albArn = + "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; const clientId = "my-client-id"; - const exp = 4000000000;// nock and jest.useFakeTimers do not work well together. Used of a long expired date instead + const exp = 4000000000; // nock and jest.useFakeTimers do not work well together. Used of a long expired date instead const header = { - typ:"JWT", + typ: "JWT", kid, - alg:"ES256", - iss:"badissuer", - client:clientId, - signer:albArn, - exp + alg: "ES256", + iss: "badissuer", + client: clientId, + signer: albArn, + exp, }; const payload = { hello: "world", exp, - iss:"badissuer", + iss: "badissuer", }; const signedJwt = signJwt(header, payload, keypair.privateKey); const albVerifier = AlbJwtVerifier.create({ @@ -205,22 +207,23 @@ describe("unit tests alb verifier", () => { const kid = keypair.jwk.kid; const userPoolId = "us-east-1_123456"; const issuer = `https://cognito-idp.us-east-1.amazonaws.com/${userPoolId}`; - const albArn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; + const albArn = + "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; const clientId = "my-client-id"; - const exp = 4000000000;// nock and jest.useFakeTimers do not work well together. Used of a long expired date instead + const exp = 4000000000; // nock and jest.useFakeTimers do not work well together. Used of a long expired date instead const header = { - typ:"JWT", + typ: "JWT", kid, - alg:"ES256", - iss:"badissuer", - client:clientId, - signer:albArn, - exp + alg: "ES256", + iss: "badissuer", + client: clientId, + signer: albArn, + exp, }; const payload = { hello: "world", exp, - iss:"badissuer", + iss: "badissuer", }; const signedJwt = signJwt(header, payload, keypair.privateKey); const albVerifier = AlbJwtVerifier.create({ @@ -248,22 +251,23 @@ describe("unit tests alb verifier", () => { const kid = keypair.jwk.kid; const userPoolId = "us-east-1_123456"; const issuer = `https://cognito-idp.us-east-1.amazonaws.com/${userPoolId}`; - const albArn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; + const albArn = + "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; const clientId = "my-client-id"; - const exp = 4000000000;// nock and jest.useFakeTimers do not work well together. Used of a long expired date instead + const exp = 4000000000; // nock and jest.useFakeTimers do not work well together. Used of a long expired date instead const header = { - typ:"JWT", + typ: "JWT", kid, - alg:"ES256", - iss:"badissuer", - client:clientId, - signer:albArn, - exp + alg: "ES256", + iss: "badissuer", + client: clientId, + signer: albArn, + exp, }; const payload = { hello: "world", exp, - iss:"badissuer", + iss: "badissuer", }; const signedJwt = signJwt(header, payload, keypair.privateKey); const albVerifier = AlbJwtVerifier.create({ @@ -289,30 +293,31 @@ describe("unit tests alb verifier", () => { const kid = keypair.jwk.kid; const userPoolId = "us-east-1_123456"; const issuer = `https://cognito-idp.us-east-1.amazonaws.com/${userPoolId}`; - const albArn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; + const albArn = + "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; const clientId = "my-client-id"; - const exp = 4000000000;// nock and jest.useFakeTimers do not work well together. Used of a long expired date instead - + const exp = 4000000000; // nock and jest.useFakeTimers do not work well together. Used of a long expired date instead + const signedJwt = signJwt( { - typ:"JWT", + typ: "JWT", kid, - alg:"ES256", - iss:issuer, - client:clientId, - signer:albArn, - exp + alg: "ES256", + iss: issuer, + client: clientId, + signer: albArn, + exp, }, { hello: "world", exp, - iss:issuer, + iss: issuer, }, keypair.privateKey ); - const albVerifier = AlbJwtVerifier.create({ + const albVerifier = AlbJwtVerifier.create({ albArn, - issuer, + issuer, }); albVerifier.cacheJwks(keypair.jwks); expect( @@ -323,80 +328,81 @@ describe("unit tests alb verifier", () => { }); test("clientId null", async () => { - const region = "us-east-1"; const userPoolId = "us-east-1_123456"; - const albArn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; + const albArn = + "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 exp = 4000000000; // nock and jest.useFakeTimers do not work well together. Used of a long expired date instead + const signedJwt = signJwt( { - typ:"JWT", + typ: "JWT", kid, - alg:"ES256", - iss:issuer, - client:clientId, - signer:albArn, - exp + alg: "ES256", + iss: issuer, + client: clientId, + signer: albArn, + exp, }, { hello: "world", exp, - iss:issuer, + iss: issuer, }, keypair.privateKey ); const albVerifier = AlbJwtVerifier.create({ issuer, - clientId:null, + clientId: null, albArn, - jwksUri + jwksUri, }); - + albVerifier.cacheJwks(keypair.jwks); expect.assertions(1); - expect( - albVerifier.verifySync(signedJwt) - ).toMatchObject({ hello: "world" }); + expect(albVerifier.verifySync(signedJwt)).toMatchObject({ + hello: "world", + }); }); test("clientId undefined", () => { const kid = keypair.jwk.kid; const userPoolId = "us-east-1_123456"; const issuer = `https://cognito-idp.us-east-1.amazonaws.com/${userPoolId}`; - const albArn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; + const albArn = + "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; const clientId = "my-client-id"; - const exp = 4000000000;// nock and jest.useFakeTimers do not work well together. Used of a long expired date instead - + const exp = 4000000000; // nock and jest.useFakeTimers do not work well together. Used of a long expired date instead + const signedJwt = signJwt( { - typ:"JWT", + typ: "JWT", kid, - alg:"ES256", - iss:issuer, - client:clientId, - signer:albArn, - exp + alg: "ES256", + iss: issuer, + client: clientId, + signer: albArn, + exp, }, { hello: "world", exp, - iss:issuer, + iss: issuer, }, keypair.privateKey ); const verifier = AlbJwtVerifier.create({ albArn, - issuer, + issuer, clientId: undefined as unknown as null, }); verifier.cacheJwks(keypair.jwks); - + expect.assertions(2); expect(() => verifier.verifySync(signedJwt)).toThrow( "clientId must be provided or set to null explicitly" @@ -407,31 +413,31 @@ describe("unit tests alb verifier", () => { }); test("invalid issuer", () => { - const region = "us-east-1"; const userPoolId = "us-east-1_123456"; - const albArn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; + const albArn = + "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 exp = 4000000000; // nock and jest.useFakeTimers do not work well together. Used of a long expired date instead + const signedJwt = signJwt( { - typ:"JWT", + typ: "JWT", kid, - alg:"ES256", - iss:badIssuer, - client:clientId, - signer:albArn, - exp + alg: "ES256", + iss: badIssuer, + client: clientId, + signer: albArn, + exp, }, { hello: "world", exp, - iss:badIssuer, + iss: badIssuer, }, keypair.privateKey ); @@ -439,43 +445,44 @@ describe("unit tests alb verifier", () => { issuer, clientId, albArn, - jwksUri + jwksUri, }); - + albVerifier.cacheJwks(keypair.jwks); expect.assertions(1); - expect( - () => albVerifier.verifySync(signedJwt) - ).toThrow(JwtInvalidIssuerError); + expect(() => albVerifier.verifySync(signedJwt)).toThrow( + JwtInvalidIssuerError + ); }); test("invalid signer", () => { - const region = "us-east-1"; const userPoolId = "us-east-1_123456"; - const albArn = "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 albArn = + "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 exp = 4000000000; // nock and jest.useFakeTimers do not work well together. Used of a long expired date instead + const signedJwt = signJwt( { - typ:"JWT", + typ: "JWT", kid, - alg:"ES256", - iss:issuer, - client:clientId, - signer:badSigner, - exp + alg: "ES256", + iss: issuer, + client: clientId, + signer: badSigner, + exp, }, { hello: "world", exp, - iss:issuer, + iss: issuer, }, keypair.privateKey ); @@ -483,43 +490,43 @@ describe("unit tests alb verifier", () => { issuer, clientId, albArn, - jwksUri + jwksUri, }); - + albVerifier.cacheJwks(keypair.jwks); expect.assertions(1); - expect( - () => albVerifier.verifySync(signedJwt) - ).toThrow(FailedAssertionError); + expect(() => albVerifier.verifySync(signedJwt)).toThrow( + FailedAssertionError + ); }); - + test("invalid clientId", () => { - const region = "us-east-1"; const userPoolId = "us-east-1_123456"; - const albArn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; + const albArn = + "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 exp = 4000000000; // nock and jest.useFakeTimers do not work well together. Used of a long expired date instead + const signedJwt = signJwt( { - typ:"JWT", + typ: "JWT", kid, - alg:"ES256", - iss:issuer, - client:badClientId, - signer:albArn, - exp + alg: "ES256", + iss: issuer, + client: badClientId, + signer: albArn, + exp, }, { hello: "world", exp, - iss:issuer, + iss: issuer, }, keypair.privateKey ); @@ -527,39 +534,36 @@ describe("unit tests alb verifier", () => { issuer, clientId, albArn, - jwksUri + jwksUri, }); - + albVerifier.cacheJwks(keypair.jwks); expect.assertions(1); - expect( - () => albVerifier.verifySync(signedJwt) - ).toThrow(FailedAssertionError); + expect(() => albVerifier.verifySync(signedJwt)).toThrow( + FailedAssertionError + ); }); - - }); describe("jwksUri", () => { test("default jwksUri", async () => { - const region = "us-east-1"; const userPoolId = "us-east-1_123456"; - const albArn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; + const albArn = + "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 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-----. - + key: jwk, + format: "jwk", + }).export({ + format: "pem", + type: "spki", + }); //pem with -----BEGIN PUBLIC KEY----- and -----END PUBLIC KEY-----. mockHttpsUri(`${jwksUri}/${kid}`, { responsePayload: pem, @@ -567,18 +571,18 @@ describe("unit tests alb verifier", () => { const signedJwt = signJwt( { - typ:"JWT", + typ: "JWT", kid, - alg:"ES256", - iss:issuer, - client:clientId, - signer:albArn, - exp + alg: "ES256", + iss: issuer, + client: clientId, + signer: albArn, + exp, }, { hello: "world", exp, - iss:issuer, + iss: issuer, }, keypair.privateKey ); @@ -588,30 +592,29 @@ describe("unit tests alb verifier", () => { albArn, }); expect.assertions(1); - expect( - await albVerifier.verify(signedJwt) - ).toMatchObject({ hello: "world" }); + expect(await albVerifier.verify(signedJwt)).toMatchObject({ + hello: "world", + }); }); test("custom jwksUri", async () => { - const region = "us-east-1"; const userPoolId = "us-east-1_123456"; - const albArn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; + const albArn = + "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://s3-us-gov-west-1.amazonaws.com/aws-elb-public-keys-prod-us-gov-west-1`; 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 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-----. - + key: jwk, + format: "jwk", + }).export({ + format: "pem", + type: "spki", + }); //pem with -----BEGIN PUBLIC KEY----- and -----END PUBLIC KEY-----. mockHttpsUri(`${jwksUri}/${kid}`, { responsePayload: pem, @@ -619,18 +622,18 @@ describe("unit tests alb verifier", () => { const signedJwt = signJwt( { - typ:"JWT", + typ: "JWT", kid, - alg:"ES256", - iss:issuer, - client:clientId, - signer:albArn, - exp + alg: "ES256", + iss: issuer, + client: clientId, + signer: albArn, + exp, }, { hello: "world", exp, - iss:issuer, + iss: issuer, }, keypair.privateKey ); @@ -638,30 +641,27 @@ describe("unit tests alb verifier", () => { issuer, clientId, albArn, - jwksUri + jwksUri, }); expect.assertions(1); - expect( - await albVerifier.verify(signedJwt) - ).toMatchObject({ hello: "world" }); + expect(await albVerifier.verify(signedJwt)).toMatchObject({ + hello: "world", + }); }); test("can't extract region when null albArn and undefined jwksUri", async () => { - const region = "us-east-1"; const userPoolId = "us-east-1_123456"; const clientId = "my-client-id"; const issuer = `https://cognito-idp.${region}.amazonaws.com/${userPoolId}`; - expect( - ()=>{ - AlbJwtVerifier.create({ - issuer, - clientId, - albArn:null - }) - } - ).toThrow(ParameterValidationError); + expect(() => { + AlbJwtVerifier.create({ + issuer, + clientId, + albArn: null, + }); + }).toThrow(ParameterValidationError); }); }); }); @@ -669,31 +669,35 @@ describe("unit tests alb verifier", () => { describe("AlbJwtVerifier with multiple alb", () => { describe("verifySync", () => { test("happy flow with 2 albs and 2 issuers", async () => { - const exp = 4000000000;// nock and jest.useFakeTimers do not work well together. Used of a long expired date instead - + const exp = 4000000000; // nock and jest.useFakeTimers do not work well together. Used of a long expired date instead + const identityProviders = [ { config: { - albArn: "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/60dc6c495c0c9188", - issuer:"https://cognito-idp.us-east-1.amazonaws.com/us-east-1_qbc", + albArn: + "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/60dc6c495c0c9188", + issuer: + "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_qbc", clientId: "client1", }, keypair: generateKeyPair({ - kid:"00000000-0000-0000-0000-000000000000", - kty:"EC", - alg:"ES256", + kid: "00000000-0000-0000-0000-000000000000", + kty: "EC", + alg: "ES256", }), }, { config: { - albArn: "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/", - issuer: "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_def", + albArn: + "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/", + issuer: + "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_def", clientId: "client2", }, keypair: generateKeyPair({ - kid:"11111111-0000-0000-0000-000000000000", - kty:"EC", - alg:"ES256", + kid: "11111111-0000-0000-0000-000000000000", + kty: "EC", + alg: "ES256", }), }, ]; @@ -706,18 +710,18 @@ describe("unit tests alb verifier", () => { verifier.cacheJwks(idp.keypair.jwks, idp.config.issuer); const signedJwt = signJwt( { - typ:"JWT", - kid:idp.keypair.jwk.kid, - alg:"ES256", - iss:idp.config.issuer, - client:idp.config.clientId, - signer:idp.config.albArn, - exp + typ: "JWT", + kid: idp.keypair.jwk.kid, + alg: "ES256", + iss: idp.config.issuer, + client: idp.config.clientId, + signer: idp.config.albArn, + exp, }, { hello: "world", exp, - iss:idp.config.issuer, + iss: idp.config.issuer, }, idp.keypair.privateKey ); @@ -728,58 +732,68 @@ describe("unit tests alb verifier", () => { }); test("happy flow with 2 albs and 1 issuer", 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 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 albArn1 = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/AAAAAAAAAAAAAAAA"; - const albArn2 = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/BBBBBBBBBBBBBBBB"; + const albArn1 = + "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/AAAAAAAAAAAAAAAA"; + const albArn2 = + "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 + const exp = 4000000000; // nock and jest.useFakeTimers do not work well together. Used of a long expired date instead const signedJwt1 = signJwt( { - typ:"JWT", - kid:keypair1.jwk.kid, - alg:"ES256", - iss:issuer, - client:clientId, - signer:albArn1, - exp + typ: "JWT", + kid: keypair1.jwk.kid, + alg: "ES256", + iss: issuer, + client: clientId, + signer: albArn1, + exp, }, { hello: "world1", exp, - iss:issuer, + iss: issuer, }, keypair1.privateKey ); const signedJwt2 = signJwt( { - typ:"JWT", - kid:keypair2.jwk.kid, - alg:"ES256", - iss:issuer, - client:clientId, - signer:albArn2, - exp + typ: "JWT", + kid: keypair2.jwk.kid, + alg: "ES256", + iss: issuer, + client: clientId, + signer: albArn2, + exp, }, { hello: "world2", exp, - iss:issuer, + iss: issuer, }, keypair2.privateKey ); - + const albVerifier = AlbJwtVerifier.create({ issuer, clientId, - albArn:[albArn1,albArn2], + albArn: [albArn1, albArn2], jwksUri, }); @@ -787,39 +801,43 @@ describe("unit tests alb verifier", () => { albVerifier.cacheJwks(keypair2.jwks); expect.assertions(2); - expect( - await albVerifier.verify(signedJwt1) - ).toMatchObject({ hello: "world1" }); + expect(await albVerifier.verify(signedJwt1)).toMatchObject({ + hello: "world1", + }); - expect( - await albVerifier.verify(signedJwt2) - ).toMatchObject({ hello: "world2" }); + expect(await albVerifier.verify(signedJwt2)).toMatchObject({ + hello: "world2", + }); }); test("cache jwks with multiple IDPs needs issuer", () => { const identityProviders = [ { config: { - albArn: "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/60dc6c495c0c9188", - issuer:"https://cognito-idp.us-east-1.amazonaws.com/us-east-1_qbc", + albArn: + "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/60dc6c495c0c9188", + issuer: + "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_qbc", clientId: "client1", }, keypair: generateKeyPair({ - kid:"00000000-0000-0000-0000-000000000000", - kty:"EC", - alg:"ES256", + kid: "00000000-0000-0000-0000-000000000000", + kty: "EC", + alg: "ES256", }), }, { config: { - albArn: "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/", - issuer: "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_def", + albArn: + "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/", + issuer: + "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_def", clientId: "client2", }, keypair: generateKeyPair({ - kid:"11111111-0000-0000-0000-000000000000", - kty:"EC", - alg:"ES256", + kid: "11111111-0000-0000-0000-000000000000", + kty: "EC", + alg: "ES256", }), }, ]; @@ -827,8 +845,7 @@ describe("unit tests alb verifier", () => { identityProviders.map((idp) => idp.config) ); const issuer: any = undefined; - const statement = () => - verifier.cacheJwks(keypair.jwks, issuer); + const statement = () => verifier.cacheJwks(keypair.jwks, issuer); expect(statement).toThrow( new ParameterValidationError("issuer must be provided") ); @@ -857,13 +874,14 @@ describe("unit tests alb verifier", () => { const customJwksCache = new CustomJwksCache(); const userPoolId = "us-east-1_123456"; const issuer = `https://cognito-idp.us-east-1.amazonaws.com/${userPoolId}`; - const albArn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; - + const albArn = + "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; + const jwksUri = "https://public-keys.auth.elb.us-east-1.amazonaws.com"; const verifier = AlbJwtVerifier.create( { albArn, - issuer, + issuer, tokenUse: "id", }, { jwksCache: customJwksCache } From c4e4443f90e227c5bbbf17062b5926fccd94db99 Mon Sep 17 00:00:00 2001 From: Nicolas Viaud Date: Tue, 28 Jan 2025 17:04:29 +0000 Subject: [PATCH 04/18] add e2e tests. All e2e tests OK --- .gitignore | 3 +++ package.json | 6 ++++++ src/alb-cache.ts | 12 +++++------ src/alb-verifier.ts | 12 +++++------ src/index.ts | 1 + tests/cognito/test/cognito.test.ts | 34 ++++++------------------------ 6 files changed, 28 insertions(+), 40 deletions(-) diff --git a/.gitignore b/.gitignore index 48d7e48..994898c 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,9 @@ junit.xml # Submodules generated when building dist assert.d.ts +alb-cache.d.ts +alb-verifier.d.ts +cache.d.ts cognito-verifier.d.ts error.d.ts https-common.d.ts diff --git a/package.json b/package.json index c7106d8..e92bc3f 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "module": "dist/esm/index.js", "files": [ "assert.d.ts", + "alb-verifier.d.ts", "cognito-verifier.d.ts", "dist", "error.d.ts", @@ -38,6 +39,11 @@ "require": "./dist/cjs/assert.js", "types": "./assert.d.ts" }, + "./alb-verifier": { + "import": "./dist/esm/alb-verifier.js", + "require": "./dist/cjs/alb-verifier.js", + "types": "./alb-verifier.d.ts" + }, "./cognito-verifier": { "import": "./dist/esm/cognito-verifier.js", "require": "./dist/cjs/cognito-verifier.js", diff --git a/src/alb-cache.ts b/src/alb-cache.ts index 96182d4..3ca9287 100644 --- a/src/alb-cache.ts +++ b/src/alb-cache.ts @@ -4,12 +4,12 @@ import { 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"; +} from "./error.js"; +import { JwkWithKid, Jwks, JwksCache } from "./jwk.js"; +import { JwtHeader, JwtPayload } from "./jwt-model.js"; +import { Fetcher, SimpleFetcher } from "./https.js"; +import { SimpleLruCache } from "./cache.js"; +import { assertStringEquals } from "./assert.js"; const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; diff --git a/src/alb-verifier.ts b/src/alb-verifier.ts index 6d41577..7caab7d 100644 --- a/src/alb-verifier.ts +++ b/src/alb-verifier.ts @@ -1,10 +1,10 @@ import { AwsAlbJwksCache } from "./alb-cache"; -import { assertStringArrayContainsString } from "./assert"; -import { JwtInvalidClaimError, ParameterValidationError } from "./error"; -import { Jwk, JwksCache } from "./jwk"; -import { JwtHeader, JwtPayload } from "./jwt-model"; // todo consider creating a specific type for AWS ALB JWT Payload -import { JwtVerifierBase, JwtVerifierProperties } from "./jwt-verifier"; -import { Properties } from "./typing-util"; +import { assertStringArrayContainsString } from "./assert.js"; +import { JwtInvalidClaimError, ParameterValidationError } from "./error.js"; +import { Jwk, JwksCache } from "./jwk.js"; +import { JwtHeader, JwtPayload } from "./jwt-model.js"; // todo consider creating a specific type for AWS ALB JWT Payload +import { JwtVerifierBase, JwtVerifierProperties } from "./jwt-verifier.js"; +import { Properties } from "./typing-util.js"; export interface AlbVerifyProperties { /** diff --git a/src/index.ts b/src/index.ts index f066430..ec82dc7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ export { JwtVerifier } from "./jwt-verifier.js"; export { CognitoJwtVerifier } from "./cognito-verifier.js"; +export { AlbJwtVerifier } from "./alb-verifier.js"; // Backward compatibility import { JwtVerifier } from "./jwt-verifier.js"; diff --git a/tests/cognito/test/cognito.test.ts b/tests/cognito/test/cognito.test.ts index f360b19..9a4bed0 100644 --- a/tests/cognito/test/cognito.test.ts +++ b/tests/cognito/test/cognito.test.ts @@ -1,12 +1,9 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import * as crypto from "node:crypto"; import * as outputs from "../outputs.json"; -import { JwtVerifier, CognitoJwtVerifier } from "aws-jwt-verify"; +import { AlbJwtVerifier, CognitoJwtVerifier } from "aws-jwt-verify"; import { assertStringEquals } from "aws-jwt-verify/assert"; -import { decomposeUnverifiedJwt } from "aws-jwt-verify/jwt"; -import { Jwk } from "aws-jwt-verify/jwk"; import { CognitoIdentityProviderClient, InitiateAuthCommand, @@ -38,9 +35,9 @@ let albSigninJWTs: Awaited< const cognitoVerifier = CognitoJwtVerifier.create({ userPoolId, }); -const albJwtVerifier = JwtVerifier.create({ +const albJwtVerifier = AlbJwtVerifier.create({ + albArn, issuer: CognitoJwtVerifier.parseUserPoolId(userPoolId).issuer, - audience: null, customJwtCheck: ({ header }) => { assertStringEquals("ALB arn", header.signer, albArn); assertStringEquals("ALB client", header.client, clientIdAlb); @@ -170,28 +167,9 @@ test("Verify Cognito Access token from ALB", async () => { }); test("Verify Data token from ALB", async () => { - const { - header: { kid }, - } = decomposeUnverifiedJwt(albSigninJWTs.albToken); - - const albRegion = albArn.split(":")[3]; - const pem = await fetch( - `https://public-keys.auth.elb.${albRegion}.amazonaws.com/${kid}` - ).then((res) => res.text()); - - const keyObject = crypto.createPublicKey({ - key: pem, - format: "pem", - type: "spki", - }); - - const jwk = keyObject.export({ - format: "jwk", - }); - - albJwtVerifier.cacheJwks({ keys: [{ ...jwk, kid, alg: "ES256" } as Jwk] }); - return expect( - albJwtVerifier.verify(albSigninJWTs.albToken) + albJwtVerifier.verify(albSigninJWTs.albToken, { + clientId: clientIdAlb, + }) ).resolves.toMatchObject({ email: username }); }); From 7db92738ff1073b2894c17c56b4f5a89c31c91e2 Mon Sep 17 00:00:00 2001 From: Nicolas Viaud Date: Tue, 28 Jan 2025 17:49:43 +0000 Subject: [PATCH 05/18] type Alb JWT Header and Payload --- src/alb-verifier.ts | 18 +++++++++--------- src/jwt-model.ts | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/src/alb-verifier.ts b/src/alb-verifier.ts index 7caab7d..a9145f1 100644 --- a/src/alb-verifier.ts +++ b/src/alb-verifier.ts @@ -2,7 +2,7 @@ import { AwsAlbJwksCache } from "./alb-cache"; import { assertStringArrayContainsString } from "./assert.js"; import { JwtInvalidClaimError, ParameterValidationError } from "./error.js"; import { Jwk, JwksCache } from "./jwk.js"; -import { JwtHeader, JwtPayload } from "./jwt-model.js"; // todo consider creating a specific type for AWS ALB JWT Payload +import { AlbJwtHeader, AlbJwtPayload, JwtHeader } from "./jwt-model.js"; // todo consider creating a specific type for AWS ALB JWT Payload import { JwtVerifierBase, JwtVerifierProperties } from "./jwt-verifier.js"; import { Properties } from "./typing-util.js"; @@ -29,8 +29,8 @@ export interface AlbVerifyProperties { * - the JWK that was used to verify the JWT's signature */ customJwtCheck?: (props: { - header: JwtHeader; - payload: JwtPayload; + header: AlbJwtHeader; + payload: AlbJwtPayload; jwk: Jwk; }) => Promise | void; /** @@ -146,7 +146,7 @@ export class AlbJwtVerifier< > extends JwtVerifierBase { private constructor( props: AlbJwtVerifierProperties | AlbJwtVerifierMultiProperties[], - jwksCache: JwksCache = new AwsAlbJwksCache() + jwksCache: JwksCache ) { const issuerConfig = Array.isArray(props) ? (props.map((p) => ({ @@ -197,7 +197,7 @@ export class AlbJwtVerifier< ) { return new this( verifyProperties, - additionalProperties?.jwksCache // todo by default we should select the ALB specific cache here + additionalProperties?.jwksCache ?? new AwsAlbJwksCache() ); } @@ -210,7 +210,7 @@ export class AlbJwtVerifier< */ public verifySync( ...[jwt, properties]: AlbVerifyParameters - ): JwtPayload { + ): AlbJwtPayload { const { decomposedJwt, jwksUri, verifyProperties } = this.getVerifyParameters(jwt, properties); this.verifyDecomposedJwtSync(decomposedJwt, jwksUri, verifyProperties); @@ -225,7 +225,7 @@ export class AlbJwtVerifier< } throw err; } - return decomposedJwt.payload; + return decomposedJwt.payload as AlbJwtPayload; } /** @@ -239,7 +239,7 @@ export class AlbJwtVerifier< */ public async verify( ...[jwt, properties]: AlbVerifyParameters - ): Promise { + ): Promise { const { decomposedJwt, jwksUri, verifyProperties } = this.getVerifyParameters(jwt, properties); await this.verifyDecomposedJwt(decomposedJwt, jwksUri, verifyProperties); @@ -254,7 +254,7 @@ export class AlbJwtVerifier< } throw err; } - return decomposedJwt.payload; + return decomposedJwt.payload as AlbJwtPayload; } } diff --git a/src/jwt-model.ts b/src/jwt-model.ts index 2337c11..379f934 100644 --- a/src/jwt-model.ts +++ b/src/jwt-model.ts @@ -90,3 +90,22 @@ export interface CognitoJwt { header: JwtHeader; payload: CognitoAccessTokenPayload | CognitoIdTokenPayload; } +export interface AlbJwtHeader extends JwtHeader { + alg: string; + kid: string; + signer: string; + iss: string; + client: string; + exp: number; +} + +//TODO more specifics fields when ALB auth is cognito or oidc +export type AlbJwtPayload = { + exp: number; + iss: string; +} & JwtPayloadStandardFields & JsonObject; + +export interface AlbJwt { + header: AlbJwtHeader; + payload: AlbJwtPayload; +} \ No newline at end of file From 2c79a0d5c89f46852e812a4870283d27a348364f Mon Sep 17 00:00:00 2001 From: Nicolas Viaud Date: Tue, 28 Jan 2025 19:01:15 +0000 Subject: [PATCH 06/18] add region check add unit test for alb-verifier.ts (reach 100% coverage) --- src/alb-verifier.ts | 17 ++-- tests/unit/alb-verifier.test.ts | 152 +++++++++++++++++++++++++++++++- 2 files changed, 162 insertions(+), 7 deletions(-) diff --git a/src/alb-verifier.ts b/src/alb-verifier.ts index a9145f1..c56b1ed 100644 --- a/src/alb-verifier.ts +++ b/src/alb-verifier.ts @@ -213,8 +213,8 @@ export class AlbJwtVerifier< ): AlbJwtPayload { const { decomposedJwt, jwksUri, verifyProperties } = this.getVerifyParameters(jwt, properties); - this.verifyDecomposedJwtSync(decomposedJwt, jwksUri, verifyProperties); try { + this.verifyDecomposedJwtSync(decomposedJwt, jwksUri, verifyProperties); validateAlbJwtFields(decomposedJwt.header, verifyProperties); } catch (err) { if ( @@ -242,8 +242,8 @@ export class AlbJwtVerifier< ): Promise { const { decomposedJwt, jwksUri, verifyProperties } = this.getVerifyParameters(jwt, properties); - await this.verifyDecomposedJwt(decomposedJwt, jwksUri, verifyProperties); try { + await this.verifyDecomposedJwt(decomposedJwt, jwksUri, verifyProperties); validateAlbJwtFields(decomposedJwt.header, verifyProperties); } catch (err) { if ( @@ -295,17 +295,22 @@ export function validateAlbJwtFields( } } -function defaultJwksUri(albArn: string | string[] | null): string { - if (albArn === null) { +export function defaultJwksUri(albArn: string | string[] | null): string { + if (!albArn) { throw new ParameterValidationError("ALB ARN cannot be null"); } + const regionRegex = /^[a-z]{2}-[a-z]+-\d{1}$/; const extractRegion = (arn: string): string => { const arnParts = arn.split(":"); - if (arnParts.length < 4) { + if (arnParts.length < 4 || arnParts[0] !== "arn" || arnParts[1] !== "aws" || arnParts[2] !== "elasticloadbalancing") { throw new ParameterValidationError(`Invalid load balancer ARN: ${arn}`); } - return arnParts[3]; + const region = arnParts[3]; + if (!regionRegex.test(region)) { + throw new ParameterValidationError(`Invalid AWS region in ARN: ${region}`); + } + return region; }; if (Array.isArray(albArn)) { diff --git a/tests/unit/alb-verifier.test.ts b/tests/unit/alb-verifier.test.ts index 3f5efb1..6bd3ffb 100644 --- a/tests/unit/alb-verifier.test.ts +++ b/tests/unit/alb-verifier.test.ts @@ -7,7 +7,7 @@ import { } from "./test-util"; import { decomposeUnverifiedJwt } from "../../src/jwt"; import { JwksCache, Jwks } from "../../src/jwk"; -import { AlbJwtVerifier } from "../../src/alb-verifier"; +import { AlbJwtVerifier, defaultJwksUri } from "../../src/alb-verifier"; import { ParameterValidationError, JwtInvalidClaimError, @@ -327,6 +327,87 @@ describe("unit tests alb verifier", () => { ).toMatchObject({ hello: "world" }); }); + test("albArn null", () => { + const kid = keypair.jwk.kid; + const userPoolId = "us-east-1_123456"; + const issuer = `https://cognito-idp.us-east-1.amazonaws.com/${userPoolId}`; + const albArn = + "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; + const clientId = "my-client-id"; + 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: albArn, + exp, + }, + { + hello: "world", + exp, + iss: issuer, + }, + keypair.privateKey + ); + const verifier = AlbJwtVerifier.create({ + albArn: null, + issuer, + clientId, + }); + verifier.cacheJwks(keypair.jwks); + + expect.assertions(1); + expect(verifier.verifySync(signedJwt)).toMatchObject({ + hello: "world", + }); + }); + + test("albArn undefined", () => { + const kid = keypair.jwk.kid; + const userPoolId = "us-east-1_123456"; + const issuer = `https://cognito-idp.us-east-1.amazonaws.com/${userPoolId}`; + const albArn = + "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; + const clientId = "my-client-id"; + 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: albArn, + exp, + }, + { + hello: "world", + exp, + iss: issuer, + }, + keypair.privateKey + ); + const verifier = AlbJwtVerifier.create({ + albArn: undefined as unknown as null, + issuer, + clientId, + }); + verifier.cacheJwks(keypair.jwks); + + expect.assertions(2); + expect(() => verifier.verifySync(signedJwt)).toThrow( + "AlbArn must be provided or set to null explicitly" + ); + expect(() => verifier.verifySync(signedJwt)).toThrow( + ParameterValidationError + ); + }); + test("clientId null", async () => { const region = "us-east-1"; const userPoolId = "us-east-1_123456"; @@ -894,4 +975,73 @@ describe("unit tests alb verifier", () => { }); }); }); + + describe("defaultJwksUri", () => { + test("happy flow with us-east-1 region", () => { + const albArn = + "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; + + expect(defaultJwksUri(albArn)).toBe("https://public-keys.auth.elb.us-east-1.amazonaws.com"); + }); + + test("happy flow with eu-west-2 region", () => { + const albArn = + "arn:aws:elasticloadbalancing:eu-west-2:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; + + expect(defaultJwksUri(albArn)).toBe("https://public-keys.auth.elb.eu-west-2.amazonaws.com"); + }); + + test("happy flow with multi ALB ARN", () => { + const albArns = [ + "arn:aws:elasticloadbalancing:eu-west-2:123456789012:loadbalancer/app/my-load-balancer-1/50dc6c495c0c9188", + "arn:aws:elasticloadbalancing:eu-west-2:123456789012:loadbalancer/app/my-load-balancer-2/901e7c495c0c9188", + ] + + expect(defaultJwksUri(albArns)).toBe("https://public-keys.auth.elb.eu-west-2.amazonaws.com"); + }); + + test("invalid load balancer ARN - too short", () => { + const albArn = + "arn:aws:elasticloadbalancing"; + + expect(()=>defaultJwksUri(albArn)).toThrow(new ParameterValidationError(`Invalid load balancer ARN: arn:aws:elasticloadbalancing`)); + }); + + test("invalid load balancer ARN - invalid region 1", () => { + const albArn = + "arn:aws:elasticloadbalancing:.:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; + + expect(()=>defaultJwksUri(albArn)).toThrow(new ParameterValidationError(`Invalid AWS region in ARN: .`)); + }); + + test("invalid load balancer ARN - invalid region 2", () => { + const albArn = + "arn:aws:elasticloadbalancing:/:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; + + expect(()=>defaultJwksUri(albArn)).toThrow(new ParameterValidationError(`Invalid AWS region in ARN: /`)); + }); + + test("invalid load balancer ARN - invalid region 3", () => { + const albArn = + "arn:aws:elasticloadbalancing:?:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; + + expect(()=>defaultJwksUri(albArn)).toThrow(new ParameterValidationError(`Invalid AWS region in ARN: ?`)); + }); + + test("invalid load balancer ARN - invalid region 4", () => { + const albArn = + "arn:aws:elasticloadbalancing:=:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; + + expect(()=>defaultJwksUri(albArn)).toThrow(new ParameterValidationError((`Invalid AWS region in ARN: =`))); + }); + + test("invalid load balancer ARN with multiple regions", () => { + const albArns = [ + "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer-1/50dc6c495c0c9188", + "arn:aws:elasticloadbalancing:eu-west-2:123456789012:loadbalancer/app/my-load-balancer-2/901e7c495c0c9188", + ] + + expect(()=>defaultJwksUri(albArns)).toThrow(new ParameterValidationError("Multiple regions found in ALB ARNs")); + }); + }); }); From 3de432843d76844265893f286cbe156c8e94c758 Mon Sep 17 00:00:00 2001 From: Nicolas Viaud Date: Tue, 4 Feb 2025 22:29:03 +0000 Subject: [PATCH 07/18] set albArn mandatory add unit tests for cache.ts and alb-cache.ts --- src/alb-cache.ts | 8 ++- src/alb-verifier.ts | 46 +++++++------- src/error.ts | 9 +++ tests/unit/alb-cache.test.ts | 97 ++++++++++++++++++++++++++++++ tests/unit/alb-verifier.test.ts | 102 ++------------------------------ tests/unit/cache.test.ts | 65 ++++++++++++++++++++ 6 files changed, 200 insertions(+), 127 deletions(-) create mode 100644 tests/unit/alb-cache.test.ts create mode 100644 tests/unit/cache.test.ts diff --git a/src/alb-cache.ts b/src/alb-cache.ts index 3ca9287..59e78a5 100644 --- a/src/alb-cache.ts +++ b/src/alb-cache.ts @@ -2,6 +2,8 @@ import { createPublicKey } from "crypto"; import { JwkInvalidKtyError, JwksNotAvailableInCacheError, + JwksValidationError, + JwkValidationError, JwtBaseError, JwtWithoutValidKidError, } from "./error.js"; @@ -27,7 +29,7 @@ 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. + * This security requirement is mandatory otherwise the application is exposed to several security risks like DoS attack by injecting a forged kid. * */ export class AwsAlbJwksCache implements JwksCache { @@ -152,10 +154,10 @@ export class AwsAlbJwksCache implements JwksCache { const jwksUriWithKid = this.expandWithKid(jwksUri, kid); this.jwkCache.set(jwksUriWithKid, jwkWithKid); } else { - throw new Error("TODO"); + throw new JwkValidationError("JWK does not have a kid"); } } else { - throw new Error("TODO"); + throw new JwksValidationError("Only one JWK is expected in the JWKS"); } } diff --git a/src/alb-verifier.ts b/src/alb-verifier.ts index c56b1ed..6306cd3 100644 --- a/src/alb-verifier.ts +++ b/src/alb-verifier.ts @@ -1,6 +1,6 @@ import { AwsAlbJwksCache } from "./alb-cache"; import { assertStringArrayContainsString } from "./assert.js"; -import { JwtInvalidClaimError, ParameterValidationError } from "./error.js"; +import { AlbJwtInvalidClientIdError, AlbJwtInvalidSignerError, JwtInvalidClaimError, ParameterValidationError } from "./error.js"; import { Jwk, JwksCache } from "./jwk.js"; import { AlbJwtHeader, AlbJwtPayload, JwtHeader } from "./jwt-model.js"; // todo consider creating a specific type for AWS ALB JWT Payload import { JwtVerifierBase, JwtVerifierProperties } from "./jwt-verifier.js"; @@ -60,9 +60,8 @@ export type AlbJwtVerifierProperties = { * Set this to the expected value of the `signer` claim in the JWT (JWT header). * If you provide a string array, that means at least one of those ALB ARNs * must be present in the JWT's signer claim. - * Pass null explicitly to not check the JWT's signer--if you know what you're doing */ - albArn: string | string[] | null; + albArn: string | string[]; } & Partial; /** @@ -86,9 +85,8 @@ export type AlbJwtVerifierMultiProperties = { * Set this to the expected value of the `signer` claim in the JWT (JWT header). * If you provide a string array, that means at least one of those ALB ARNs * must be present in the JWT's signer claim. - * Pass null explicitly to not check the JWT's signer--if you know what you're doing */ - albArn: string | string[] | null; + albArn: string | string[]; } & AlbVerifyProperties; /** @@ -99,7 +97,7 @@ export type AlbJwtVerifierSingleUserPool = Properties, T & JwtVerifierProperties & { - albArn: string | string[] | null; + albArn: string | string[]; audience: null; }, false @@ -114,7 +112,7 @@ export type AlbJwtVerifierMultiUserPool< Properties, T & JwtVerifierProperties & { - albArn: string | string[] | null; + albArn: string | string[]; audience: null; }, true @@ -140,7 +138,7 @@ export class AlbJwtVerifier< SpecificVerifyProperties extends Partial, IssuerConfig extends JwtVerifierProperties & { audience: null; - albArn: string | string[] | null; + albArn: string | string[]; }, MultiIssuer extends boolean, > extends JwtVerifierBase { @@ -262,23 +260,21 @@ export function validateAlbJwtFields( header: JwtHeader, options: { clientId?: string | string[] | null; - albArn?: string | string[] | null; + albArn?: string | string[]; } ): void { // Check ALB ARN (signer) - if (options.albArn !== null) { - if (options.albArn === undefined) { - throw new ParameterValidationError( - "albArn must be provided or set to null explicitly" - ); - } - assertStringArrayContainsString( - "ALB ARN", - header.signer, - options.albArn - // todo create new error type + if (options.albArn === undefined) { + throw new ParameterValidationError( + "albArn must be provided" ); } + assertStringArrayContainsString( + "ALB ARN", + header.signer, + options.albArn, + AlbJwtInvalidSignerError + ); // Check clientId if (options.clientId !== null) { if (options.clientId === undefined) { @@ -289,16 +285,14 @@ export function validateAlbJwtFields( assertStringArrayContainsString( "Client ID", header.client, - options.clientId - // todo create new error type + options.clientId, + AlbJwtInvalidClientIdError ); } } -export function defaultJwksUri(albArn: string | string[] | null): string { - if (!albArn) { - throw new ParameterValidationError("ALB ARN cannot be null"); - } +export function defaultJwksUri(albArn: string | string[]): string { + const regionRegex = /^[a-z]{2}-[a-z]+-\d{1}$/; const extractRegion = (arn: string): string => { diff --git a/src/error.ts b/src/error.ts index c28399b..0f4fd80 100644 --- a/src/error.ts +++ b/src/error.ts @@ -106,6 +106,15 @@ export class CognitoJwtInvalidTokenUseError extends JwtInvalidClaimError {} export class CognitoJwtInvalidClientIdError extends JwtInvalidClaimError {} + +/** + * Amazon ALB specific erros + */ + +export class AlbJwtInvalidSignerError extends JwtInvalidClaimError {} + +export class AlbJwtInvalidClientIdError extends JwtInvalidClaimError {} + /** * JWK errors */ diff --git a/tests/unit/alb-cache.test.ts b/tests/unit/alb-cache.test.ts new file mode 100644 index 0000000..0c4ca53 --- /dev/null +++ b/tests/unit/alb-cache.test.ts @@ -0,0 +1,97 @@ +import { AwsAlbJwksCache } from "../../src/alb-cache"; +import { + JwksValidationError, + JwkValidationError, + JwtWithoutValidKidError, + } from "../../src/error"; + import { allowAllRealNetworkTraffic, disallowAllRealNetworkTraffic, generateKeyPair } from "./test-util"; + + describe("unit tests AwsAlbJwksCache", () => { + const jwksUri = "https://public-keys.auth.elb.eu-west-1.amazonaws.com"; + let keypair: ReturnType; + const getDecomposedJwt = (kid?: string) => ({ + header: { + alg: "EC256", + kid: kid ?? keypair.jwk.kid ?? "kid", + }, + payload: {}, + }); + const getAlbResponseArrayBuffer = () => { + const encoder = new TextEncoder(); + return encoder.encode(keypair.publicKeyPem).buffer; + } + beforeAll(() => { + keypair = generateKeyPair({ + kid: "00000000-0000-0000-0000-000000000000", + kty: "EC", + alg: "ES256", + }); + disallowAllRealNetworkTraffic(); + }); + afterAll(() => { + allowAllRealNetworkTraffic(); + }); + + test("ALB JWKS cache error flow: kid empty", () => { + const jwksCache = new AwsAlbJwksCache(); + expect.assertions(1); + return expect( + jwksCache.getJwk(jwksUri, { header: { alg: "EC256" }, payload: {} }) + ).rejects.toThrow(JwtWithoutValidKidError); + }); + + test("ALB JWKS cache returns cached JWK", () => { + const jwksCache = new AwsAlbJwksCache(); + jwksCache.addJwks(jwksUri, keypair.jwks); + expect(jwksCache.getCachedJwk(jwksUri, getDecomposedJwt())).toEqual( + keypair.jwk + ); + }); + + test("ALB JWKS add cache return multiple JWK exception", () => { + const jwksCache = new AwsAlbJwksCache(); + expect(()=>jwksCache.addJwks(jwksUri, { + keys: [keypair.jwk, keypair.jwk] + })).toThrow(JwksValidationError); + }); + + test("ALB JWKS add cache return no kid", () => { + const jwksCache = new AwsAlbJwksCache(); + expect(()=>jwksCache.addJwks(jwksUri, { + keys: [{ + kty: "EC", + alg: "ES256" + }] + })).toThrow(JwkValidationError); + }); + + test("ALB JWKS get JWKS return not implemented exception", () => { + const jwksCache = new AwsAlbJwksCache(); + expect.assertions(1); + return expect(jwksCache.getJwks()).rejects.toThrow( + "Method not implemented." + ); + }); + + 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. + */ + const fetcher = { + fetch: jest.fn(async () => getAlbResponseArrayBuffer()), + }; + const jwksCache = new AwsAlbJwksCache({ + fetcher, + }); + const promise1 = jwksCache.getJwk(jwksUri, getDecomposedJwt()); + const promise2 = jwksCache.getJwk(jwksUri, getDecomposedJwt()); + expect.assertions(2); + expect(promise1).toEqual(promise2); + await Promise.all([promise1, promise2]); + expect(fetcher.fetch).toHaveBeenCalledTimes(1); + }); + + }); + \ No newline at end of file diff --git a/tests/unit/alb-verifier.test.ts b/tests/unit/alb-verifier.test.ts index 6bd3ffb..eabe3ea 100644 --- a/tests/unit/alb-verifier.test.ts +++ b/tests/unit/alb-verifier.test.ts @@ -12,7 +12,8 @@ import { ParameterValidationError, JwtInvalidClaimError, JwtInvalidIssuerError, - FailedAssertionError, + AlbJwtInvalidSignerError, + AlbJwtInvalidClientIdError, } from "../../src/error"; import { createPublicKey } from "crypto"; @@ -327,87 +328,6 @@ describe("unit tests alb verifier", () => { ).toMatchObject({ hello: "world" }); }); - test("albArn null", () => { - const kid = keypair.jwk.kid; - const userPoolId = "us-east-1_123456"; - const issuer = `https://cognito-idp.us-east-1.amazonaws.com/${userPoolId}`; - const albArn = - "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; - const clientId = "my-client-id"; - 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: albArn, - exp, - }, - { - hello: "world", - exp, - iss: issuer, - }, - keypair.privateKey - ); - const verifier = AlbJwtVerifier.create({ - albArn: null, - issuer, - clientId, - }); - verifier.cacheJwks(keypair.jwks); - - expect.assertions(1); - expect(verifier.verifySync(signedJwt)).toMatchObject({ - hello: "world", - }); - }); - - test("albArn undefined", () => { - const kid = keypair.jwk.kid; - const userPoolId = "us-east-1_123456"; - const issuer = `https://cognito-idp.us-east-1.amazonaws.com/${userPoolId}`; - const albArn = - "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; - const clientId = "my-client-id"; - 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: albArn, - exp, - }, - { - hello: "world", - exp, - iss: issuer, - }, - keypair.privateKey - ); - const verifier = AlbJwtVerifier.create({ - albArn: undefined as unknown as null, - issuer, - clientId, - }); - verifier.cacheJwks(keypair.jwks); - - expect.assertions(2); - expect(() => verifier.verifySync(signedJwt)).toThrow( - "AlbArn must be provided or set to null explicitly" - ); - expect(() => verifier.verifySync(signedJwt)).toThrow( - ParameterValidationError - ); - }); - test("clientId null", async () => { const region = "us-east-1"; const userPoolId = "us-east-1_123456"; @@ -578,7 +498,7 @@ describe("unit tests alb verifier", () => { expect.assertions(1); expect(() => albVerifier.verifySync(signedJwt)).toThrow( - FailedAssertionError + AlbJwtInvalidSignerError ); }); @@ -622,7 +542,7 @@ describe("unit tests alb verifier", () => { expect.assertions(1); expect(() => albVerifier.verifySync(signedJwt)).toThrow( - FailedAssertionError + AlbJwtInvalidClientIdError ); }); }); @@ -730,20 +650,6 @@ describe("unit tests alb verifier", () => { }); }); - test("can't extract region when null albArn and undefined jwksUri", async () => { - const region = "us-east-1"; - const userPoolId = "us-east-1_123456"; - const clientId = "my-client-id"; - const issuer = `https://cognito-idp.${region}.amazonaws.com/${userPoolId}`; - - expect(() => { - AlbJwtVerifier.create({ - issuer, - clientId, - albArn: null, - }); - }).toThrow(ParameterValidationError); - }); }); }); diff --git a/tests/unit/cache.test.ts b/tests/unit/cache.test.ts new file mode 100644 index 0000000..cdedd4b --- /dev/null +++ b/tests/unit/cache.test.ts @@ -0,0 +1,65 @@ +import { SimpleLruCache } from '../../src/cache'; + +describe('unit test SimpleLruCache', () => { + test('should throw an error if capacity is less than 1', () => { + expect(() => new SimpleLruCache(0)).toThrow('capacity must be greater than 0, but got 0'); + }); + + test('should initialize with the correct capacity', () => { + const cache = new SimpleLruCache(2); + expect(cache.capacity).toBe(2); + }); + + test('should return undefined for a non-existent key', () => { + const cache = new SimpleLruCache(2); + expect(cache.get(1)).toBeUndefined(); + }); + + test('should set and get a value', () => { + const cache = new SimpleLruCache(2); + cache.set(1, 100); + expect(cache.get(1)).toBe(100); + }); + + test('should evict the least recently used item when capacity is exceeded', () => { + const cache = new SimpleLruCache(2); + cache.set(1, 100); + cache.set(2, 200); + cache.set(3, 300); + expect(cache.get(1)).toBeUndefined(); + expect(cache.get(2)).toBe(200); + expect(cache.get(3)).toBe(300); + }); + + test('should move accessed item to the most recent position', () => { + const cache = new SimpleLruCache(2); + cache.set(1, 100); + cache.set(2, 200); + cache.get(1); + cache.set(3, 300); + expect(cache.get(2)).toBeUndefined(); + expect(cache.get(1)).toBe(100); + expect(cache.get(3)).toBe(300); + }); + + test('should return the correct size', () => { + const cache = new SimpleLruCache(2); + expect(cache.size).toBe(0); + cache.set(1, 100); + expect(cache.size).toBe(1); + cache.set(2, 200); + expect(cache.size).toBe(2); + cache.set(3, 300); + expect(cache.size).toBe(2); + }); + + test('should convert the cache to an array in the correct order', () => { + const cache = new SimpleLruCache(2); + cache.set(1, 100); + cache.set(2, 200); + expect(cache.toArray()).toEqual([[1, 100], [2, 200]]); + cache.get(1); + cache.set(3, 300); + expect(cache.toArray()).toEqual([[1, 100], [3, 300]]); + }); +}); From b9b3fcb40369b08e2f606735f10fbad9644b3265 Mon Sep 17 00:00:00 2001 From: Nicolas Viaud Date: Fri, 7 Feb 2025 13:34:45 +0000 Subject: [PATCH 08/18] reach 100% test coverage for alb-cache.ts --- src/alb-cache.ts | 1 + tests/unit/alb-cache.test.ts | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/src/alb-cache.ts b/src/alb-cache.ts index 59e78a5..1579388 100644 --- a/src/alb-cache.ts +++ b/src/alb-cache.ts @@ -119,6 +119,7 @@ export class AwsAlbJwksCache implements JwksCache { return { kid: kid, use: "sig", + alg: "ES256", ...jwk, } as JwkWithKid; } diff --git a/tests/unit/alb-cache.test.ts b/tests/unit/alb-cache.test.ts index 0c4ca53..2fb5953 100644 --- a/tests/unit/alb-cache.test.ts +++ b/tests/unit/alb-cache.test.ts @@ -1,5 +1,6 @@ import { AwsAlbJwksCache } from "../../src/alb-cache"; import { + JwksNotAvailableInCacheError, JwksValidationError, JwkValidationError, JwtWithoutValidKidError, @@ -32,6 +33,17 @@ import { allowAllRealNetworkTraffic(); }); + test("ALB JWKS cache happy flow", () => { + const fetcher = { + fetch: jest.fn(async () => getAlbResponseArrayBuffer()), + }; + const jwksCache = new AwsAlbJwksCache({ fetcher }); + expect.assertions(1); + return expect( + jwksCache.getJwk(jwksUri, getDecomposedJwt()) + ).resolves.toEqual(keypair.jwk); + }); + test("ALB JWKS cache error flow: kid empty", () => { const jwksCache = new AwsAlbJwksCache(); expect.assertions(1); @@ -40,6 +52,23 @@ import { ).rejects.toThrow(JwtWithoutValidKidError); }); + test("ALB JWKS cache error flow: fetcher error", () => { + const errorExpected = new Error("fetcher error"); + const jwksCache = new AwsAlbJwksCache( + { + fetcher: { + fetch: async () => { + throw errorExpected; + }, + }, + } + ); + expect.assertions(1); + return expect( + jwksCache.getJwk(jwksUri, getDecomposedJwt()) + ).rejects.toThrow(errorExpected); + }); + test("ALB JWKS cache returns cached JWK", () => { const jwksCache = new AwsAlbJwksCache(); jwksCache.addJwks(jwksUri, keypair.jwks); @@ -48,6 +77,11 @@ import { ); }); + test("ALB JWKS cache returns no JWK", () => { + const jwksCache = new AwsAlbJwksCache(); + expect(()=>jwksCache.getCachedJwk(jwksUri, getDecomposedJwt())).toThrow(JwksNotAvailableInCacheError); + }); + test("ALB JWKS add cache return multiple JWK exception", () => { const jwksCache = new AwsAlbJwksCache(); expect(()=>jwksCache.addJwks(jwksUri, { From bb711e6f043a6f46264ee350bc2557f3f20e59ce Mon Sep 17 00:00:00 2001 From: Nicolas Viaud Date: Fri, 7 Feb 2025 18:36:20 +0000 Subject: [PATCH 09/18] fix cypress test during CI/CD --- src/alb-verifier.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/alb-verifier.ts b/src/alb-verifier.ts index 6306cd3..f7ea160 100644 --- a/src/alb-verifier.ts +++ b/src/alb-verifier.ts @@ -1,4 +1,4 @@ -import { AwsAlbJwksCache } from "./alb-cache"; +import { AwsAlbJwksCache } from "./alb-cache.js"; import { assertStringArrayContainsString } from "./assert.js"; import { AlbJwtInvalidClientIdError, AlbJwtInvalidSignerError, JwtInvalidClaimError, ParameterValidationError } from "./error.js"; import { Jwk, JwksCache } from "./jwk.js"; From 09688d9431adf5dc7dba1d46a1e0f912527dc7be Mon Sep 17 00:00:00 2001 From: Nicolas Viaud Date: Sat, 8 Feb 2025 11:15:44 +0000 Subject: [PATCH 10/18] add alb arn param verification mandatory for alb-verifier --- src/alb-verifier.ts | 59 +++++----- tests/unit/alb-verifier.test.ts | 193 +++++++++++++++++++++----------- 2 files changed, 158 insertions(+), 94 deletions(-) diff --git a/src/alb-verifier.ts b/src/alb-verifier.ts index f7ea160..98b7ad9 100644 --- a/src/alb-verifier.ts +++ b/src/alb-verifier.ts @@ -146,17 +146,19 @@ export class AlbJwtVerifier< props: AlbJwtVerifierProperties | AlbJwtVerifierMultiProperties[], jwksCache: JwksCache ) { - const issuerConfig = Array.isArray(props) - ? (props.map((p) => ({ - jwksUri: p.jwksUri ?? defaultJwksUri(p.albArn), - ...p, - audience: null, - })) as IssuerConfig[]) - : ({ - jwksUri: props.jwksUri ?? defaultJwksUri(props.albArn), + + const transformPropertiesToIssuerConfig = (props: AlbJwtVerifierProperties) => { + const paramsValidationResult = validateAlbJwtParams(props.albArn); + const region = paramsValidationResult.region; + return { + jwksUri: props.jwksUri ?? `https://public-keys.auth.elb.${region}.amazonaws.com`, ...props, audience: null, - } as IssuerConfig); + } as IssuerConfig; + } + + let issuerConfig = Array.isArray(props) ? props.map(transformPropertiesToIssuerConfig) : transformPropertiesToIssuerConfig(props); + super(issuerConfig, jwksCache); } @@ -291,31 +293,36 @@ export function validateAlbJwtFields( } } -export function defaultJwksUri(albArn: string | string[]): string { - - const regionRegex = /^[a-z]{2}-[a-z]+-\d{1}$/; - - const extractRegion = (arn: string): string => { - const arnParts = arn.split(":"); - if (arnParts.length < 4 || arnParts[0] !== "arn" || arnParts[1] !== "aws" || arnParts[2] !== "elasticloadbalancing") { - throw new ParameterValidationError(`Invalid load balancer ARN: ${arn}`); - } - const region = arnParts[3]; - if (!regionRegex.test(region)) { - throw new ParameterValidationError(`Invalid AWS region in ARN: ${region}`); - } - return region; - }; +const regionRegex = /^[a-z]{2}-[a-z]+-\d{1}$/; +export function validateAlbJwtParams(albArn: string | string[]): { + region: string; +} { if (Array.isArray(albArn)) { const regions = albArn.map(extractRegion); const uniqueRegions = Array.from(new Set(regions)); if (uniqueRegions.length > 1) { throw new ParameterValidationError("Multiple regions found in ALB ARNs"); } - return `https://public-keys.auth.elb.${uniqueRegions[0]}.amazonaws.com`; + return { + region: uniqueRegions[0], + }; } else { const region = extractRegion(albArn); - return `https://public-keys.auth.elb.${region}.amazonaws.com`; + return { + region, + } } } + +export function extractRegion(arn: string): string { + const arnParts = arn.split(":"); + if (arnParts.length < 4 || arnParts[0] !== "arn" || arnParts[1] !== "aws" || arnParts[2] !== "elasticloadbalancing") { + throw new ParameterValidationError(`Invalid load balancer ARN: ${arn}`); + } + const region = arnParts[3]; + if (!regionRegex.test(region)) { + throw new ParameterValidationError(`Invalid AWS region in ARN: ${region}`); + } + return region; +}; \ No newline at end of file diff --git a/tests/unit/alb-verifier.test.ts b/tests/unit/alb-verifier.test.ts index eabe3ea..ae1a671 100644 --- a/tests/unit/alb-verifier.test.ts +++ b/tests/unit/alb-verifier.test.ts @@ -7,7 +7,7 @@ import { } from "./test-util"; import { decomposeUnverifiedJwt } from "../../src/jwt"; import { JwksCache, Jwks } from "../../src/jwk"; -import { AlbJwtVerifier, defaultJwksUri } from "../../src/alb-verifier"; +import { AlbJwtVerifier, validateAlbJwtFields, validateAlbJwtParams } from "../../src/alb-verifier"; import { ParameterValidationError, JwtInvalidClaimError, @@ -80,6 +80,64 @@ describe("unit tests alb verifier", () => { }); }); }); + describe("validateAlbJwtParams", () => { + test("invalid load balancer ARN - too short", async () => { + const userPoolId = "us-east-1_123456"; + const issuer = `https://cognito-idp.us-east-1.amazonaws.com/${userPoolId}`; + const albArn = "arn:aws:elasticloadbalancing"; + + expect(() => AlbJwtVerifier.create({ + albArn, + issuer, + })).toThrow(new ParameterValidationError(`Invalid load balancer ARN: arn:aws:elasticloadbalancing`)) + }); + + test("invalid load balancer ARN - invalid region 1", async () => { + const userPoolId = "us-east-1_123456"; + const issuer = `https://cognito-idp.us-east-1.amazonaws.com/${userPoolId}`; + const albArn = "arn:aws:elasticloadbalancing:.:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; + + expect(() => AlbJwtVerifier.create({ + albArn, + issuer, + })).toThrow(new ParameterValidationError(`Invalid AWS region in ARN: .`)) + }); + + + test("invalid load balancer ARN - invalid region 2", async () => { + const userPoolId = "us-east-1_123456"; + const issuer = `https://cognito-idp.us-east-1.amazonaws.com/${userPoolId}`; + const albArn = "arn:aws:elasticloadbalancing:/:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; + + expect(() => AlbJwtVerifier.create({ + albArn, + issuer, + })).toThrow(new ParameterValidationError(`Invalid AWS region in ARN: /`)) + }); + + test("invalid load balancer ARN - invalid region 3", async () => { + const userPoolId = "us-east-1_123456"; + const issuer = `https://cognito-idp.us-east-1.amazonaws.com/${userPoolId}`; + const albArn = "arn:aws:elasticloadbalancing:?:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; + + expect(() => AlbJwtVerifier.create({ + albArn, + issuer, + })).toThrow(new ParameterValidationError(`Invalid AWS region in ARN: ?`)) + }); + + test("invalid load balancer ARN - invalid region 4", async () => { + const userPoolId = "us-east-1_123456"; + const issuer = `https://cognito-idp.us-east-1.amazonaws.com/${userPoolId}`; + const albArn = "arn:aws:elasticloadbalancing:=:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; + + expect(() => AlbJwtVerifier.create({ + albArn, + issuer, + })).toThrow(new ParameterValidationError(`Invalid AWS region in ARN: =`)) + }); + + }); describe("includeRawJwtInErrors", () => { test("verify - flag set at statement level", () => { const kid = keypair.jwk.kid; @@ -547,7 +605,7 @@ describe("unit tests alb verifier", () => { }); }); describe("jwksUri", () => { - test("default jwksUri", async () => { + test("default jwksUri in us-east-1", async () => { const region = "us-east-1"; const userPoolId = "us-east-1_123456"; const albArn = @@ -598,6 +656,57 @@ describe("unit tests alb verifier", () => { }); }); + test("default jwksUri in eu-west-2", async () => { + const region = "eu-west-2"; + const userPoolId = "eu-west-2_123456"; + const albArn = + "arn:aws:elasticloadbalancing:eu-west-2: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: albArn, + exp, + }, + { + hello: "world", + exp, + iss: issuer, + }, + keypair.privateKey + ); + const albVerifier = AlbJwtVerifier.create({ + issuer, + clientId, + albArn, + }); + expect.assertions(1); + expect(await albVerifier.verify(signedJwt)).toMatchObject({ + hello: "world", + }); + }); + test("custom jwksUri", async () => { const region = "us-east-1"; const userPoolId = "us-east-1_123456"; @@ -880,74 +989,22 @@ describe("unit tests alb verifier", () => { ); }); }); - }); - describe("defaultJwksUri", () => { - test("happy flow with us-east-1 region", () => { - const albArn = - "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; - - expect(defaultJwksUri(albArn)).toBe("https://public-keys.auth.elb.us-east-1.amazonaws.com"); - }); - - test("happy flow with eu-west-2 region", () => { - const albArn = - "arn:aws:elasticloadbalancing:eu-west-2:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; - - expect(defaultJwksUri(albArn)).toBe("https://public-keys.auth.elb.eu-west-2.amazonaws.com"); - }); - - test("happy flow with multi ALB ARN", () => { - const albArns = [ - "arn:aws:elasticloadbalancing:eu-west-2:123456789012:loadbalancer/app/my-load-balancer-1/50dc6c495c0c9188", - "arn:aws:elasticloadbalancing:eu-west-2:123456789012:loadbalancer/app/my-load-balancer-2/901e7c495c0c9188", - ] - - expect(defaultJwksUri(albArns)).toBe("https://public-keys.auth.elb.eu-west-2.amazonaws.com"); - }); - - test("invalid load balancer ARN - too short", () => { - const albArn = - "arn:aws:elasticloadbalancing"; - - expect(()=>defaultJwksUri(albArn)).toThrow(new ParameterValidationError(`Invalid load balancer ARN: arn:aws:elasticloadbalancing`)); - }); - - test("invalid load balancer ARN - invalid region 1", () => { - const albArn = - "arn:aws:elasticloadbalancing:.:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; - - expect(()=>defaultJwksUri(albArn)).toThrow(new ParameterValidationError(`Invalid AWS region in ARN: .`)); - }); - - test("invalid load balancer ARN - invalid region 2", () => { - const albArn = - "arn:aws:elasticloadbalancing:/:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; - - expect(()=>defaultJwksUri(albArn)).toThrow(new ParameterValidationError(`Invalid AWS region in ARN: /`)); - }); - - test("invalid load balancer ARN - invalid region 3", () => { - const albArn = - "arn:aws:elasticloadbalancing:?:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; - - expect(()=>defaultJwksUri(albArn)).toThrow(new ParameterValidationError(`Invalid AWS region in ARN: ?`)); - }); - - test("invalid load balancer ARN - invalid region 4", () => { - const albArn = - "arn:aws:elasticloadbalancing:=:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; - - expect(()=>defaultJwksUri(albArn)).toThrow(new ParameterValidationError((`Invalid AWS region in ARN: =`))); - }); + describe("validateAlbJwtParams", () => { + test("invalid load balancer ARN with multiple regions", async () => { + const userPoolId = "us-east-1_123456"; + const issuer = `https://cognito-idp.us-east-1.amazonaws.com/${userPoolId}`; + const albArn = [ + "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer-1/50dc6c495c0c9188", + "arn:aws:elasticloadbalancing:eu-west-2:123456789012:loadbalancer/app/my-load-balancer-2/901e7c495c0c9188", + ] - test("invalid load balancer ARN with multiple regions", () => { - const albArns = [ - "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer-1/50dc6c495c0c9188", - "arn:aws:elasticloadbalancing:eu-west-2:123456789012:loadbalancer/app/my-load-balancer-2/901e7c495c0c9188", - ] - - expect(()=>defaultJwksUri(albArns)).toThrow(new ParameterValidationError("Multiple regions found in ALB ARNs")); + expect(() => AlbJwtVerifier.create({ + albArn, + issuer, + })).toThrow(new ParameterValidationError("Multiple regions found in ALB ARNs")) + }); }); }); + }); From 6d3abb14a6929db2723ceb64bcec12acf463c406 Mon Sep 17 00:00:00 2001 From: Nicolas Viaud Date: Sat, 8 Feb 2025 11:33:40 +0000 Subject: [PATCH 11/18] Run linter and prettier --- src/alb-verifier.ts | 45 +++--- src/error.ts | 1 - src/jwt-model.ts | 5 +- tests/unit/alb-cache.test.ts | 246 +++++++++++++++++--------------- tests/unit/alb-verifier.test.ts | 86 ++++++----- tests/unit/cache.test.ts | 120 ++++++++-------- 6 files changed, 275 insertions(+), 228 deletions(-) diff --git a/src/alb-verifier.ts b/src/alb-verifier.ts index 98b7ad9..232f751 100644 --- a/src/alb-verifier.ts +++ b/src/alb-verifier.ts @@ -1,6 +1,11 @@ import { AwsAlbJwksCache } from "./alb-cache.js"; import { assertStringArrayContainsString } from "./assert.js"; -import { AlbJwtInvalidClientIdError, AlbJwtInvalidSignerError, JwtInvalidClaimError, ParameterValidationError } from "./error.js"; +import { + AlbJwtInvalidClientIdError, + AlbJwtInvalidSignerError, + JwtInvalidClaimError, + ParameterValidationError, +} from "./error.js"; import { Jwk, JwksCache } from "./jwk.js"; import { AlbJwtHeader, AlbJwtPayload, JwtHeader } from "./jwt-model.js"; // todo consider creating a specific type for AWS ALB JWT Payload import { JwtVerifierBase, JwtVerifierProperties } from "./jwt-verifier.js"; @@ -146,18 +151,23 @@ export class AlbJwtVerifier< props: AlbJwtVerifierProperties | AlbJwtVerifierMultiProperties[], jwksCache: JwksCache ) { - - const transformPropertiesToIssuerConfig = (props: AlbJwtVerifierProperties) => { + const transformPropertiesToIssuerConfig = ( + props: AlbJwtVerifierProperties + ) => { const paramsValidationResult = validateAlbJwtParams(props.albArn); const region = paramsValidationResult.region; return { - jwksUri: props.jwksUri ?? `https://public-keys.auth.elb.${region}.amazonaws.com`, - ...props, - audience: null, - } as IssuerConfig; - } + jwksUri: + props.jwksUri ?? + `https://public-keys.auth.elb.${region}.amazonaws.com`, + ...props, + audience: null, + } as IssuerConfig; + }; - let issuerConfig = Array.isArray(props) ? props.map(transformPropertiesToIssuerConfig) : transformPropertiesToIssuerConfig(props); + const issuerConfig = Array.isArray(props) + ? props.map(transformPropertiesToIssuerConfig) + : transformPropertiesToIssuerConfig(props); super(issuerConfig, jwksCache); } @@ -267,9 +277,7 @@ export function validateAlbJwtFields( ): void { // Check ALB ARN (signer) if (options.albArn === undefined) { - throw new ParameterValidationError( - "albArn must be provided" - ); + throw new ParameterValidationError("albArn must be provided"); } assertStringArrayContainsString( "ALB ARN", @@ -306,18 +314,23 @@ export function validateAlbJwtParams(albArn: string | string[]): { } return { region: uniqueRegions[0], - }; + }; } else { const region = extractRegion(albArn); return { region, - } + }; } } export function extractRegion(arn: string): string { const arnParts = arn.split(":"); - if (arnParts.length < 4 || arnParts[0] !== "arn" || arnParts[1] !== "aws" || arnParts[2] !== "elasticloadbalancing") { + if ( + arnParts.length < 4 || + arnParts[0] !== "arn" || + arnParts[1] !== "aws" || + arnParts[2] !== "elasticloadbalancing" + ) { throw new ParameterValidationError(`Invalid load balancer ARN: ${arn}`); } const region = arnParts[3]; @@ -325,4 +338,4 @@ export function extractRegion(arn: string): string { throw new ParameterValidationError(`Invalid AWS region in ARN: ${region}`); } return region; -}; \ No newline at end of file +} diff --git a/src/error.ts b/src/error.ts index 0f4fd80..a106782 100644 --- a/src/error.ts +++ b/src/error.ts @@ -106,7 +106,6 @@ export class CognitoJwtInvalidTokenUseError extends JwtInvalidClaimError {} export class CognitoJwtInvalidClientIdError extends JwtInvalidClaimError {} - /** * Amazon ALB specific erros */ diff --git a/src/jwt-model.ts b/src/jwt-model.ts index 379f934..2808617 100644 --- a/src/jwt-model.ts +++ b/src/jwt-model.ts @@ -103,9 +103,10 @@ export interface AlbJwtHeader extends JwtHeader { export type AlbJwtPayload = { exp: number; iss: string; -} & JwtPayloadStandardFields & JsonObject; +} & JwtPayloadStandardFields & + JsonObject; export interface AlbJwt { header: AlbJwtHeader; payload: AlbJwtPayload; -} \ No newline at end of file +} diff --git a/tests/unit/alb-cache.test.ts b/tests/unit/alb-cache.test.ts index 2fb5953..3d142a8 100644 --- a/tests/unit/alb-cache.test.ts +++ b/tests/unit/alb-cache.test.ts @@ -1,131 +1,139 @@ import { AwsAlbJwksCache } from "../../src/alb-cache"; import { - JwksNotAvailableInCacheError, - JwksValidationError, - JwkValidationError, - JwtWithoutValidKidError, - } from "../../src/error"; - import { allowAllRealNetworkTraffic, disallowAllRealNetworkTraffic, generateKeyPair } from "./test-util"; - - describe("unit tests AwsAlbJwksCache", () => { - const jwksUri = "https://public-keys.auth.elb.eu-west-1.amazonaws.com"; - let keypair: ReturnType; - const getDecomposedJwt = (kid?: string) => ({ - header: { - alg: "EC256", - kid: kid ?? keypair.jwk.kid ?? "kid", - }, - payload: {}, - }); - const getAlbResponseArrayBuffer = () => { - const encoder = new TextEncoder(); - return encoder.encode(keypair.publicKeyPem).buffer; - } - beforeAll(() => { - keypair = generateKeyPair({ - kid: "00000000-0000-0000-0000-000000000000", - kty: "EC", - alg: "ES256", - }); - disallowAllRealNetworkTraffic(); - }); - afterAll(() => { - allowAllRealNetworkTraffic(); - }); + JwksNotAvailableInCacheError, + JwksValidationError, + JwkValidationError, + JwtWithoutValidKidError, +} from "../../src/error"; +import { + allowAllRealNetworkTraffic, + disallowAllRealNetworkTraffic, + generateKeyPair, +} from "./test-util"; - test("ALB JWKS cache happy flow", () => { - const fetcher = { - fetch: jest.fn(async () => getAlbResponseArrayBuffer()), - }; - const jwksCache = new AwsAlbJwksCache({ fetcher }); - expect.assertions(1); - return expect( - jwksCache.getJwk(jwksUri, getDecomposedJwt()) - ).resolves.toEqual(keypair.jwk); - }); - - test("ALB JWKS cache error flow: kid empty", () => { - const jwksCache = new AwsAlbJwksCache(); - expect.assertions(1); - return expect( - jwksCache.getJwk(jwksUri, { header: { alg: "EC256" }, payload: {} }) - ).rejects.toThrow(JwtWithoutValidKidError); +describe("unit tests AwsAlbJwksCache", () => { + const jwksUri = "https://public-keys.auth.elb.eu-west-1.amazonaws.com"; + let keypair: ReturnType; + const getDecomposedJwt = (kid?: string) => ({ + header: { + alg: "EC256", + kid: kid ?? keypair.jwk.kid ?? "kid", + }, + payload: {}, + }); + const getAlbResponseArrayBuffer = () => { + const encoder = new TextEncoder(); + return encoder.encode(keypair.publicKeyPem).buffer; + }; + beforeAll(() => { + keypair = generateKeyPair({ + kid: "00000000-0000-0000-0000-000000000000", + kty: "EC", + alg: "ES256", }); - - test("ALB JWKS cache error flow: fetcher error", () => { - const errorExpected = new Error("fetcher error"); - const jwksCache = new AwsAlbJwksCache( - { - fetcher: { - fetch: async () => { - throw errorExpected; - }, - }, - } - ); - expect.assertions(1); - return expect( - jwksCache.getJwk(jwksUri, getDecomposedJwt()) - ).rejects.toThrow(errorExpected); - }); + disallowAllRealNetworkTraffic(); + }); + afterAll(() => { + allowAllRealNetworkTraffic(); + }); - test("ALB JWKS cache returns cached JWK", () => { - const jwksCache = new AwsAlbJwksCache(); - jwksCache.addJwks(jwksUri, keypair.jwks); - expect(jwksCache.getCachedJwk(jwksUri, getDecomposedJwt())).toEqual( - keypair.jwk - ); - }); + test("ALB JWKS cache happy flow", () => { + const fetcher = { + fetch: jest.fn(async () => getAlbResponseArrayBuffer()), + }; + const jwksCache = new AwsAlbJwksCache({ fetcher }); + expect.assertions(1); + return expect( + jwksCache.getJwk(jwksUri, getDecomposedJwt()) + ).resolves.toEqual(keypair.jwk); + }); - test("ALB JWKS cache returns no JWK", () => { - const jwksCache = new AwsAlbJwksCache(); - expect(()=>jwksCache.getCachedJwk(jwksUri, getDecomposedJwt())).toThrow(JwksNotAvailableInCacheError); - }); + test("ALB JWKS cache error flow: kid empty", () => { + const jwksCache = new AwsAlbJwksCache(); + expect.assertions(1); + return expect( + jwksCache.getJwk(jwksUri, { header: { alg: "EC256" }, payload: {} }) + ).rejects.toThrow(JwtWithoutValidKidError); + }); - test("ALB JWKS add cache return multiple JWK exception", () => { - const jwksCache = new AwsAlbJwksCache(); - expect(()=>jwksCache.addJwks(jwksUri, { - keys: [keypair.jwk, keypair.jwk] - })).toThrow(JwksValidationError); + test("ALB JWKS cache error flow: fetcher error", () => { + const errorExpected = new Error("fetcher error"); + const jwksCache = new AwsAlbJwksCache({ + fetcher: { + fetch: async () => { + throw errorExpected; + }, + }, }); + expect.assertions(1); + return expect( + jwksCache.getJwk(jwksUri, getDecomposedJwt()) + ).rejects.toThrow(errorExpected); + }); - test("ALB JWKS add cache return no kid", () => { - const jwksCache = new AwsAlbJwksCache(); - expect(()=>jwksCache.addJwks(jwksUri, { - keys: [{ - kty: "EC", - alg: "ES256" - }] - })).toThrow(JwkValidationError); - }); + test("ALB JWKS cache returns cached JWK", () => { + const jwksCache = new AwsAlbJwksCache(); + jwksCache.addJwks(jwksUri, keypair.jwks); + expect(jwksCache.getCachedJwk(jwksUri, getDecomposedJwt())).toEqual( + keypair.jwk + ); + }); - test("ALB JWKS get JWKS return not implemented exception", () => { - const jwksCache = new AwsAlbJwksCache(); - expect.assertions(1); - return expect(jwksCache.getJwks()).rejects.toThrow( - "Method not implemented." - ); - }); - - 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. - */ - const fetcher = { - fetch: jest.fn(async () => getAlbResponseArrayBuffer()), - }; - const jwksCache = new AwsAlbJwksCache({ - fetcher, - }); - const promise1 = jwksCache.getJwk(jwksUri, getDecomposedJwt()); - const promise2 = jwksCache.getJwk(jwksUri, getDecomposedJwt()); - expect.assertions(2); - expect(promise1).toEqual(promise2); - await Promise.all([promise1, promise2]); - expect(fetcher.fetch).toHaveBeenCalledTimes(1); + test("ALB JWKS cache returns no JWK", () => { + const jwksCache = new AwsAlbJwksCache(); + expect(() => jwksCache.getCachedJwk(jwksUri, getDecomposedJwt())).toThrow( + JwksNotAvailableInCacheError + ); + }); + + test("ALB JWKS add cache return multiple JWK exception", () => { + const jwksCache = new AwsAlbJwksCache(); + expect(() => + jwksCache.addJwks(jwksUri, { + keys: [keypair.jwk, keypair.jwk], + }) + ).toThrow(JwksValidationError); + }); + + test("ALB JWKS add cache return no kid", () => { + const jwksCache = new AwsAlbJwksCache(); + expect(() => + jwksCache.addJwks(jwksUri, { + keys: [ + { + kty: "EC", + alg: "ES256", + }, + ], + }) + ).toThrow(JwkValidationError); + }); + + test("ALB JWKS get JWKS return not implemented exception", () => { + const jwksCache = new AwsAlbJwksCache(); + expect.assertions(1); + return expect(jwksCache.getJwks()).rejects.toThrow( + "Method not implemented." + ); + }); + + 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. + */ + const fetcher = { + fetch: jest.fn(async () => getAlbResponseArrayBuffer()), + }; + const jwksCache = new AwsAlbJwksCache({ + fetcher, }); - + const promise1 = jwksCache.getJwk(jwksUri, getDecomposedJwt()); + const promise2 = jwksCache.getJwk(jwksUri, getDecomposedJwt()); + expect.assertions(2); + expect(promise1).toEqual(promise2); + await Promise.all([promise1, promise2]); + expect(fetcher.fetch).toHaveBeenCalledTimes(1); }); - \ No newline at end of file +}); diff --git a/tests/unit/alb-verifier.test.ts b/tests/unit/alb-verifier.test.ts index ae1a671..b959312 100644 --- a/tests/unit/alb-verifier.test.ts +++ b/tests/unit/alb-verifier.test.ts @@ -7,7 +7,7 @@ import { } from "./test-util"; import { decomposeUnverifiedJwt } from "../../src/jwt"; import { JwksCache, Jwks } from "../../src/jwk"; -import { AlbJwtVerifier, validateAlbJwtFields, validateAlbJwtParams } from "../../src/alb-verifier"; +import { AlbJwtVerifier } from "../../src/alb-verifier"; import { ParameterValidationError, JwtInvalidClaimError, @@ -86,57 +86,73 @@ describe("unit tests alb verifier", () => { const issuer = `https://cognito-idp.us-east-1.amazonaws.com/${userPoolId}`; const albArn = "arn:aws:elasticloadbalancing"; - expect(() => AlbJwtVerifier.create({ - albArn, - issuer, - })).toThrow(new ParameterValidationError(`Invalid load balancer ARN: arn:aws:elasticloadbalancing`)) + expect(() => + AlbJwtVerifier.create({ + albArn, + issuer, + }) + ).toThrow( + new ParameterValidationError( + `Invalid load balancer ARN: arn:aws:elasticloadbalancing` + ) + ); }); test("invalid load balancer ARN - invalid region 1", async () => { const userPoolId = "us-east-1_123456"; const issuer = `https://cognito-idp.us-east-1.amazonaws.com/${userPoolId}`; - const albArn = "arn:aws:elasticloadbalancing:.:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; + const albArn = + "arn:aws:elasticloadbalancing:.:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; - expect(() => AlbJwtVerifier.create({ - albArn, - issuer, - })).toThrow(new ParameterValidationError(`Invalid AWS region in ARN: .`)) + expect(() => + AlbJwtVerifier.create({ + albArn, + issuer, + }) + ).toThrow(new ParameterValidationError(`Invalid AWS region in ARN: .`)); }); - test("invalid load balancer ARN - invalid region 2", async () => { const userPoolId = "us-east-1_123456"; const issuer = `https://cognito-idp.us-east-1.amazonaws.com/${userPoolId}`; - const albArn = "arn:aws:elasticloadbalancing:/:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; + const albArn = + "arn:aws:elasticloadbalancing:/:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; - expect(() => AlbJwtVerifier.create({ - albArn, - issuer, - })).toThrow(new ParameterValidationError(`Invalid AWS region in ARN: /`)) + expect(() => + AlbJwtVerifier.create({ + albArn, + issuer, + }) + ).toThrow(new ParameterValidationError(`Invalid AWS region in ARN: /`)); }); test("invalid load balancer ARN - invalid region 3", async () => { const userPoolId = "us-east-1_123456"; const issuer = `https://cognito-idp.us-east-1.amazonaws.com/${userPoolId}`; - const albArn = "arn:aws:elasticloadbalancing:?:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; + const albArn = + "arn:aws:elasticloadbalancing:?:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; - expect(() => AlbJwtVerifier.create({ - albArn, - issuer, - })).toThrow(new ParameterValidationError(`Invalid AWS region in ARN: ?`)) + expect(() => + AlbJwtVerifier.create({ + albArn, + issuer, + }) + ).toThrow(new ParameterValidationError(`Invalid AWS region in ARN: ?`)); }); test("invalid load balancer ARN - invalid region 4", async () => { const userPoolId = "us-east-1_123456"; const issuer = `https://cognito-idp.us-east-1.amazonaws.com/${userPoolId}`; - const albArn = "arn:aws:elasticloadbalancing:=:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; + const albArn = + "arn:aws:elasticloadbalancing:=:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; - expect(() => AlbJwtVerifier.create({ - albArn, - issuer, - })).toThrow(new ParameterValidationError(`Invalid AWS region in ARN: =`)) + expect(() => + AlbJwtVerifier.create({ + albArn, + issuer, + }) + ).toThrow(new ParameterValidationError(`Invalid AWS region in ARN: =`)); }); - }); describe("includeRawJwtInErrors", () => { test("verify - flag set at statement level", () => { @@ -758,7 +774,6 @@ describe("unit tests alb verifier", () => { hello: "world", }); }); - }); }); @@ -997,14 +1012,17 @@ describe("unit tests alb verifier", () => { const albArn = [ "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer-1/50dc6c495c0c9188", "arn:aws:elasticloadbalancing:eu-west-2:123456789012:loadbalancer/app/my-load-balancer-2/901e7c495c0c9188", - ] + ]; - expect(() => AlbJwtVerifier.create({ - albArn, - issuer, - })).toThrow(new ParameterValidationError("Multiple regions found in ALB ARNs")) + expect(() => + AlbJwtVerifier.create({ + albArn, + issuer, + }) + ).toThrow( + new ParameterValidationError("Multiple regions found in ALB ARNs") + ); }); }); }); - }); diff --git a/tests/unit/cache.test.ts b/tests/unit/cache.test.ts index cdedd4b..35bb807 100644 --- a/tests/unit/cache.test.ts +++ b/tests/unit/cache.test.ts @@ -1,65 +1,73 @@ -import { SimpleLruCache } from '../../src/cache'; +import { SimpleLruCache } from "../../src/cache"; -describe('unit test SimpleLruCache', () => { - test('should throw an error if capacity is less than 1', () => { - expect(() => new SimpleLruCache(0)).toThrow('capacity must be greater than 0, but got 0'); - }); +describe("unit test SimpleLruCache", () => { + test("should throw an error if capacity is less than 1", () => { + expect(() => new SimpleLruCache(0)).toThrow( + "capacity must be greater than 0, but got 0" + ); + }); - test('should initialize with the correct capacity', () => { - const cache = new SimpleLruCache(2); - expect(cache.capacity).toBe(2); - }); + test("should initialize with the correct capacity", () => { + const cache = new SimpleLruCache(2); + expect(cache.capacity).toBe(2); + }); - test('should return undefined for a non-existent key', () => { - const cache = new SimpleLruCache(2); - expect(cache.get(1)).toBeUndefined(); - }); + test("should return undefined for a non-existent key", () => { + const cache = new SimpleLruCache(2); + expect(cache.get(1)).toBeUndefined(); + }); - test('should set and get a value', () => { - const cache = new SimpleLruCache(2); - cache.set(1, 100); - expect(cache.get(1)).toBe(100); - }); + test("should set and get a value", () => { + const cache = new SimpleLruCache(2); + cache.set(1, 100); + expect(cache.get(1)).toBe(100); + }); - test('should evict the least recently used item when capacity is exceeded', () => { - const cache = new SimpleLruCache(2); - cache.set(1, 100); - cache.set(2, 200); - cache.set(3, 300); - expect(cache.get(1)).toBeUndefined(); - expect(cache.get(2)).toBe(200); - expect(cache.get(3)).toBe(300); - }); + test("should evict the least recently used item when capacity is exceeded", () => { + const cache = new SimpleLruCache(2); + cache.set(1, 100); + cache.set(2, 200); + cache.set(3, 300); + expect(cache.get(1)).toBeUndefined(); + expect(cache.get(2)).toBe(200); + expect(cache.get(3)).toBe(300); + }); - test('should move accessed item to the most recent position', () => { - const cache = new SimpleLruCache(2); - cache.set(1, 100); - cache.set(2, 200); - cache.get(1); - cache.set(3, 300); - expect(cache.get(2)).toBeUndefined(); - expect(cache.get(1)).toBe(100); - expect(cache.get(3)).toBe(300); - }); + test("should move accessed item to the most recent position", () => { + const cache = new SimpleLruCache(2); + cache.set(1, 100); + cache.set(2, 200); + cache.get(1); + cache.set(3, 300); + expect(cache.get(2)).toBeUndefined(); + expect(cache.get(1)).toBe(100); + expect(cache.get(3)).toBe(300); + }); - test('should return the correct size', () => { - const cache = new SimpleLruCache(2); - expect(cache.size).toBe(0); - cache.set(1, 100); - expect(cache.size).toBe(1); - cache.set(2, 200); - expect(cache.size).toBe(2); - cache.set(3, 300); - expect(cache.size).toBe(2); - }); + test("should return the correct size", () => { + const cache = new SimpleLruCache(2); + expect(cache.size).toBe(0); + cache.set(1, 100); + expect(cache.size).toBe(1); + cache.set(2, 200); + expect(cache.size).toBe(2); + cache.set(3, 300); + expect(cache.size).toBe(2); + }); - test('should convert the cache to an array in the correct order', () => { - const cache = new SimpleLruCache(2); - cache.set(1, 100); - cache.set(2, 200); - expect(cache.toArray()).toEqual([[1, 100], [2, 200]]); - cache.get(1); - cache.set(3, 300); - expect(cache.toArray()).toEqual([[1, 100], [3, 300]]); - }); + test("should convert the cache to an array in the correct order", () => { + const cache = new SimpleLruCache(2); + cache.set(1, 100); + cache.set(2, 200); + expect(cache.toArray()).toEqual([ + [1, 100], + [2, 200], + ]); + cache.get(1); + cache.set(3, 300); + expect(cache.toArray()).toEqual([ + [1, 100], + [3, 300], + ]); + }); }); From aa614928574ace4b7e682514b73d83c6a2cac189 Mon Sep 17 00:00:00 2001 From: Nicolas Viaud Date: Sat, 8 Feb 2025 11:37:03 +0000 Subject: [PATCH 12/18] Rename AwsAlbJwksCache to AlbJwksCache to be coherent with the rest of the code --- src/alb-cache.ts | 2 +- src/alb-verifier.ts | 4 ++-- tests/unit/alb-cache.test.ts | 22 +++++++++++----------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/alb-cache.ts b/src/alb-cache.ts index 1579388..2702210 100644 --- a/src/alb-cache.ts +++ b/src/alb-cache.ts @@ -32,7 +32,7 @@ export class AlbUriError extends JwtBaseError {} * This security requirement is mandatory otherwise the application is exposed to several security risks like DoS attack by injecting a forged kid. * */ -export class AwsAlbJwksCache implements JwksCache { +export class AlbJwksCache implements JwksCache { fetcher: Fetcher; // penaltyBox:PenaltyBox; diff --git a/src/alb-verifier.ts b/src/alb-verifier.ts index 232f751..9352b96 100644 --- a/src/alb-verifier.ts +++ b/src/alb-verifier.ts @@ -1,4 +1,4 @@ -import { AwsAlbJwksCache } from "./alb-cache.js"; +import { AlbJwksCache } from "./alb-cache.js"; import { assertStringArrayContainsString } from "./assert.js"; import { AlbJwtInvalidClientIdError, @@ -207,7 +207,7 @@ export class AlbJwtVerifier< ) { return new this( verifyProperties, - additionalProperties?.jwksCache ?? new AwsAlbJwksCache() + additionalProperties?.jwksCache ?? new AlbJwksCache() ); } diff --git a/tests/unit/alb-cache.test.ts b/tests/unit/alb-cache.test.ts index 3d142a8..c76d529 100644 --- a/tests/unit/alb-cache.test.ts +++ b/tests/unit/alb-cache.test.ts @@ -1,4 +1,4 @@ -import { AwsAlbJwksCache } from "../../src/alb-cache"; +import { AlbJwksCache } from "../../src/alb-cache"; import { JwksNotAvailableInCacheError, JwksValidationError, @@ -11,7 +11,7 @@ import { generateKeyPair, } from "./test-util"; -describe("unit tests AwsAlbJwksCache", () => { +describe("unit tests AlbJwksCache", () => { const jwksUri = "https://public-keys.auth.elb.eu-west-1.amazonaws.com"; let keypair: ReturnType; const getDecomposedJwt = (kid?: string) => ({ @@ -41,7 +41,7 @@ describe("unit tests AwsAlbJwksCache", () => { const fetcher = { fetch: jest.fn(async () => getAlbResponseArrayBuffer()), }; - const jwksCache = new AwsAlbJwksCache({ fetcher }); + const jwksCache = new AlbJwksCache({ fetcher }); expect.assertions(1); return expect( jwksCache.getJwk(jwksUri, getDecomposedJwt()) @@ -49,7 +49,7 @@ describe("unit tests AwsAlbJwksCache", () => { }); test("ALB JWKS cache error flow: kid empty", () => { - const jwksCache = new AwsAlbJwksCache(); + const jwksCache = new AlbJwksCache(); expect.assertions(1); return expect( jwksCache.getJwk(jwksUri, { header: { alg: "EC256" }, payload: {} }) @@ -58,7 +58,7 @@ describe("unit tests AwsAlbJwksCache", () => { test("ALB JWKS cache error flow: fetcher error", () => { const errorExpected = new Error("fetcher error"); - const jwksCache = new AwsAlbJwksCache({ + const jwksCache = new AlbJwksCache({ fetcher: { fetch: async () => { throw errorExpected; @@ -72,7 +72,7 @@ describe("unit tests AwsAlbJwksCache", () => { }); test("ALB JWKS cache returns cached JWK", () => { - const jwksCache = new AwsAlbJwksCache(); + const jwksCache = new AlbJwksCache(); jwksCache.addJwks(jwksUri, keypair.jwks); expect(jwksCache.getCachedJwk(jwksUri, getDecomposedJwt())).toEqual( keypair.jwk @@ -80,14 +80,14 @@ describe("unit tests AwsAlbJwksCache", () => { }); test("ALB JWKS cache returns no JWK", () => { - const jwksCache = new AwsAlbJwksCache(); + const jwksCache = new AlbJwksCache(); expect(() => jwksCache.getCachedJwk(jwksUri, getDecomposedJwt())).toThrow( JwksNotAvailableInCacheError ); }); test("ALB JWKS add cache return multiple JWK exception", () => { - const jwksCache = new AwsAlbJwksCache(); + const jwksCache = new AlbJwksCache(); expect(() => jwksCache.addJwks(jwksUri, { keys: [keypair.jwk, keypair.jwk], @@ -96,7 +96,7 @@ describe("unit tests AwsAlbJwksCache", () => { }); test("ALB JWKS add cache return no kid", () => { - const jwksCache = new AwsAlbJwksCache(); + const jwksCache = new AlbJwksCache(); expect(() => jwksCache.addJwks(jwksUri, { keys: [ @@ -110,7 +110,7 @@ describe("unit tests AwsAlbJwksCache", () => { }); test("ALB JWKS get JWKS return not implemented exception", () => { - const jwksCache = new AwsAlbJwksCache(); + const jwksCache = new AlbJwksCache(); expect.assertions(1); return expect(jwksCache.getJwks()).rejects.toThrow( "Method not implemented." @@ -126,7 +126,7 @@ describe("unit tests AwsAlbJwksCache", () => { const fetcher = { fetch: jest.fn(async () => getAlbResponseArrayBuffer()), }; - const jwksCache = new AwsAlbJwksCache({ + const jwksCache = new AlbJwksCache({ fetcher, }); const promise1 = jwksCache.getJwk(jwksUri, getDecomposedJwt()); From d2ec0f213317d38fa843a41616ac1b85f734bddd Mon Sep 17 00:00:00 2001 From: Nicolas Viaud Date: Sat, 8 Feb 2025 13:26:42 +0000 Subject: [PATCH 13/18] Clean penalitybox comment in alb cache --- src/alb-cache.ts | 13 +------------ tests/unit/alb-verifier.test.ts | 12 ++++++++++-- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/src/alb-cache.ts b/src/alb-cache.ts index 2702210..0571fc9 100644 --- a/src/alb-cache.ts +++ b/src/alb-cache.ts @@ -34,17 +34,12 @@ export class AlbUriError extends JwtBaseError {} */ export class AlbJwksCache implements JwksCache { fetcher: Fetcher; - // penaltyBox:PenaltyBox; private jwkCache: SimpleLruCache = new SimpleLruCache(2); private fetchingJwks: Map> = new Map(); - constructor(props?: { - fetcher?: Fetcher; - // penaltyBox?: PenaltyBox; - }) { + constructor(props?: { fetcher?: Fetcher }) { this.fetcher = props?.fetcher ?? new SimpleFetcher(); - // this.penaltyBox = props?.penaltyBox ?? new SimplePenaltyBox(); } private expandWithKid(jwksUri: string, kid: string): string { @@ -81,19 +76,13 @@ export class AlbJwksCache implements JwksCache { 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); }); diff --git a/tests/unit/alb-verifier.test.ts b/tests/unit/alb-verifier.test.ts index b959312..2e9681f 100644 --- a/tests/unit/alb-verifier.test.ts +++ b/tests/unit/alb-verifier.test.ts @@ -7,7 +7,10 @@ import { } from "./test-util"; import { decomposeUnverifiedJwt } from "../../src/jwt"; import { JwksCache, Jwks } from "../../src/jwk"; -import { AlbJwtVerifier } from "../../src/alb-verifier"; +import { + AlbJwtVerifier, + AlbJwtVerifierMultiProperties, +} from "../../src/alb-verifier"; import { ParameterValidationError, JwtInvalidClaimError, @@ -16,6 +19,7 @@ import { AlbJwtInvalidClientIdError, } from "../../src/error"; import { createPublicKey } from "crypto"; +import { KeyPair } from "../util/util"; describe("unit tests alb verifier", () => { let keypair: ReturnType; @@ -782,13 +786,17 @@ describe("unit tests alb verifier", () => { test("happy flow with 2 albs and 2 issuers", async () => { const exp = 4000000000; // nock and jest.useFakeTimers do not work well together. Used of a long expired date instead - const identityProviders = [ + const identityProviders: { + config: AlbJwtVerifierMultiProperties; + keypair: KeyPair; + }[] = [ { config: { albArn: "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/60dc6c495c0c9188", issuer: "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_qbc", + clientId: "client1", }, keypair: generateKeyPair({ From 9c6ef0ebfd80e08fe86c2ed91331de22fd225c80 Mon Sep 17 00:00:00 2001 From: Nicolas Viaud Date: Sat, 8 Feb 2025 16:16:13 +0000 Subject: [PATCH 14/18] Change alb arn validation rules: - check all the time that the alb arn is valid - check, only when jwksUri is not specified, that all alb arn associated to one issuer have the same region + Improve the error message when multiple regions --- src/alb-verifier.ts | 54 ++++++++++--------- tests/unit/alb-verifier.test.ts | 96 ++++++++++++--------------------- 2 files changed, 62 insertions(+), 88 deletions(-) diff --git a/src/alb-verifier.ts b/src/alb-verifier.ts index 9352b96..67a3731 100644 --- a/src/alb-verifier.ts +++ b/src/alb-verifier.ts @@ -11,6 +11,12 @@ import { AlbJwtHeader, AlbJwtPayload, JwtHeader } from "./jwt-model.js"; // todo import { JwtVerifierBase, JwtVerifierProperties } from "./jwt-verifier.js"; import { Properties } from "./typing-util.js"; +const regionRegex = /^[a-z]{2}-[a-z]+-\d{1}$/; + +type AlbArn = { + region: string; +}; + export interface AlbVerifyProperties { /** * The client ID that you expect to be present in the JWT's client claim (in the JWT header). @@ -154,12 +160,9 @@ export class AlbJwtVerifier< const transformPropertiesToIssuerConfig = ( props: AlbJwtVerifierProperties ) => { - const paramsValidationResult = validateAlbJwtParams(props.albArn); - const region = paramsValidationResult.region; + const albArns = validateAndParseAlbArns(props.albArn); return { - jwksUri: - props.jwksUri ?? - `https://public-keys.auth.elb.${region}.amazonaws.com`, + jwksUri: props.jwksUri ?? getDefaultJwksUri(albArns), ...props, audience: null, } as IssuerConfig; @@ -301,41 +304,40 @@ export function validateAlbJwtFields( } } -const regionRegex = /^[a-z]{2}-[a-z]+-\d{1}$/; - -export function validateAlbJwtParams(albArn: string | string[]): { - region: string; -} { +export function validateAndParseAlbArns(albArn: string | string[]): AlbArn[] { if (Array.isArray(albArn)) { - const regions = albArn.map(extractRegion); - const uniqueRegions = Array.from(new Set(regions)); - if (uniqueRegions.length > 1) { - throw new ParameterValidationError("Multiple regions found in ALB ARNs"); - } - return { - region: uniqueRegions[0], - }; + return albArn.map(parseAlbArn); } else { - const region = extractRegion(albArn); - return { - region, - }; + return [parseAlbArn(albArn)]; } } -export function extractRegion(arn: string): string { - const arnParts = arn.split(":"); +function parseAlbArn(albArn: string): AlbArn { + const arnParts = albArn.split(":"); if ( arnParts.length < 4 || arnParts[0] !== "arn" || arnParts[1] !== "aws" || arnParts[2] !== "elasticloadbalancing" ) { - throw new ParameterValidationError(`Invalid load balancer ARN: ${arn}`); + throw new ParameterValidationError(`Invalid load balancer ARN: ${albArn}`); } const region = arnParts[3]; if (!regionRegex.test(region)) { throw new ParameterValidationError(`Invalid AWS region in ARN: ${region}`); } - return region; + return { + region, + }; +} + +function getDefaultJwksUri(albArns: AlbArn[]): string { + const regions = albArns.map((arn) => arn.region); + const uniqueRegions = Array.from(new Set(regions)); + if (uniqueRegions.length > 1) { + throw new ParameterValidationError( + "Unable to generate default jwksUri because multiple regions in ALB ARNs parameters found" + ); + } + return `https://public-keys.auth.elb.${uniqueRegions[0]}.amazonaws.com`; } diff --git a/tests/unit/alb-verifier.test.ts b/tests/unit/alb-verifier.test.ts index 2e9681f..92ef34a 100644 --- a/tests/unit/alb-verifier.test.ts +++ b/tests/unit/alb-verifier.test.ts @@ -39,8 +39,7 @@ describe("unit tests alb verifier", () => { describe("verify", () => { test("happy flow", async () => { const kid = keypair.jwk.kid; - const userPoolId = "us-east-1_123456"; - const issuer = `https://cognito-idp.us-east-1.amazonaws.com/${userPoolId}`; + const issuer = `https://cognito-idp.us-east-1.amazonaws.com/us-east-1_123456`; const albArn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; const clientId = "my-client-id"; @@ -86,8 +85,7 @@ describe("unit tests alb verifier", () => { }); describe("validateAlbJwtParams", () => { test("invalid load balancer ARN - too short", async () => { - const userPoolId = "us-east-1_123456"; - const issuer = `https://cognito-idp.us-east-1.amazonaws.com/${userPoolId}`; + const issuer = `https://cognito-idp.us-east-1.amazonaws.com/us-east-1_123456`; const albArn = "arn:aws:elasticloadbalancing"; expect(() => @@ -103,8 +101,7 @@ describe("unit tests alb verifier", () => { }); test("invalid load balancer ARN - invalid region 1", async () => { - const userPoolId = "us-east-1_123456"; - const issuer = `https://cognito-idp.us-east-1.amazonaws.com/${userPoolId}`; + const issuer = `https://cognito-idp.us-east-1.amazonaws.com/us-east-1_123456`; const albArn = "arn:aws:elasticloadbalancing:.:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; @@ -117,8 +114,7 @@ describe("unit tests alb verifier", () => { }); test("invalid load balancer ARN - invalid region 2", async () => { - const userPoolId = "us-east-1_123456"; - const issuer = `https://cognito-idp.us-east-1.amazonaws.com/${userPoolId}`; + const issuer = `https://cognito-idp.us-east-1.amazonaws.com/us-east-1_123456`; const albArn = "arn:aws:elasticloadbalancing:/:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; @@ -131,8 +127,7 @@ describe("unit tests alb verifier", () => { }); test("invalid load balancer ARN - invalid region 3", async () => { - const userPoolId = "us-east-1_123456"; - const issuer = `https://cognito-idp.us-east-1.amazonaws.com/${userPoolId}`; + const issuer = `https://cognito-idp.us-east-1.amazonaws.com/us-east-1_123456`; const albArn = "arn:aws:elasticloadbalancing:?:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; @@ -145,8 +140,7 @@ describe("unit tests alb verifier", () => { }); test("invalid load balancer ARN - invalid region 4", async () => { - const userPoolId = "us-east-1_123456"; - const issuer = `https://cognito-idp.us-east-1.amazonaws.com/${userPoolId}`; + const issuer = `https://cognito-idp.us-east-1.amazonaws.com/us-east-1_123456`; const albArn = "arn:aws:elasticloadbalancing:=:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; @@ -161,8 +155,7 @@ describe("unit tests alb verifier", () => { describe("includeRawJwtInErrors", () => { test("verify - flag set at statement level", () => { const kid = keypair.jwk.kid; - const userPoolId = "us-east-1_123456"; - const issuer = `https://cognito-idp.us-east-1.amazonaws.com/${userPoolId}`; + const issuer = `https://cognito-idp.us-east-1.amazonaws.com/us-east-1_123456`; const albArn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; const clientId = "my-client-id"; @@ -204,8 +197,7 @@ describe("unit tests alb verifier", () => { }); test("verify - flag set at verifier level", () => { const kid = keypair.jwk.kid; - const userPoolId = "us-east-1_123456"; - const issuer = `https://cognito-idp.us-east-1.amazonaws.com/${userPoolId}`; + const issuer = `https://cognito-idp.us-east-1.amazonaws.com/us-east-1_123456`; const albArn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; const clientId = "my-client-id"; @@ -246,8 +238,7 @@ describe("unit tests alb verifier", () => { }); test("verify - flag NOT set", () => { const kid = keypair.jwk.kid; - const userPoolId = "us-east-1_123456"; - const issuer = `https://cognito-idp.us-east-1.amazonaws.com/${userPoolId}`; + const issuer = `https://cognito-idp.us-east-1.amazonaws.com/us-east-1_123456`; const albArn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; const clientId = "my-client-id"; @@ -284,8 +275,7 @@ describe("unit tests alb verifier", () => { }); test("verifySync - flag set at verifier level", () => { const kid = keypair.jwk.kid; - const userPoolId = "us-east-1_123456"; - const issuer = `https://cognito-idp.us-east-1.amazonaws.com/${userPoolId}`; + const issuer = `https://cognito-idp.us-east-1.amazonaws.com/us-east-1_123456`; const albArn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; const clientId = "my-client-id"; @@ -328,8 +318,7 @@ describe("unit tests alb verifier", () => { }); test("verifySync - flag NOT set", () => { const kid = keypair.jwk.kid; - const userPoolId = "us-east-1_123456"; - const issuer = `https://cognito-idp.us-east-1.amazonaws.com/${userPoolId}`; + const issuer = `https://cognito-idp.us-east-1.amazonaws.com/us-east-1_123456`; const albArn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; const clientId = "my-client-id"; @@ -370,8 +359,7 @@ describe("unit tests alb verifier", () => { describe("verifySync", () => { test("happy flow", () => { const kid = keypair.jwk.kid; - const userPoolId = "us-east-1_123456"; - const issuer = `https://cognito-idp.us-east-1.amazonaws.com/${userPoolId}`; + const issuer = `https://cognito-idp.us-east-1.amazonaws.com/us-east-1_123456`; const albArn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; const clientId = "my-client-id"; @@ -408,11 +396,10 @@ describe("unit tests alb verifier", () => { test("clientId null", async () => { const region = "us-east-1"; - const userPoolId = "us-east-1_123456"; const albArn = "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 issuer = `https://cognito-idp.${region}.amazonaws.com/us-east-1_123456`; 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 @@ -451,8 +438,7 @@ describe("unit tests alb verifier", () => { test("clientId undefined", () => { const kid = keypair.jwk.kid; - const userPoolId = "us-east-1_123456"; - const issuer = `https://cognito-idp.us-east-1.amazonaws.com/${userPoolId}`; + const issuer = `https://cognito-idp.us-east-1.amazonaws.com/us-east-1_123456`; const albArn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; const clientId = "my-client-id"; @@ -492,14 +478,12 @@ describe("unit tests alb verifier", () => { }); test("invalid issuer", () => { - const region = "us-east-1"; - const userPoolId = "us-east-1_123456"; const albArn = "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 issuer = `https://cognito-idp.us-east-1.amazonaws.com/us-east-1_123456`; + const jwksUri = `https://public-keys.auth.elb.us-east-1.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 @@ -536,15 +520,13 @@ describe("unit tests alb verifier", () => { }); test("invalid signer", () => { - const region = "us-east-1"; - const userPoolId = "us-east-1_123456"; const albArn = "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 issuer = `https://cognito-idp.us-east-1.amazonaws.com/us-east-1_123456`; + const jwksUri = `https://public-keys.auth.elb.us-east-1.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 @@ -581,14 +563,12 @@ describe("unit tests alb verifier", () => { }); test("invalid clientId", () => { - const region = "us-east-1"; - const userPoolId = "us-east-1_123456"; const albArn = "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 issuer = `https://cognito-idp.us-east-1.amazonaws.com/us-east-1_123456`; + const jwksUri = `https://public-keys.auth.elb.us-east-1.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 @@ -626,13 +606,11 @@ describe("unit tests alb verifier", () => { }); describe("jwksUri", () => { test("default jwksUri in us-east-1", async () => { - const region = "us-east-1"; - const userPoolId = "us-east-1_123456"; const albArn = "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 issuer = `https://cognito-idp.us-east-1.amazonaws.com/us-east-1_123456`; + const jwksUri = `https://public-keys.auth.elb.us-east-1.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 @@ -677,13 +655,11 @@ describe("unit tests alb verifier", () => { }); test("default jwksUri in eu-west-2", async () => { - const region = "eu-west-2"; - const userPoolId = "eu-west-2_123456"; const albArn = "arn:aws:elasticloadbalancing:eu-west-2: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 issuer = `https://cognito-idp.eu-west-2.amazonaws.com/eu-west-2_123456`; + const jwksUri = `https://public-keys.auth.elb.eu-west-2.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 @@ -728,12 +704,10 @@ describe("unit tests alb verifier", () => { }); test("custom jwksUri", async () => { - const region = "us-east-1"; - const userPoolId = "us-east-1_123456"; const albArn = "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 issuer = `https://cognito-idp.us-east-1.amazonaws.com/us-east-1_123456`; const jwksUri = `https://s3-us-gov-west-1.amazonaws.com/aws-elb-public-keys-prod-us-gov-west-1`; const jwk = keypair.jwk; const kid = jwk.kid; @@ -862,15 +836,13 @@ describe("unit tests alb verifier", () => { kid: "22222222-2222-2222-2222-222222222222", }); - const region = "us-east-1"; - const userPoolId = "us-east-1_123456"; const albArn1 = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/AAAAAAAAAAAAAAAA"; const albArn2 = "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 issuer = `https://cognito-idp.us-east-1.amazonaws.com/us-east-1_123456`; + const jwksUri = `https://public-keys.auth.elb.us-east-1.amazonaws.com`; const exp = 4000000000; // nock and jest.useFakeTimers do not work well together. Used of a long expired date instead const signedJwt1 = signJwt( @@ -991,8 +963,7 @@ describe("unit tests alb verifier", () => { ); } const customJwksCache = new CustomJwksCache(); - const userPoolId = "us-east-1_123456"; - const issuer = `https://cognito-idp.us-east-1.amazonaws.com/${userPoolId}`; + const issuer = `https://cognito-idp.us-east-1.amazonaws.com/us-east-1_123456`; const albArn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; @@ -1013,10 +984,9 @@ describe("unit tests alb verifier", () => { }); }); - describe("validateAlbJwtParams", () => { - test("invalid load balancer ARN with multiple regions", async () => { - const userPoolId = "us-east-1_123456"; - const issuer = `https://cognito-idp.us-east-1.amazonaws.com/${userPoolId}`; + describe("jwksUri", () => { + test("default jwksUri with alb arn in multiple regions error", async () => { + const issuer = `https://cognito-idp.us-east-1.amazonaws.com/us-east-1_123456`; const albArn = [ "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer-1/50dc6c495c0c9188", "arn:aws:elasticloadbalancing:eu-west-2:123456789012:loadbalancer/app/my-load-balancer-2/901e7c495c0c9188", @@ -1028,7 +998,9 @@ describe("unit tests alb verifier", () => { issuer, }) ).toThrow( - new ParameterValidationError("Multiple regions found in ALB ARNs") + new ParameterValidationError( + "Unable to generate default jwksUri because multiple regions in ALB ARNs parameters found" + ) ); }); }); From f1439aef9bd74fdf8491321897b788152a321b02 Mon Sep 17 00:00:00 2001 From: Nicolas Viaud Date: Tue, 11 Feb 2025 20:28:00 +0000 Subject: [PATCH 15/18] add code review comments - first part --- package.json | 6 ++ src/alb-cache.ts | 33 ++------ src/alb-verifier.ts | 25 ++---- src/error.ts | 2 + src/jwt-model.ts | 1 - tests/cognito/test/cognito.test.ts | 10 +-- tests/unit/alb-cache.test.ts | 3 +- tests/unit/alb-verifier.test.ts | 132 ++++++----------------------- 8 files changed, 55 insertions(+), 157 deletions(-) diff --git a/package.json b/package.json index e92bc3f..cc31979 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "files": [ "assert.d.ts", "alb-verifier.d.ts", + "cache.d.ts", "cognito-verifier.d.ts", "dist", "error.d.ts", @@ -44,6 +45,11 @@ "require": "./dist/cjs/alb-verifier.js", "types": "./alb-verifier.d.ts" }, + "./cache": { + "import": "./dist/esm/cache.js", + "require": "./dist/cjs/cache.js", + "types": "./cache.d.ts" + }, "./cognito-verifier": { "import": "./dist/esm/cognito-verifier.js", "require": "./dist/cjs/cognito-verifier.js", diff --git a/src/alb-cache.ts b/src/alb-cache.ts index 0571fc9..969cec8 100644 --- a/src/alb-cache.ts +++ b/src/alb-cache.ts @@ -1,19 +1,19 @@ import { createPublicKey } from "crypto"; import { + AlbJwksNotExposedError, JwkInvalidKtyError, JwksNotAvailableInCacheError, JwksValidationError, JwkValidationError, - JwtBaseError, JwtWithoutValidKidError, } from "./error.js"; import { JwkWithKid, Jwks, JwksCache } from "./jwk.js"; -import { JwtHeader, JwtPayload } from "./jwt-model.js"; import { Fetcher, SimpleFetcher } from "./https.js"; import { SimpleLruCache } from "./cache.js"; import { assertStringEquals } from "./assert.js"; +import { JwtHeader, JwtPayload } from "./jwt-model.js"; -const uuidRegex = +const UUID_REGEXP = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; interface DecomposedJwt { @@ -23,15 +23,6 @@ interface DecomposedJwt { 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 requirement is mandatory otherwise the application is exposed to several security risks like DoS attack by injecting a forged kid. - * - */ export class AlbJwksCache implements JwksCache { fetcher: Fetcher; @@ -57,7 +48,7 @@ export class AlbJwksCache implements JwksCache { } private isValidAlbKid(kid: string): boolean { - return uuidRegex.test(kid); + return UUID_REGEXP.test(kid); } public async getJwk( @@ -106,19 +97,13 @@ export class AlbJwksCache implements JwksCache { assertStringEquals("JWK kty", jwk.kty, "EC", JwkInvalidKtyError); return { + ...jwk, kid: kid, use: "sig", alg: "ES256", - ...jwk, } as JwkWithKid; } - /** - * - * @param Ex: https://public-keys.auth.elb.eu-west-1.amazonaws.com - * @param decomposedJwt - * @returns - */ public getCachedJwk( jwksUri: string, decomposedJwt: DecomposedJwt @@ -139,10 +124,8 @@ export class AlbJwksCache implements JwksCache { 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); + const jwksUriWithKid = this.expandWithKid(jwksUri, jwk.kid); + this.jwkCache.set(jwksUriWithKid, jwk as JwkWithKid); } else { throw new JwkValidationError("JWK does not have a kid"); } @@ -152,6 +135,6 @@ export class AlbJwksCache implements JwksCache { } async getJwks(): Promise { - throw new Error("Method not implemented."); + throw new AlbJwksNotExposedError("AWS ALB does not expose JWKS"); } } diff --git a/src/alb-verifier.ts b/src/alb-verifier.ts index 67a3731..21638a0 100644 --- a/src/alb-verifier.ts +++ b/src/alb-verifier.ts @@ -7,11 +7,12 @@ import { ParameterValidationError, } from "./error.js"; import { Jwk, JwksCache } from "./jwk.js"; -import { AlbJwtHeader, AlbJwtPayload, JwtHeader } from "./jwt-model.js"; // todo consider creating a specific type for AWS ALB JWT Payload +import { AlbJwtHeader, AlbJwtPayload, JwtHeader } from "./jwt-model.js"; import { JwtVerifierBase, JwtVerifierProperties } from "./jwt-verifier.js"; import { Properties } from "./typing-util.js"; -const regionRegex = /^[a-z]{2}-[a-z]+-\d{1}$/; +const ALB_ARN_REGEX = + /^arn:(?:aws|aws-cn):elasticloadbalancing:([a-z]{2}-(?:gov-)?[a-z]+-\d{1}):.+$/; type AlbArn = { region: string; @@ -65,7 +66,7 @@ export type AlbJwtVerifierProperties = { * The issuer of the JWTs you want to verify. * Set this to the expected value of the `iss` claim in the JWT. */ - issuer: string; + issuer: string | null; /** * The ARN of the Application Load Balancer (ALB) that signs the JWT. * Set this to the expected value of the `signer` claim in the JWT (JWT header). @@ -90,7 +91,7 @@ export type AlbJwtVerifierMultiProperties = { * The issuer of the JWTs you want to verify. * Set this to the expected value of the `iss` claim in the JWT. */ - issuer: string; + issuer: string | null; /** * The ARN of the Application Load Balancer (ALB) that signs the JWT. * Set this to the expected value of the `signer` claim in the JWT (JWT header). @@ -201,7 +202,6 @@ export class AlbJwtVerifier< additionalProperties?: { jwksCache: JwksCache } ): AlbJwtVerifierMultiUserPool; - // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types static create( verifyProperties: | AlbJwtVerifierProperties @@ -313,21 +313,12 @@ export function validateAndParseAlbArns(albArn: string | string[]): AlbArn[] { } function parseAlbArn(albArn: string): AlbArn { - const arnParts = albArn.split(":"); - if ( - arnParts.length < 4 || - arnParts[0] !== "arn" || - arnParts[1] !== "aws" || - arnParts[2] !== "elasticloadbalancing" - ) { + const match = ALB_ARN_REGEX.exec(albArn); + if (!match) { throw new ParameterValidationError(`Invalid load balancer ARN: ${albArn}`); } - const region = arnParts[3]; - if (!regionRegex.test(region)) { - throw new ParameterValidationError(`Invalid AWS region in ARN: ${region}`); - } return { - region, + region: match[1], }; } diff --git a/src/error.ts b/src/error.ts index a106782..50d9119 100644 --- a/src/error.ts +++ b/src/error.ts @@ -114,6 +114,8 @@ export class AlbJwtInvalidSignerError extends JwtInvalidClaimError {} export class AlbJwtInvalidClientIdError extends JwtInvalidClaimError {} +export class AlbJwksNotExposedError extends JwtBaseError {} + /** * JWK errors */ diff --git a/src/jwt-model.ts b/src/jwt-model.ts index 2808617..3cd80d7 100644 --- a/src/jwt-model.ts +++ b/src/jwt-model.ts @@ -99,7 +99,6 @@ export interface AlbJwtHeader extends JwtHeader { exp: number; } -//TODO more specifics fields when ALB auth is cognito or oidc export type AlbJwtPayload = { exp: number; iss: string; diff --git a/tests/cognito/test/cognito.test.ts b/tests/cognito/test/cognito.test.ts index 9a4bed0..09bc945 100644 --- a/tests/cognito/test/cognito.test.ts +++ b/tests/cognito/test/cognito.test.ts @@ -3,7 +3,6 @@ import * as outputs from "../outputs.json"; import { AlbJwtVerifier, CognitoJwtVerifier } from "aws-jwt-verify"; -import { assertStringEquals } from "aws-jwt-verify/assert"; import { CognitoIdentityProviderClient, InitiateAuthCommand, @@ -38,10 +37,7 @@ const cognitoVerifier = CognitoJwtVerifier.create({ const albJwtVerifier = AlbJwtVerifier.create({ albArn, issuer: CognitoJwtVerifier.parseUserPoolId(userPoolId).issuer, - customJwtCheck: ({ header }) => { - assertStringEquals("ALB arn", header.signer, albArn); - assertStringEquals("ALB client", header.client, clientIdAlb); - }, + clientId: clientIdAlb, }); const CLIENT = new CognitoIdentityProviderClient({ region }); const SIGN_IN_AS_USER = new InitiateAuthCommand({ @@ -168,8 +164,6 @@ test("Verify Cognito Access token from ALB", async () => { test("Verify Data token from ALB", async () => { return expect( - albJwtVerifier.verify(albSigninJWTs.albToken, { - clientId: clientIdAlb, - }) + albJwtVerifier.verify(albSigninJWTs.albToken) ).resolves.toMatchObject({ email: username }); }); diff --git a/tests/unit/alb-cache.test.ts b/tests/unit/alb-cache.test.ts index c76d529..3febcd4 100644 --- a/tests/unit/alb-cache.test.ts +++ b/tests/unit/alb-cache.test.ts @@ -1,5 +1,6 @@ import { AlbJwksCache } from "../../src/alb-cache"; import { + AlbJwksNotExposedError, JwksNotAvailableInCacheError, JwksValidationError, JwkValidationError, @@ -113,7 +114,7 @@ describe("unit tests AlbJwksCache", () => { const jwksCache = new AlbJwksCache(); expect.assertions(1); return expect(jwksCache.getJwks()).rejects.toThrow( - "Method not implemented." + new AlbJwksNotExposedError("AWS ALB does not expose JWKS") ); }); diff --git a/tests/unit/alb-verifier.test.ts b/tests/unit/alb-verifier.test.ts index 92ef34a..012f1d6 100644 --- a/tests/unit/alb-verifier.test.ts +++ b/tests/unit/alb-verifier.test.ts @@ -6,7 +6,6 @@ import { mockHttpsUri, } from "./test-util"; import { decomposeUnverifiedJwt } from "../../src/jwt"; -import { JwksCache, Jwks } from "../../src/jwk"; import { AlbJwtVerifier, AlbJwtVerifierMultiProperties, @@ -43,7 +42,7 @@ describe("unit tests alb verifier", () => { const albArn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; const clientId = "my-client-id"; - const exp = 4000000000; // nock and jest.useFakeTimers do not work well together. Used of a long expired date instead + const exp = 4000000000; // Use of a long expiry date const signedJwt = signJwt( { @@ -100,7 +99,7 @@ describe("unit tests alb verifier", () => { ); }); - test("invalid load balancer ARN - invalid region 1", async () => { + test("invalid load balancer ARN - invalid region", async () => { const issuer = `https://cognito-idp.us-east-1.amazonaws.com/us-east-1_123456`; const albArn = "arn:aws:elasticloadbalancing:.:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; @@ -110,46 +109,11 @@ describe("unit tests alb verifier", () => { albArn, issuer, }) - ).toThrow(new ParameterValidationError(`Invalid AWS region in ARN: .`)); - }); - - test("invalid load balancer ARN - invalid region 2", async () => { - const issuer = `https://cognito-idp.us-east-1.amazonaws.com/us-east-1_123456`; - const albArn = - "arn:aws:elasticloadbalancing:/:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; - - expect(() => - AlbJwtVerifier.create({ - albArn, - issuer, - }) - ).toThrow(new ParameterValidationError(`Invalid AWS region in ARN: /`)); - }); - - test("invalid load balancer ARN - invalid region 3", async () => { - const issuer = `https://cognito-idp.us-east-1.amazonaws.com/us-east-1_123456`; - const albArn = - "arn:aws:elasticloadbalancing:?:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; - - expect(() => - AlbJwtVerifier.create({ - albArn, - issuer, - }) - ).toThrow(new ParameterValidationError(`Invalid AWS region in ARN: ?`)); - }); - - test("invalid load balancer ARN - invalid region 4", async () => { - const issuer = `https://cognito-idp.us-east-1.amazonaws.com/us-east-1_123456`; - const albArn = - "arn:aws:elasticloadbalancing:=:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; - - expect(() => - AlbJwtVerifier.create({ - albArn, - issuer, - }) - ).toThrow(new ParameterValidationError(`Invalid AWS region in ARN: =`)); + ).toThrow( + new ParameterValidationError( + `Invalid load balancer ARN: arn:aws:elasticloadbalancing:.:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188` + ) + ); }); }); describe("includeRawJwtInErrors", () => { @@ -159,7 +123,7 @@ describe("unit tests alb verifier", () => { const albArn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; const clientId = "my-client-id"; - const exp = 4000000000; // nock and jest.useFakeTimers do not work well together. Used of a long expired date instead + const exp = 4000000000; // Use of a long expiry date const header = { typ: "JWT", @@ -201,7 +165,7 @@ describe("unit tests alb verifier", () => { const albArn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; const clientId = "my-client-id"; - const exp = 4000000000; // nock and jest.useFakeTimers do not work well together. Used of a long expired date instead + const exp = 4000000000; // Use of a long expiry date const header = { typ: "JWT", kid, @@ -242,7 +206,7 @@ describe("unit tests alb verifier", () => { const albArn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; const clientId = "my-client-id"; - const exp = 4000000000; // nock and jest.useFakeTimers do not work well together. Used of a long expired date instead + const exp = 4000000000; // Use of a long expiry date const header = { typ: "JWT", kid, @@ -279,7 +243,7 @@ describe("unit tests alb verifier", () => { const albArn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; const clientId = "my-client-id"; - const exp = 4000000000; // nock and jest.useFakeTimers do not work well together. Used of a long expired date instead + const exp = 4000000000; // Use of a long expiry date const header = { typ: "JWT", kid, @@ -322,7 +286,7 @@ describe("unit tests alb verifier", () => { const albArn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; const clientId = "my-client-id"; - const exp = 4000000000; // nock and jest.useFakeTimers do not work well together. Used of a long expired date instead + const exp = 4000000000; // Use of a long expiry date const header = { typ: "JWT", kid, @@ -363,7 +327,7 @@ describe("unit tests alb verifier", () => { const albArn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; const clientId = "my-client-id"; - const exp = 4000000000; // nock and jest.useFakeTimers do not work well together. Used of a long expired date instead + const exp = 4000000000; // Use of a long expiry date const signedJwt = signJwt( { @@ -395,14 +359,13 @@ describe("unit tests alb verifier", () => { }); test("clientId null", async () => { - const region = "us-east-1"; const albArn = "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/us-east-1_123456`; - const jwksUri = `https://public-keys.auth.elb.${region}.amazonaws.com`; + const issuer = `https://cognito-idp.us-east-1.amazonaws.com/us-east-1_123456`; + const jwksUri = `https://public-keys.auth.elb.us-east-1.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 exp = 4000000000; // Use of a long expiry date const signedJwt = signJwt( { @@ -442,7 +405,7 @@ describe("unit tests alb verifier", () => { const albArn = "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; const clientId = "my-client-id"; - const exp = 4000000000; // nock and jest.useFakeTimers do not work well together. Used of a long expired date instead + const exp = 4000000000; // Use of a long expiry date const signedJwt = signJwt( { @@ -485,7 +448,7 @@ describe("unit tests alb verifier", () => { const issuer = `https://cognito-idp.us-east-1.amazonaws.com/us-east-1_123456`; const jwksUri = `https://public-keys.auth.elb.us-east-1.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 exp = 4000000000; // Use of a long expiry date const signedJwt = signJwt( { @@ -528,7 +491,7 @@ describe("unit tests alb verifier", () => { const issuer = `https://cognito-idp.us-east-1.amazonaws.com/us-east-1_123456`; const jwksUri = `https://public-keys.auth.elb.us-east-1.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 exp = 4000000000; // Use of a long expiry date const signedJwt = signJwt( { @@ -570,7 +533,7 @@ describe("unit tests alb verifier", () => { const issuer = `https://cognito-idp.us-east-1.amazonaws.com/us-east-1_123456`; const jwksUri = `https://public-keys.auth.elb.us-east-1.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 exp = 4000000000; // Use of a long expiry date const signedJwt = signJwt( { @@ -613,7 +576,7 @@ describe("unit tests alb verifier", () => { const jwksUri = `https://public-keys.auth.elb.us-east-1.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 exp = 4000000000; // Use of a long expiry date const pem = createPublicKey({ key: jwk, format: "jwk", @@ -662,7 +625,7 @@ describe("unit tests alb verifier", () => { const jwksUri = `https://public-keys.auth.elb.eu-west-2.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 exp = 4000000000; // Use of a long expiry date const pem = createPublicKey({ key: jwk, format: "jwk", @@ -711,7 +674,7 @@ describe("unit tests alb verifier", () => { const jwksUri = `https://s3-us-gov-west-1.amazonaws.com/aws-elb-public-keys-prod-us-gov-west-1`; 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 exp = 4000000000; // Use of a long expiry date const pem = createPublicKey({ key: jwk, format: "jwk", @@ -758,7 +721,7 @@ describe("unit tests alb verifier", () => { describe("AlbJwtVerifier with multiple alb", () => { describe("verifySync", () => { test("happy flow with 2 albs and 2 issuers", async () => { - const exp = 4000000000; // nock and jest.useFakeTimers do not work well together. Used of a long expired date instead + const exp = 4000000000; // Use of a long expiry date const identityProviders: { config: AlbJwtVerifierMultiProperties; @@ -774,7 +737,7 @@ describe("unit tests alb verifier", () => { clientId: "client1", }, keypair: generateKeyPair({ - kid: "00000000-0000-0000-0000-000000000000", + kid: "11111111-1111-1111-1111-111111111111", kty: "EC", alg: "ES256", }), @@ -788,7 +751,7 @@ describe("unit tests alb verifier", () => { clientId: "client2", }, keypair: generateKeyPair({ - kid: "11111111-0000-0000-0000-000000000000", + kid: "22222222-2222-2222-2222-222222222222", kty: "EC", alg: "ES256", }), @@ -843,7 +806,7 @@ describe("unit tests alb verifier", () => { const clientId = "my-client-id"; const issuer = `https://cognito-idp.us-east-1.amazonaws.com/us-east-1_123456`; const jwksUri = `https://public-keys.auth.elb.us-east-1.amazonaws.com`; - const exp = 4000000000; // nock and jest.useFakeTimers do not work well together. Used of a long expired date instead + const exp = 4000000000; // Use of a long expiry date const signedJwt1 = signJwt( { @@ -941,47 +904,6 @@ describe("unit tests alb verifier", () => { new ParameterValidationError("issuer must be provided") ); }); - test("custom JWKS cache", () => { - class CustomJwksCache implements JwksCache { - getJwks = jest - .fn() - .mockImplementation(async (_jwksUri?: string) => keypair.jwks); - addJwks = jest - .fn() - .mockImplementation((_jwksUri: string, _jwks: Jwks) => { - // This is intentional - }); - getCachedJwk = jest - .fn() - .mockImplementation( - (_jwksUri: string, _kid: string) => keypair.jwk - ); - getJwk = jest - .fn() - .mockImplementation( - async (_jwksUri: string, _kid: string) => keypair.jwk - ); - } - const customJwksCache = new CustomJwksCache(); - const issuer = `https://cognito-idp.us-east-1.amazonaws.com/us-east-1_123456`; - const albArn = - "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; - - const jwksUri = "https://public-keys.auth.elb.us-east-1.amazonaws.com"; - const verifier = AlbJwtVerifier.create( - { - albArn, - issuer, - tokenUse: "id", - }, - { jwksCache: customJwksCache } - ); - verifier.cacheJwks(keypair.jwks); - expect(customJwksCache.addJwks).toHaveBeenCalledWith( - jwksUri, - keypair.jwks - ); - }); }); describe("jwksUri", () => { From 8a5242bb09ee88e7d920961ad72ec6c37e0d2032 Mon Sep 17 00:00:00 2001 From: Nicolas Viaud Date: Thu, 13 Feb 2025 10:49:23 +0000 Subject: [PATCH 16/18] add code review comments - second part --- src/alb-verifier.ts | 2 +- tests/unit/alb-verifier.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/alb-verifier.ts b/src/alb-verifier.ts index 21638a0..de36d91 100644 --- a/src/alb-verifier.ts +++ b/src/alb-verifier.ts @@ -327,7 +327,7 @@ function getDefaultJwksUri(albArns: AlbArn[]): string { const uniqueRegions = Array.from(new Set(regions)); if (uniqueRegions.length > 1) { throw new ParameterValidationError( - "Unable to generate default jwksUri because multiple regions in ALB ARNs parameters found" + "Using ALBs from different regions is not supported for the same issuer" ); } return `https://public-keys.auth.elb.${uniqueRegions[0]}.amazonaws.com`; diff --git a/tests/unit/alb-verifier.test.ts b/tests/unit/alb-verifier.test.ts index 012f1d6..4dbbc25 100644 --- a/tests/unit/alb-verifier.test.ts +++ b/tests/unit/alb-verifier.test.ts @@ -921,7 +921,7 @@ describe("unit tests alb verifier", () => { }) ).toThrow( new ParameterValidationError( - "Unable to generate default jwksUri because multiple regions in ALB ARNs parameters found" + "Using ALBs from different regions is not supported for the same issuer" ) ); }); From d1b542ef24ce6438d557758cf0ba799576d1b4c9 Mon Sep 17 00:00:00 2001 From: Nicolas Viaud Date: Thu, 13 Feb 2025 21:03:04 +0000 Subject: [PATCH 17/18] fix crypto import for web in alb cache. add transformPemToJwk function for node and web --- src/alb-cache.ts | 17 +++-------------- src/node-web-compat-node.ts | 10 +++++++++- src/node-web-compat-web.ts | 7 ++++++- src/node-web-compat.ts | 3 ++- 4 files changed, 20 insertions(+), 17 deletions(-) diff --git a/src/alb-cache.ts b/src/alb-cache.ts index 969cec8..88ee632 100644 --- a/src/alb-cache.ts +++ b/src/alb-cache.ts @@ -1,7 +1,5 @@ -import { createPublicKey } from "crypto"; import { AlbJwksNotExposedError, - JwkInvalidKtyError, JwksNotAvailableInCacheError, JwksValidationError, JwkValidationError, @@ -10,8 +8,8 @@ import { import { JwkWithKid, Jwks, JwksCache } from "./jwk.js"; import { Fetcher, SimpleFetcher } from "./https.js"; import { SimpleLruCache } from "./cache.js"; -import { assertStringEquals } from "./assert.js"; import { JwtHeader, JwtPayload } from "./jwt-model.js"; +import { nodeWebCompat } from "#node-web-compat"; const UUID_REGEXP = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -86,21 +84,12 @@ export class AlbJwksCache implements JwksCache { } 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); - + const jwk = nodeWebCompat.transformPemToJwk(pem); return { ...jwk, - kid: kid, use: "sig", alg: "ES256", + kid: kid, } as JwkWithKid; } diff --git a/src/node-web-compat-node.ts b/src/node-web-compat-node.ts index 3d40398..ad768b1 100644 --- a/src/node-web-compat-node.ts +++ b/src/node-web-compat-node.ts @@ -4,7 +4,7 @@ // Node.js implementations for the node-web-compatibility layer import { createPublicKey, createVerify, KeyObject, verify } from "crypto"; -import { SignatureJwk } from "./jwk.js"; +import { Jwk, SignatureJwk } from "./jwk.js"; import { fetch } from "./https-node.js"; import { NodeWebCompat } from "./node-web-compat.js"; @@ -60,4 +60,12 @@ export const nodeWebCompat: NodeWebCompat = { }, setTimeoutUnref: (...args: Parameters) => setTimeout(...args).unref(), + transformPemToJwk: (pem: ArrayBuffer) => { + return createPublicKey({ + key: Buffer.from(pem), + format: "pem", + }).export({ + format: "jwk", + }) as Jwk; + }, }; diff --git a/src/node-web-compat-web.ts b/src/node-web-compat-web.ts index 1d7a2bf..530ecb9 100644 --- a/src/node-web-compat-web.ts +++ b/src/node-web-compat-web.ts @@ -91,7 +91,7 @@ export const nodeWebCompat: NodeWebCompat = { } : jwk.crv!; // Ed25519 or Ed448 return crypto.subtle - .importKey("jwk", jwk, algIdentifier, false, ["verify"]) + .importKey("jwk", jwk, algIdentifier, false, ["sign"]) .then((key) => ({ key, jwk })); }, verifySignatureSync: () => { @@ -118,6 +118,11 @@ export const nodeWebCompat: NodeWebCompat = { parseB64UrlString: (b64: string): string => new TextDecoder().decode(bufferFromBase64url(b64)), setTimeoutUnref: setTimeout.bind(undefined), + transformPemToJwk: () => { + throw new NotSupportedError( + "PEM to JWK transformation is not supported in the browser" + ); + }, }; const bufferFromBase64url = (function () { diff --git a/src/node-web-compat.ts b/src/node-web-compat.ts index 8af7a4b..70059d6 100644 --- a/src/node-web-compat.ts +++ b/src/node-web-compat.ts @@ -8,7 +8,7 @@ // At runtime, either the Node.js or Web implementation is actually loaded. This works because the // package.json specifies "#node-web-compat" as a subpath import, with conditions pointing to the right implementation (for Node.js or Web) -import { SignatureJwk } from "./jwk.js"; +import { Jwk, SignatureJwk } from "./jwk.js"; import { JwsVerificationFunctionAsync, JwsVerificationFunctionSync, @@ -43,4 +43,5 @@ export interface NodeWebCompat { socketIdle?: number; // socket idle timeout (Only supported by Node.js runtime) response: number; // total round trip timeout }; + transformPemToJwk: (pem: ArrayBuffer) => Jwk; // Only ES256 is supported } From 211ba750cd6fb3f9137d3c11ac2e686aa13d8a99 Mon Sep 17 00:00:00 2001 From: Nicolas Viaud Date: Fri, 14 Feb 2025 16:41:57 +0000 Subject: [PATCH 18/18] transformPemToJwk implementation for web + revert usage "sign" to "verify" --- src/alb-cache.ts | 4 +-- src/node-web-compat-node.ts | 2 +- src/node-web-compat-web.ts | 62 ++++++++++++++++++++++++++++++++++--- src/node-web-compat.ts | 5 ++- 4 files changed, 64 insertions(+), 9 deletions(-) diff --git a/src/alb-cache.ts b/src/alb-cache.ts index 88ee632..2f1dc2d 100644 --- a/src/alb-cache.ts +++ b/src/alb-cache.ts @@ -83,8 +83,8 @@ export class AlbJwksCache implements JwksCache { } } - private pemToJwk(kid: string, pem: ArrayBuffer): JwkWithKid { - const jwk = nodeWebCompat.transformPemToJwk(pem); + private async pemToJwk(kid: string, pem: ArrayBuffer): Promise { + const jwk = await nodeWebCompat.transformPemToJwk(pem); return { ...jwk, use: "sig", diff --git a/src/node-web-compat-node.ts b/src/node-web-compat-node.ts index ad768b1..a2e8484 100644 --- a/src/node-web-compat-node.ts +++ b/src/node-web-compat-node.ts @@ -60,7 +60,7 @@ export const nodeWebCompat: NodeWebCompat = { }, setTimeoutUnref: (...args: Parameters) => setTimeout(...args).unref(), - transformPemToJwk: (pem: ArrayBuffer) => { + transformPemToJwk: async (pem: ArrayBuffer): Promise => { return createPublicKey({ key: Buffer.from(pem), format: "pem", diff --git a/src/node-web-compat-web.ts b/src/node-web-compat-web.ts index 530ecb9..fbad12c 100644 --- a/src/node-web-compat-web.ts +++ b/src/node-web-compat-web.ts @@ -3,7 +3,7 @@ // // Web implementations for the node-web-compatibility layer -import { SignatureJwk } from "jwk.js"; +import { Jwk, SignatureJwk } from "jwk.js"; import { FetchError, NotSupportedError, @@ -91,7 +91,7 @@ export const nodeWebCompat: NodeWebCompat = { } : jwk.crv!; // Ed25519 or Ed448 return crypto.subtle - .importKey("jwk", jwk, algIdentifier, false, ["sign"]) + .importKey("jwk", jwk, algIdentifier, false, ["verify"]) .then((key) => ({ key, jwk })); }, verifySignatureSync: () => { @@ -118,13 +118,65 @@ export const nodeWebCompat: NodeWebCompat = { parseB64UrlString: (b64: string): string => new TextDecoder().decode(bufferFromBase64url(b64)), setTimeoutUnref: setTimeout.bind(undefined), - transformPemToJwk: () => { - throw new NotSupportedError( - "PEM to JWK transformation is not supported in the browser" + transformPemToJwk: async (pem, jwtHeaderAlg): Promise => { + // Remove the PEM header and footer + const pemContents = pem.slice(27, pem.byteLength - 25); + // convert the ArrayBuffer to a string + const pemContentsString = new TextDecoder().decode(pemContents); + // base64 decode the string to get the binary data + const binaryDerString = atob(pemContentsString); + // convert from a binary string to an ArrayBuffer + const binaryDer = str2ab(binaryDerString); + + let alg: RsaHashedImportParams | EcKeyImportParams; + switch (jwtHeaderAlg) { + case "RS256": + case "RS384": + case "RS512": + alg = { + name: "RSASSA-PKCS1-v1_5", + hash: `SHA-${jwtHeaderAlg.slice(2)}`, + }; + break; + case "ES256": + case "ES384": + case "ES512": + alg = { + name: "ECDSA", + namedCurve: + NamedCurvesWebCrypto[ + jwtHeaderAlg as keyof typeof NamedCurvesWebCrypto + ], + }; + break; + default: + throw new JwtInvalidSignatureAlgorithmError( + "Unsupported signature algorithm", + jwtHeaderAlg + ); + } + const cryptoKey = await crypto.subtle.importKey( + "spki", + binaryDer, + alg, + true, + ["verify"] ); + + return crypto.subtle.exportKey("jwk", cryptoKey) as Promise; }, }; +const str2ab = (str: string): ArrayBuffer => { + const buf = new ArrayBuffer(str.length); + const bufView = new Uint8Array(buf); + for (let i = 0, strLen = str.length; i < strLen; i++) { + // eslint-disable-next-line security/detect-object-injection + bufView[i] = str.charCodeAt(i); + } + return buf; +}; + const bufferFromBase64url = (function () { const map = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_" .split("") diff --git a/src/node-web-compat.ts b/src/node-web-compat.ts index 70059d6..467187a 100644 --- a/src/node-web-compat.ts +++ b/src/node-web-compat.ts @@ -43,5 +43,8 @@ export interface NodeWebCompat { socketIdle?: number; // socket idle timeout (Only supported by Node.js runtime) response: number; // total round trip timeout }; - transformPemToJwk: (pem: ArrayBuffer) => Jwk; // Only ES256 is supported + transformPemToJwk: ( + pem: ArrayBuffer, + jwtHeaderAlg?: SupportedSignatureAlgorithm + ) => Promise; }