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..cc31979 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ "module": "dist/esm/index.js", "files": [ "assert.d.ts", + "alb-verifier.d.ts", + "cache.d.ts", "cognito-verifier.d.ts", "dist", "error.d.ts", @@ -38,6 +40,16 @@ "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" + }, + "./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 new file mode 100644 index 0000000..2f1dc2d --- /dev/null +++ b/src/alb-cache.ts @@ -0,0 +1,129 @@ +import { + AlbJwksNotExposedError, + JwksNotAvailableInCacheError, + JwksValidationError, + JwkValidationError, + JwtWithoutValidKidError, +} from "./error.js"; +import { JwkWithKid, Jwks, JwksCache } from "./jwk.js"; +import { Fetcher, SimpleFetcher } from "./https.js"; +import { SimpleLruCache } from "./cache.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; + +interface DecomposedJwt { + header: JwtHeader; + payload: JwtPayload; +} + +type JwksUri = string; + +export class AlbJwksCache implements JwksCache { + fetcher: Fetcher; + + private jwkCache: SimpleLruCache = new SimpleLruCache(2); + private fetchingJwks: Map> = new Map(); + + constructor(props?: { fetcher?: Fetcher }) { + this.fetcher = props?.fetcher ?? new SimpleFetcher(); + } + + 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 UUID_REGEXP.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 { + const newFetchPromise = this.fetcher + .fetch(jwksUriWithKid) + .then((pem) => this.pemToJwk(kid, pem)) + .then((jwk) => { + this.jwkCache.set(jwksUriWithKid, jwk); + return jwk; + }) + .finally(() => { + this.fetchingJwks.delete(jwksUriWithKid); + }); + + this.fetchingJwks.set(jwksUriWithKid, newFetchPromise); + + return newFetchPromise; + } + } + } + + private async pemToJwk(kid: string, pem: ArrayBuffer): Promise { + const jwk = await nodeWebCompat.transformPemToJwk(pem); + return { + ...jwk, + use: "sig", + alg: "ES256", + kid: kid, + } as JwkWithKid; + } + + 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 jwksUriWithKid = this.expandWithKid(jwksUri, jwk.kid); + this.jwkCache.set(jwksUriWithKid, jwk as JwkWithKid); + } else { + throw new JwkValidationError("JWK does not have a kid"); + } + } else { + throw new JwksValidationError("Only one JWK is expected in the JWKS"); + } + } + + async getJwks(): Promise { + throw new AlbJwksNotExposedError("AWS ALB does not expose JWKS"); + } +} diff --git a/src/alb-verifier.ts b/src/alb-verifier.ts new file mode 100644 index 0000000..de36d91 --- /dev/null +++ b/src/alb-verifier.ts @@ -0,0 +1,334 @@ +import { AlbJwksCache } from "./alb-cache.js"; +import { assertStringArrayContainsString } from "./assert.js"; +import { + AlbJwtInvalidClientIdError, + AlbJwtInvalidSignerError, + JwtInvalidClaimError, + ParameterValidationError, +} from "./error.js"; +import { Jwk, JwksCache } from "./jwk.js"; +import { AlbJwtHeader, AlbJwtPayload, JwtHeader } from "./jwt-model.js"; +import { JwtVerifierBase, JwtVerifierProperties } from "./jwt-verifier.js"; +import { Properties } from "./typing-util.js"; + +const ALB_ARN_REGEX = + /^arn:(?:aws|aws-cn):elasticloadbalancing:([a-z]{2}-(?:gov-)?[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). + * 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: AlbJwtHeader; + payload: AlbJwtPayload; + 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 | 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). + * If you provide a string array, that means at least one of those ALB ARNs + * must be present in the JWT's signer claim. + */ + albArn: string | string[]; +} & 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 | 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). + * If you provide a string array, that means at least one of those ALB ARNs + * must be present in the JWT's signer claim. + */ + albArn: string | string[]; +} & AlbVerifyProperties; + +/** + * ALB JWT Verifier for a single issuer + */ +export type AlbJwtVerifierSingleUserPool = + AlbJwtVerifier< + Properties, + T & + JwtVerifierProperties & { + albArn: string | string[]; + audience: null; + }, + false + >; + +/** + * ALB JWT Verifier for multiple issuer + */ +export type AlbJwtVerifierMultiUserPool< + T extends AlbJwtVerifierMultiProperties, +> = AlbJwtVerifier< + Properties, + T & + JwtVerifierProperties & { + albArn: string | string[]; + 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[]; + }, + MultiIssuer extends boolean, +> extends JwtVerifierBase { + private constructor( + props: AlbJwtVerifierProperties | AlbJwtVerifierMultiProperties[], + jwksCache: JwksCache + ) { + const transformPropertiesToIssuerConfig = ( + props: AlbJwtVerifierProperties + ) => { + const albArns = validateAndParseAlbArns(props.albArn); + return { + jwksUri: props.jwksUri ?? getDefaultJwksUri(albArns), + ...props, + audience: null, + } as IssuerConfig; + }; + + const issuerConfig = Array.isArray(props) + ? props.map(transformPropertiesToIssuerConfig) + : transformPropertiesToIssuerConfig(props); + + super(issuerConfig, jwksCache); + } + + /** + * 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; + + static create( + verifyProperties: + | AlbJwtVerifierProperties + | AlbJwtVerifierMultiProperties[], + additionalProperties?: { jwksCache: JwksCache } + ) { + return new this( + verifyProperties, + additionalProperties?.jwksCache ?? new AlbJwksCache() + ); + } + + /** + * 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 + ): AlbJwtPayload { + const { decomposedJwt, jwksUri, verifyProperties } = + this.getVerifyParameters(jwt, properties); + try { + this.verifyDecomposedJwtSync(decomposedJwt, jwksUri, verifyProperties); + validateAlbJwtFields(decomposedJwt.header, verifyProperties); + } catch (err) { + if ( + verifyProperties.includeRawJwtInErrors && + err instanceof JwtInvalidClaimError + ) { + throw err.withRawJwt(decomposedJwt); + } + throw err; + } + return decomposedJwt.payload as AlbJwtPayload; + } + + /** + * 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); + try { + await this.verifyDecomposedJwt(decomposedJwt, jwksUri, verifyProperties); + validateAlbJwtFields(decomposedJwt.header, verifyProperties); + } catch (err) { + if ( + verifyProperties.includeRawJwtInErrors && + err instanceof JwtInvalidClaimError + ) { + throw err.withRawJwt(decomposedJwt); + } + throw err; + } + return decomposedJwt.payload as AlbJwtPayload; + } +} + +export function validateAlbJwtFields( + header: JwtHeader, + options: { + clientId?: string | string[] | null; + albArn?: string | string[]; + } +): void { + // Check ALB ARN (signer) + 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) { + throw new ParameterValidationError( + "clientId must be provided or set to null explicitly" + ); + } + assertStringArrayContainsString( + "Client ID", + header.client, + options.clientId, + AlbJwtInvalidClientIdError + ); + } +} + +export function validateAndParseAlbArns(albArn: string | string[]): AlbArn[] { + if (Array.isArray(albArn)) { + return albArn.map(parseAlbArn); + } else { + return [parseAlbArn(albArn)]; + } +} + +function parseAlbArn(albArn: string): AlbArn { + const match = ALB_ARN_REGEX.exec(albArn); + if (!match) { + throw new ParameterValidationError(`Invalid load balancer ARN: ${albArn}`); + } + return { + region: match[1], + }; +} + +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( + "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/src/cache.ts b/src/cache.ts new file mode 100644 index 0000000..232c65f --- /dev/null +++ b/src/cache.ts @@ -0,0 +1,55 @@ +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/src/error.ts b/src/error.ts index c28399b..50d9119 100644 --- a/src/error.ts +++ b/src/error.ts @@ -106,6 +106,16 @@ export class CognitoJwtInvalidTokenUseError extends JwtInvalidClaimError {} export class CognitoJwtInvalidClientIdError extends JwtInvalidClaimError {} +/** + * Amazon ALB specific erros + */ + +export class AlbJwtInvalidSignerError extends JwtInvalidClaimError {} + +export class AlbJwtInvalidClientIdError extends JwtInvalidClaimError {} + +export class AlbJwksNotExposedError extends JwtBaseError {} + /** * JWK errors */ 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/src/jwt-model.ts b/src/jwt-model.ts index 2337c11..3cd80d7 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; +} + +export type AlbJwtPayload = { + exp: number; + iss: string; +} & JwtPayloadStandardFields & + JsonObject; + +export interface AlbJwt { + header: AlbJwtHeader; + payload: AlbJwtPayload; +} diff --git a/src/node-web-compat-node.ts b/src/node-web-compat-node.ts index 3d40398..a2e8484 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: async (pem: ArrayBuffer): Promise => { + 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..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, @@ -118,6 +118,63 @@ export const nodeWebCompat: NodeWebCompat = { parseB64UrlString: (b64: string): string => new TextDecoder().decode(bufferFromBase64url(b64)), setTimeoutUnref: setTimeout.bind(undefined), + 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 () { diff --git a/src/node-web-compat.ts b/src/node-web-compat.ts index 8af7a4b..467187a 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,8 @@ export interface NodeWebCompat { socketIdle?: number; // socket idle timeout (Only supported by Node.js runtime) response: number; // total round trip timeout }; + transformPemToJwk: ( + pem: ArrayBuffer, + jwtHeaderAlg?: SupportedSignatureAlgorithm + ) => Promise; } diff --git a/tests/cognito/test/cognito.test.ts b/tests/cognito/test/cognito.test.ts index f360b19..09bc945 100644 --- a/tests/cognito/test/cognito.test.ts +++ b/tests/cognito/test/cognito.test.ts @@ -1,12 +1,8 @@ // 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 { assertStringEquals } from "aws-jwt-verify/assert"; -import { decomposeUnverifiedJwt } from "aws-jwt-verify/jwt"; -import { Jwk } from "aws-jwt-verify/jwk"; +import { AlbJwtVerifier, CognitoJwtVerifier } from "aws-jwt-verify"; import { CognitoIdentityProviderClient, InitiateAuthCommand, @@ -38,13 +34,10 @@ 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); - }, + clientId: clientIdAlb, }); const CLIENT = new CognitoIdentityProviderClient({ region }); const SIGN_IN_AS_USER = new InitiateAuthCommand({ @@ -170,27 +163,6 @@ 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) ).resolves.toMatchObject({ email: username }); diff --git a/tests/unit/alb-cache.test.ts b/tests/unit/alb-cache.test.ts new file mode 100644 index 0000000..3febcd4 --- /dev/null +++ b/tests/unit/alb-cache.test.ts @@ -0,0 +1,140 @@ +import { AlbJwksCache } from "../../src/alb-cache"; +import { + AlbJwksNotExposedError, + JwksNotAvailableInCacheError, + JwksValidationError, + JwkValidationError, + JwtWithoutValidKidError, +} from "../../src/error"; +import { + allowAllRealNetworkTraffic, + disallowAllRealNetworkTraffic, + generateKeyPair, +} from "./test-util"; + +describe("unit tests AlbJwksCache", () => { + 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 happy flow", () => { + const fetcher = { + fetch: jest.fn(async () => getAlbResponseArrayBuffer()), + }; + const jwksCache = new AlbJwksCache({ 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 AlbJwksCache(); + expect.assertions(1); + return expect( + jwksCache.getJwk(jwksUri, { header: { alg: "EC256" }, payload: {} }) + ).rejects.toThrow(JwtWithoutValidKidError); + }); + + test("ALB JWKS cache error flow: fetcher error", () => { + const errorExpected = new Error("fetcher error"); + const jwksCache = new AlbJwksCache({ + 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 AlbJwksCache(); + jwksCache.addJwks(jwksUri, keypair.jwks); + expect(jwksCache.getCachedJwk(jwksUri, getDecomposedJwt())).toEqual( + keypair.jwk + ); + }); + + test("ALB JWKS cache returns no JWK", () => { + const jwksCache = new AlbJwksCache(); + expect(() => jwksCache.getCachedJwk(jwksUri, getDecomposedJwt())).toThrow( + JwksNotAvailableInCacheError + ); + }); + + test("ALB JWKS add cache return multiple JWK exception", () => { + const jwksCache = new AlbJwksCache(); + expect(() => + jwksCache.addJwks(jwksUri, { + keys: [keypair.jwk, keypair.jwk], + }) + ).toThrow(JwksValidationError); + }); + + test("ALB JWKS add cache return no kid", () => { + const jwksCache = new AlbJwksCache(); + expect(() => + jwksCache.addJwks(jwksUri, { + keys: [ + { + kty: "EC", + alg: "ES256", + }, + ], + }) + ).toThrow(JwkValidationError); + }); + + test("ALB JWKS get JWKS return not implemented exception", () => { + const jwksCache = new AlbJwksCache(); + expect.assertions(1); + return expect(jwksCache.getJwks()).rejects.toThrow( + new AlbJwksNotExposedError("AWS ALB does not expose JWKS") + ); + }); + + 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 AlbJwksCache({ + 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); + }); +}); diff --git a/tests/unit/alb-verifier.test.ts b/tests/unit/alb-verifier.test.ts new file mode 100644 index 0000000..4dbbc25 --- /dev/null +++ b/tests/unit/alb-verifier.test.ts @@ -0,0 +1,930 @@ +import { + generateKeyPair, + signJwt, + allowAllRealNetworkTraffic, + disallowAllRealNetworkTraffic, + mockHttpsUri, +} from "./test-util"; +import { decomposeUnverifiedJwt } from "../../src/jwt"; +import { + AlbJwtVerifier, + AlbJwtVerifierMultiProperties, +} from "../../src/alb-verifier"; +import { + ParameterValidationError, + JwtInvalidClaimError, + JwtInvalidIssuerError, + AlbJwtInvalidSignerError, + AlbJwtInvalidClientIdError, +} from "../../src/error"; +import { createPublicKey } from "crypto"; +import { KeyPair } from "../util/util"; + +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", async () => { + const kid = keypair.jwk.kid; + 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"; + const exp = 4000000000; // Use of a long expiry date + + const signedJwt = signJwt( + { + typ: "JWT", + kid, + alg: "ES256", + iss: issuer, + client: clientId, + signer: albArn, + exp, + }, + { + hello: "world", + exp, + iss: issuer, + }, + keypair.privateKey + ); + const decomposedJwt = decomposeUnverifiedJwt(signedJwt); + const customJwtCheck = jest.fn(); + const albVerifier = AlbJwtVerifier.create({ + albArn, + issuer, + customJwtCheck, + }); + albVerifier.cacheJwks(keypair.jwks); + expect.assertions(2); + expect( + await albVerifier.verify(signedJwt, { + clientId: null, + }) + ).toMatchObject({ hello: "world" }); + expect(customJwtCheck).toHaveBeenCalledWith({ + header: decomposedJwt.header, + payload: decomposedJwt.payload, + jwk: keypair.jwk, + }); + }); + }); + describe("validateAlbJwtParams", () => { + test("invalid load balancer ARN - too short", async () => { + const issuer = `https://cognito-idp.us-east-1.amazonaws.com/us-east-1_123456`; + 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", 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 load balancer ARN: arn:aws:elasticloadbalancing:.:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188` + ) + ); + }); + }); + describe("includeRawJwtInErrors", () => { + test("verify - flag set at statement level", () => { + const kid = keypair.jwk.kid; + 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"; + const exp = 4000000000; // Use of a long expiry date + + 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 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"; + const exp = 4000000000; // Use of a long expiry date + 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 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"; + const exp = 4000000000; // Use of a long expiry date + 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 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"; + const exp = 4000000000; // Use of a long expiry date + 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 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"; + const exp = 4000000000; // Use of a long expiry date + 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 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"; + const exp = 4000000000; // Use of a long expiry date + + 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({ + albArn, + issuer, + }); + albVerifier.cacheJwks(keypair.jwks); + expect( + albVerifier.verifySync(signedJwt, { + clientId: null, + }) + ).toMatchObject({ hello: "world" }); + }); + + test("clientId null", async () => { + 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.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; // Use of a long expiry date + + 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(albVerifier.verifySync(signedJwt)).toMatchObject({ + hello: "world", + }); + }); + + test("clientId undefined", () => { + const kid = keypair.jwk.kid; + 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"; + const exp = 4000000000; // Use of a long expiry date + + 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, + 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" + ); + expect(() => verifier.verifySync(signedJwt)).toThrow( + ParameterValidationError + ); + }); + + test("invalid issuer", () => { + 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.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; // Use of a long expiry date + + 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.verifySync(signedJwt)).toThrow( + JwtInvalidIssuerError + ); + }); + + test("invalid signer", () => { + 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.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; // Use of a long expiry date + + 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.verifySync(signedJwt)).toThrow( + AlbJwtInvalidSignerError + ); + }); + + test("invalid clientId", () => { + 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.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; // Use of a long expiry date + + 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.verifySync(signedJwt)).toThrow( + AlbJwtInvalidClientIdError + ); + }); + }); + describe("jwksUri", () => { + test("default jwksUri in us-east-1", async () => { + 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.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; // Use of a long expiry date + 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("default jwksUri in eu-west-2", async () => { + 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.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; // Use of a long expiry date + 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 albArn = + "arn:aws:elasticloadbalancing:us-east-1:123456789012:loadbalancer/app/my-load-balancer/50dc6c495c0c9188"; + const clientId = "my-client-id"; + 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; + const exp = 4000000000; // Use of a long expiry date + 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", + }); + }); + }); + }); + + describe("AlbJwtVerifier with multiple alb", () => { + describe("verifySync", () => { + test("happy flow with 2 albs and 2 issuers", async () => { + const exp = 4000000000; // Use of a long expiry date + + 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({ + kid: "11111111-1111-1111-1111-111111111111", + 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: "22222222-2222-2222-2222-222222222222", + 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("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 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.us-east-1.amazonaws.com/us-east-1_123456`; + const jwksUri = `https://public-keys.auth.elb.us-east-1.amazonaws.com`; + const exp = 4000000000; // Use of a long expiry date + + 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, + }); + + albVerifier.cacheJwks(keypair1.jwks); + albVerifier.cacheJwks(keypair2.jwks); + expect.assertions(2); + + expect(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") + ); + }); + }); + + 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", + ]; + + expect(() => + AlbJwtVerifier.create({ + albArn, + issuer, + }) + ).toThrow( + new ParameterValidationError( + "Using ALBs from different regions is not supported for the same issuer" + ) + ); + }); + }); + }); +}); diff --git a/tests/unit/cache.test.ts b/tests/unit/cache.test.ts new file mode 100644 index 0000000..35bb807 --- /dev/null +++ b/tests/unit/cache.test.ts @@ -0,0 +1,73 @@ +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], + ]); + }); +});