From cf79d82e0c89486c14067c53daef7cf451d4749b Mon Sep 17 00:00:00 2001 From: littlemight Date: Fri, 14 Feb 2025 01:53:09 +0800 Subject: [PATCH 01/38] feat: init jwks with mock json --- src/index.ts | 13 ++-- src/resource/mock-jwks.json | 28 +++++++++ src/util/base64.ts | 16 +++++ src/util/jwks.ts | 117 ++++++++++++++++++++++++++++++++++++ tsconfig.json | 1 + 5 files changed, 170 insertions(+), 5 deletions(-) create mode 100644 src/resource/mock-jwks.json create mode 100644 src/util/base64.ts create mode 100644 src/util/jwks.ts diff --git a/src/index.ts b/src/index.ts index 9022d92..f971986 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,7 @@ -import { getSigningPublicKey, getVerificationPublicKey } from './util/publicKey' +import { + getSigningPublicKeyFromJwks, + getVerificationPublicKeyFromJwks, +} from './util/jwks' import Crypto from './crypto' import CryptoV3 from './crypto-v3' import { PackageInitParams } from './types' @@ -14,16 +17,16 @@ import Webhooks from './webhooks' * @param {VerificationOptions?} [config.verificationOptions] Optional. If provided, enables the usage of the verification module. */ export = function (config: PackageInitParams = {}) { - const { webhookSecretKey, mode, verificationOptions } = config + const { webhookSecretKey, verificationOptions } = config /** * Public key is used for decrypting signed verified content in the `crypto` module, and - * also for verifying webhook signatures' authenticity in the `wehbooks` module. + * also for verifying webhook signatures' authenticity in the `webhooks` module. */ - const signingPublicKey = getSigningPublicKey(mode || 'production') + const signingPublicKey = getSigningPublicKeyFromJwks() /** * Public key is used for verifying verified field signatures' authenticity in the `verification` module. */ - const verificationPublicKey = getVerificationPublicKey(mode || 'production') + const verificationPublicKey = getVerificationPublicKeyFromJwks() return { webhooks: new Webhooks({ diff --git a/src/resource/mock-jwks.json b/src/resource/mock-jwks.json new file mode 100644 index 0000000..aa69938 --- /dev/null +++ b/src/resource/mock-jwks.json @@ -0,0 +1,28 @@ +{ + "keys": [ + { + "kty": "RSA", + "kid": "rsa-1234", + "use": "sig", + "alg": "RS256", + "n": "old-key-modulus", + "e": "AQAB" + }, + { + "kty": "OKP", + "kid": "signing-d26b11d1-4a03-40df-9b88-2234eac30ef7", + "use": "sig", + "alg": "EdDSA", + "crv": "Ed25519", + "x": "rjv41kYqZwcbe3r6ymMEEKQ-Vd-DPuogN-Gzq3lP2Og" + }, + { + "kty": "OKP", + "kid": "verification-09305bf4-b4da-469b-b502-afe318ac2a18", + "use": "sig", + "alg": "EdDSA", + "crv": "Ed25519", + "x": "bDgK1223JbrDNePFIrj7b0z02Z5nSiBzkRYRqDdVPfA" + } + ] +} diff --git a/src/util/base64.ts b/src/util/base64.ts new file mode 100644 index 0000000..e203887 --- /dev/null +++ b/src/util/base64.ts @@ -0,0 +1,16 @@ +/** + * base64 to base64url magic + * - Replaces + with - + * - Replaces / with _ + * - Removes padding (=) + */ +export const toBase64Url = (base64: string): string => { + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +/** + * Example: + * const base64 = 'rjv41kYqZwcbe3r6ymMEEKQ+Vd+DPuogN+Gzq3lP2Og=' + * const base64url = toBase64Url(base64) + * // Result: 'rjv41kYqZwcbe3r6ymMEEKQ-Vd-DPuogN-Gzq3lP2Og' + */ diff --git a/src/util/jwks.ts b/src/util/jwks.ts new file mode 100644 index 0000000..d2f607b --- /dev/null +++ b/src/util/jwks.ts @@ -0,0 +1,117 @@ +import mockJwks from '../resource/mock-jwks.json' + +// import { toBase64Url } from './base64' + +export interface JwksKey { + kty: string + kid: string + use: string + alg: string + crv?: string + x?: string // For Ed25519 keys + y?: string // For EC keys + n?: string // For RSA keys + e?: string // For RSA keys +} + +export const getKeyFromJwks = ( + kid: string, + jwks: { keys: JwksKey[] } +): string | null => { + const key = jwks.keys.find((k) => k.kid === kid) + if (!key) return null + + // Handle Ed25519 keys - a type of Edwards-curve key + if (key.kty === 'OKP' && key.crv === 'Ed25519' && key.x) { + const base64 = key.x.replace(/-/g, '+').replace(/_/g, '/') + return base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '=') + } + + // Handle EC keys (like P-256, P-384, P-521) + if (key.kty === 'EC') { + // EC keys require both x and y coordinates + if (!key.x || !key.y) return null + // Return concatenated x and y coordinates + return key.x + key.y + } + + // Handle RSA keys + if (key.kty === 'RSA') { + return key.n ?? null + } + + return null +} + +export const getSigningPublicKeyFromJwks = (): string => { + const signingKid = 'signing-d26b11d1-4a03-40df-9b88-2234eac30ef7' + const key = getKeyFromJwks(signingKid, mockJwks) + if (!key) throw new Error(`Unable to find signing key with kid=${signingKid}`) + return key +} + +export const getVerificationPublicKeyFromJwks = (): string => { + const verificationKid = 'verification-09305bf4-b4da-469b-b502-afe318ac2a18' + const key = getKeyFromJwks(verificationKid, mockJwks) + if (!key) + throw new Error( + `Unable to find verification key with kid=${verificationKid}` + ) + return key +} + +// Example function to help create JWKS entries +// export const createEd25519JwksKey = ( +// kid: string, +// base64Key: string +// ): JwksKey => { +// return { +// kty: 'OKP', +// kid, +// use: 'sig', +// alg: 'EdDSA', +// crv: 'Ed25519', +// x: toBase64Url(base64Key), +// } +// } + +// // Example function to help create EC JWKS keys +// export const createEcJwksKey = ( +// kid: string, +// x: string, +// y: string, +// curve: 'P-256' | 'P-384' | 'P-521' = 'P-256' +// ): JwksKey => { +// return { +// kty: 'EC', +// kid, +// use: 'sig', +// alg: 'ES256', +// crv: curve, +// x: toBase64Url(x), +// y: toBase64Url(y), +// } +// } + +/** + * Example usage: + * const key = createEd25519JwksKey( + * 'signing-key-1', + * 'rjv41kYqZwcbe3r6ymMEEKQ+Vd+DPuogN+Gzq3lP2Og=' + * ) + * // Result: + * // { + * // kty: 'OKP', + * // kid: 'signing-key-1', + * // use: 'sig', + * // alg: 'EdDSA', + * // crv: 'Ed25519', + * // x: 'rjv41kYqZwcbe3r6ymMEEKQ-Vd-DPuogN-Gzq3lP2Og' + * // } + */ + +/** + * Note: Ed25519 is a specific Edwards curve designed for digital signatures + * while EC keys typically use NIST curves like P-256. They have different + * characteristics and security properties. + */ diff --git a/tsconfig.json b/tsconfig.json index 41d0b16..8cb9a6e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,7 @@ "types": ["jest", "node"], "esModuleInterop": true, "rootDir": "src", + "resolveJsonModule": true, "lib": ["WebWorker"] }, "include": ["src"], From 046b52744170200361f79e92fe3a0ab68260c733 Mon Sep 17 00:00:00 2001 From: littlemight Date: Fri, 14 Feb 2025 02:08:39 +0800 Subject: [PATCH 02/38] feat: fetch using axios --- src/index.ts | 11 +++++++++-- src/resource/signing-keys.ts | 3 ++- src/types.ts | 2 ++ src/util/jwks.ts | 18 ++++++++++++++++-- 4 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/index.ts b/src/index.ts index f971986..4883b53 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ import { + fetchJwks, getSigningPublicKeyFromJwks, getVerificationPublicKeyFromJwks, } from './util/jwks' @@ -16,8 +17,14 @@ import Webhooks from './webhooks' * @param {string?} [config.webhookSecretKey] Optional. base64 secret key for signing webhooks. If provided, enables generating signature and headers to authenticate webhook data. * @param {VerificationOptions?} [config.verificationOptions] Optional. If provided, enables the usage of the verification module. */ -export = function (config: PackageInitParams = {}) { - const { webhookSecretKey, verificationOptions } = config +export = async function (config: PackageInitParams = {}) { + const { webhookSecretKey, verificationOptions, jwksUrl } = config + + // Fetch JWKS if URL is provided + if (jwksUrl) { + await fetchJwks(jwksUrl) + } + /** * Public key is used for decrypting signed verified content in the `crypto` module, and * also for verifying webhook signatures' authenticity in the `webhooks` module. diff --git a/src/resource/signing-keys.ts b/src/resource/signing-keys.ts index 1dbc120..17da951 100644 --- a/src/resource/signing-keys.ts +++ b/src/resource/signing-keys.ts @@ -6,7 +6,8 @@ export const SIGNING_KEYS = { }, development: { publicKey: 'Tl5gfszlKcQj99/0uafLwVpT6JAu4C0dHGvLq1cHzFE=', - secretKey: 'HDBXpu+2/gu10bLHpy8HjpN89xbA6boH9GwibPGJA8BOXmB+zOUpxCP33/S5p8vBWlPokC7gLR0ca8urVwfMUQ==', + secretKey: + 'HDBXpu+2/gu10bLHpy8HjpN89xbA6boH9GwibPGJA8BOXmB+zOUpxCP33/S5p8vBWlPokC7gLR0ca8urVwfMUQ==', }, production: { // production must never contain secret keys diff --git a/src/types.ts b/src/types.ts index aa47918..cf12808 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,6 +5,8 @@ export type PackageInitParams = { verificationOptions?: VerificationOptions /** Initializes public key used for verifying and decrypting in this package. If not given, will default to "production". */ mode?: PackageMode + /** URL to fetch JWKS from. */ + jwksUrl?: string } // A field type available in FormSG as a string diff --git a/src/util/jwks.ts b/src/util/jwks.ts index d2f607b..fbe50a3 100644 --- a/src/util/jwks.ts +++ b/src/util/jwks.ts @@ -1,3 +1,5 @@ +import axios from 'axios' + import mockJwks from '../resource/mock-jwks.json' // import { toBase64Url } from './base64' @@ -14,6 +16,18 @@ export interface JwksKey { e?: string // For RSA keys } +// TODO: use actual cached JWKS +let cachedJwks = mockJwks + +export const fetchJwks = async (jwksUrl: string): Promise => { + try { + const { data } = await axios.get(jwksUrl) + cachedJwks = data + } catch (error) { + console.warn('Failed to fetch JWKS, falling back to mock JWKS:', error) + } +} + export const getKeyFromJwks = ( kid: string, jwks: { keys: JwksKey[] } @@ -45,14 +59,14 @@ export const getKeyFromJwks = ( export const getSigningPublicKeyFromJwks = (): string => { const signingKid = 'signing-d26b11d1-4a03-40df-9b88-2234eac30ef7' - const key = getKeyFromJwks(signingKid, mockJwks) + const key = getKeyFromJwks(signingKid, cachedJwks) if (!key) throw new Error(`Unable to find signing key with kid=${signingKid}`) return key } export const getVerificationPublicKeyFromJwks = (): string => { const verificationKid = 'verification-09305bf4-b4da-469b-b502-afe318ac2a18' - const key = getKeyFromJwks(verificationKid, mockJwks) + const key = getKeyFromJwks(verificationKid, cachedJwks) if (!key) throw new Error( `Unable to find verification key with kid=${verificationKid}` From efe6d5d794c1532e37b61b391f7becc182e5f52e Mon Sep 17 00:00:00 2001 From: littlemight Date: Fri, 14 Feb 2025 02:18:56 +0800 Subject: [PATCH 03/38] feat: fallback to prev keys if jwks fail --- src/index.ts | 27 ++++++--------------------- src/util/keys.ts | 31 +++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 21 deletions(-) create mode 100644 src/util/keys.ts diff --git a/src/index.ts b/src/index.ts index 4883b53..d40967e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,4 @@ -import { - fetchJwks, - getSigningPublicKeyFromJwks, - getVerificationPublicKeyFromJwks, -} from './util/jwks' +import { getPublicKeys } from './util/keys' import Crypto from './crypto' import CryptoV3 from './crypto-v3' import { PackageInitParams } from './types' @@ -18,22 +14,11 @@ import Webhooks from './webhooks' * @param {VerificationOptions?} [config.verificationOptions] Optional. If provided, enables the usage of the verification module. */ export = async function (config: PackageInitParams = {}) { - const { webhookSecretKey, verificationOptions, jwksUrl } = config - - // Fetch JWKS if URL is provided - if (jwksUrl) { - await fetchJwks(jwksUrl) - } - - /** - * Public key is used for decrypting signed verified content in the `crypto` module, and - * also for verifying webhook signatures' authenticity in the `webhooks` module. - */ - const signingPublicKey = getSigningPublicKeyFromJwks() - /** - * Public key is used for verifying verified field signatures' authenticity in the `verification` module. - */ - const verificationPublicKey = getVerificationPublicKeyFromJwks() + const { webhookSecretKey, verificationOptions, jwksUrl, mode } = config + const { signingPublicKey, verificationPublicKey } = await getPublicKeys( + jwksUrl, + mode + ) return { webhooks: new Webhooks({ diff --git a/src/util/keys.ts b/src/util/keys.ts new file mode 100644 index 0000000..b38ec18 --- /dev/null +++ b/src/util/keys.ts @@ -0,0 +1,31 @@ +import { PackageMode } from '../types' + +import { + fetchJwks, + getSigningPublicKeyFromJwks, + getVerificationPublicKeyFromJwks, +} from './jwks' +import { getSigningPublicKey, getVerificationPublicKey } from './publicKey' + +export async function getPublicKeys(jwksUrl?: string, mode?: PackageMode) { + if (!jwksUrl) { + return { + signingPublicKey: getSigningPublicKey(mode), + verificationPublicKey: getVerificationPublicKey(mode), + } + } + + try { + await fetchJwks(jwksUrl) + return { + signingPublicKey: getSigningPublicKeyFromJwks(), + verificationPublicKey: getVerificationPublicKeyFromJwks(), + } + } catch (error) { + console.warn('Falling back to static public keys:', error) + return { + signingPublicKey: getSigningPublicKey(mode), + verificationPublicKey: getVerificationPublicKey(mode), + } + } +} From 44d4ba9dfeb656cee09499bcdb96779da389001c Mon Sep 17 00:00:00 2001 From: littlemight Date: Fri, 14 Feb 2025 02:29:08 +0800 Subject: [PATCH 04/38] feat: prepare boilerplate for cache logic --- src/index.ts | 4 ++-- src/types.ts | 13 +++++++++++-- src/util/constants.ts | 4 ++++ src/util/jwks.ts | 27 +++++++++++++++++++++++++-- src/util/keys.ts | 8 ++++---- 5 files changed, 46 insertions(+), 10 deletions(-) create mode 100644 src/util/constants.ts diff --git a/src/index.ts b/src/index.ts index d40967e..841b4b1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,9 +14,9 @@ import Webhooks from './webhooks' * @param {VerificationOptions?} [config.verificationOptions] Optional. If provided, enables the usage of the verification module. */ export = async function (config: PackageInitParams = {}) { - const { webhookSecretKey, verificationOptions, jwksUrl, mode } = config + const { webhookSecretKey, verificationOptions, jwks, mode } = config const { signingPublicKey, verificationPublicKey } = await getPublicKeys( - jwksUrl, + jwks, mode ) diff --git a/src/types.ts b/src/types.ts index cf12808..356397a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,12 @@ +export type JwksConfig = { + /** URL to fetch JWKS from */ + url: string + /** Timeout in milliseconds for JWKS fetch request. Defaults to 5000ms */ + timeoutMs?: number + /** Duration in milliseconds to cache JWKS. Defaults to 3600000ms (1 hour) */ + cacheDurationMs?: number +} + export type PackageInitParams = { /** base64 secret key for signing webhooks. If provided, enables generating signature and headers to authenticate webhook data. */ webhookSecretKey?: string @@ -5,8 +14,8 @@ export type PackageInitParams = { verificationOptions?: VerificationOptions /** Initializes public key used for verifying and decrypting in this package. If not given, will default to "production". */ mode?: PackageMode - /** URL to fetch JWKS from. */ - jwksUrl?: string + /** JWKS configuration */ + jwks?: JwksConfig } // A field type available in FormSG as a string diff --git a/src/util/constants.ts b/src/util/constants.ts new file mode 100644 index 0000000..8a3aad2 --- /dev/null +++ b/src/util/constants.ts @@ -0,0 +1,4 @@ +// Safe max value to avoid floating point precision issues (Number.MAX_SAFE_INTEGER = 9007199254740991) +export const DEFAULT_JWKS_CACHE_DURATION_MS = 3_600_000 // 1 hour +export const DEFAULT_JWKS_TIMEOUT_MS = 5_000 // 5 seconds +export const MAX_CACHE_DURATION_MS = 86_400_000 // 24 hours, current upper bound diff --git a/src/util/jwks.ts b/src/util/jwks.ts index fbe50a3..6c2bb87 100644 --- a/src/util/jwks.ts +++ b/src/util/jwks.ts @@ -1,6 +1,13 @@ import axios from 'axios' import mockJwks from '../resource/mock-jwks.json' +import { JwksConfig } from '../types' + +import { + DEFAULT_JWKS_CACHE_DURATION_MS, + DEFAULT_JWKS_TIMEOUT_MS, + MAX_CACHE_DURATION_MS, +} from './constants' // import { toBase64Url } from './base64' @@ -18,11 +25,27 @@ export interface JwksKey { // TODO: use actual cached JWKS let cachedJwks = mockJwks +let lastFetchTime = 0 + +export const fetchJwks = async (config: JwksConfig): Promise => { + const { + url, + timeoutMs = DEFAULT_JWKS_TIMEOUT_MS, + cacheDurationMs = DEFAULT_JWKS_CACHE_DURATION_MS, + } = config + + // Enforce maximum cache duration to avoid potential issues + const safeCacheDuration = Math.min(cacheDurationMs, MAX_CACHE_DURATION_MS) + + // Check cache validity + if (lastFetchTime && Date.now() - lastFetchTime < safeCacheDuration) { + return + } -export const fetchJwks = async (jwksUrl: string): Promise => { try { - const { data } = await axios.get(jwksUrl) + const { data } = await axios.get(url, { timeout: timeoutMs }) cachedJwks = data + lastFetchTime = Date.now() } catch (error) { console.warn('Failed to fetch JWKS, falling back to mock JWKS:', error) } diff --git a/src/util/keys.ts b/src/util/keys.ts index b38ec18..6e26b9f 100644 --- a/src/util/keys.ts +++ b/src/util/keys.ts @@ -1,4 +1,4 @@ -import { PackageMode } from '../types' +import { JwksConfig, PackageMode } from '../types' import { fetchJwks, @@ -7,8 +7,8 @@ import { } from './jwks' import { getSigningPublicKey, getVerificationPublicKey } from './publicKey' -export async function getPublicKeys(jwksUrl?: string, mode?: PackageMode) { - if (!jwksUrl) { +export async function getPublicKeys(jwks?: JwksConfig, mode?: PackageMode) { + if (!jwks?.url) { return { signingPublicKey: getSigningPublicKey(mode), verificationPublicKey: getVerificationPublicKey(mode), @@ -16,7 +16,7 @@ export async function getPublicKeys(jwksUrl?: string, mode?: PackageMode) { } try { - await fetchJwks(jwksUrl) + await fetchJwks(jwks) return { signingPublicKey: getSigningPublicKeyFromJwks(), verificationPublicKey: getVerificationPublicKeyFromJwks(), From 46757a63bea23761c5a98436cdadc354e727dabb Mon Sep 17 00:00:00 2001 From: littlemight Date: Fri, 14 Feb 2025 16:22:37 +0800 Subject: [PATCH 05/38] feat: barebones string cache --- src/crypto.ts | 12 +++--- src/index.ts | 13 +++--- src/util/cache.ts | 30 ++++++++++++++ src/util/keys.ts | 20 +++++---- src/util/publicKey.ts | 87 +++++++++++++++++++++++++-------------- src/verification/index.ts | 24 +++++++---- src/webhooks.ts | 12 +++--- 7 files changed, 134 insertions(+), 64 deletions(-) create mode 100644 src/util/cache.ts diff --git a/src/crypto.ts b/src/crypto.ts index 59086d8..1d7afbd 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -24,11 +24,11 @@ import { } from './types' export default class Crypto extends CryptoBase { - signingPublicKey?: string + getSigningPublicKey: () => string - constructor({ signingPublicKey }: { signingPublicKey?: string } = {}) { + constructor({ getSigningPublicKey }: { getSigningPublicKey: () => string }) { super() - this.signingPublicKey = signingPublicKey + this.getSigningPublicKey = getSigningPublicKey } /** @@ -87,7 +87,9 @@ export default class Crypto extends CryptoBase { } if (verifiedContent) { - if (!this.signingPublicKey) { + // Get fresh public key when verifying + const signingPublicKey = this.getSigningPublicKey() + if (!signingPublicKey) { throw new MissingPublicKeyError( 'Public signing key must be provided when instantiating the Crypto class in order to verify verified content' ) @@ -105,7 +107,7 @@ export default class Crypto extends CryptoBase { } const decryptedVerifiedObject = verifySignedMessage( decryptedVerifiedContent, - this.signingPublicKey + signingPublicKey ) returnedObject.verified = decryptedVerifiedObject diff --git a/src/index.ts b/src/index.ts index 841b4b1..2421683 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,20 +15,19 @@ import Webhooks from './webhooks' */ export = async function (config: PackageInitParams = {}) { const { webhookSecretKey, verificationOptions, jwks, mode } = config - const { signingPublicKey, verificationPublicKey } = await getPublicKeys( - jwks, - mode - ) + const keyGetters = await getPublicKeys(jwks, mode) return { webhooks: new Webhooks({ - publicKey: signingPublicKey, + getPublicKey: keyGetters.signingPublicKey, secretKey: webhookSecretKey, }), - crypto: new Crypto({ signingPublicKey }), + crypto: new Crypto({ + getSigningPublicKey: keyGetters.signingPublicKey, + }), cryptoV3: new CryptoV3(), verification: new Verification({ - publicKey: verificationPublicKey, + getVerificationPublicKey: keyGetters.verificationPublicKey, secretKey: verificationOptions?.secretKey, transactionExpiry: verificationOptions?.transactionExpiry, }), diff --git a/src/util/cache.ts b/src/util/cache.ts new file mode 100644 index 0000000..921d752 --- /dev/null +++ b/src/util/cache.ts @@ -0,0 +1,30 @@ +export type CacheData = { + value: T + timestamp: number +} + +export class Cache { + private data: CacheData | null = null + private readonly duration: number + + constructor(cacheDurationMs: number) { + this.duration = cacheDurationMs + } + + get(): T | null { + if (!this.data) return null + if (Date.now() - this.data.timestamp > this.duration) return null + return this.data.value + } + + set(value: T): void { + this.data = { + value, + timestamp: Date.now(), + } + } + + clear(): void { + this.data = null + } +} diff --git a/src/util/keys.ts b/src/util/keys.ts index 6e26b9f..1c0aaae 100644 --- a/src/util/keys.ts +++ b/src/util/keys.ts @@ -5,27 +5,33 @@ import { getSigningPublicKeyFromJwks, getVerificationPublicKeyFromJwks, } from './jwks' -import { getSigningPublicKey, getVerificationPublicKey } from './publicKey' +import { + getSigningPublicKey, + getVerificationPublicKey, + initKeyCaches, +} from './publicKey' export async function getPublicKeys(jwks?: JwksConfig, mode?: PackageMode) { + initKeyCaches(jwks) + if (!jwks?.url) { return { - signingPublicKey: getSigningPublicKey(mode), - verificationPublicKey: getVerificationPublicKey(mode), + signingPublicKey: () => getSigningPublicKey(mode), + verificationPublicKey: () => getVerificationPublicKey(mode), } } try { await fetchJwks(jwks) return { - signingPublicKey: getSigningPublicKeyFromJwks(), - verificationPublicKey: getVerificationPublicKeyFromJwks(), + signingPublicKey: () => getSigningPublicKeyFromJwks(), + verificationPublicKey: () => getVerificationPublicKeyFromJwks(), } } catch (error) { console.warn('Falling back to static public keys:', error) return { - signingPublicKey: getSigningPublicKey(mode), - verificationPublicKey: getVerificationPublicKey(mode), + signingPublicKey: () => getSigningPublicKey(mode), + verificationPublicKey: () => getVerificationPublicKey(mode), } } } diff --git a/src/util/publicKey.ts b/src/util/publicKey.ts index a187f00..6453b92 100644 --- a/src/util/publicKey.ts +++ b/src/util/publicKey.ts @@ -1,43 +1,68 @@ import { SIGNING_KEYS } from '../resource/signing-keys' import { VERIFICATION_KEYS } from '../resource/verification-keys' -import { PackageMode } from '../types' +import { JwksConfig, PackageMode } from '../types' +import { Cache } from './cache' +import { DEFAULT_JWKS_CACHE_DURATION_MS } from './constants' import STAGE from './stage' +const createKeyCache = (config?: JwksConfig) => { + const duration = config?.cacheDurationMs ?? DEFAULT_JWKS_CACHE_DURATION_MS + return new Cache(duration) +} + +let signingKeyCache: Cache +let verificationKeyCache: Cache + +export function initKeyCaches(config?: JwksConfig) { + signingKeyCache = createKeyCache(config) + verificationKeyCache = createKeyCache(config) +} + /** - * Retrieves the appropriate signing public key. - * Defaults to production. - * @param mode The package mode to retrieve the public key for. + * Gets the signing public key with caching */ -function getSigningPublicKey(mode?: PackageMode) { - switch (mode) { - case STAGE.development: - return SIGNING_KEYS.development.publicKey - case STAGE.staging: - return SIGNING_KEYS.staging.publicKey - case STAGE.test: - return SIGNING_KEYS.test.publicKey - default: - return SIGNING_KEYS.production.publicKey - } +export function getSigningPublicKey(mode?: PackageMode): string { + const cached = signingKeyCache.get() + if (cached) return cached + + const key = (() => { + switch (mode) { + case STAGE.development: + return SIGNING_KEYS.development.publicKey + case STAGE.staging: + return SIGNING_KEYS.staging.publicKey + case STAGE.test: + return SIGNING_KEYS.test.publicKey + default: + return SIGNING_KEYS.production.publicKey + } + })() + + signingKeyCache.set(key) + return key } /** - * Retrieves the appropriate verification public key. - * Defaults to production. - * @param mode The package mode to retrieve the public key for. + * Gets the verification public key with caching */ -function getVerificationPublicKey(mode?: PackageMode) { - switch (mode) { - case STAGE.development: - return VERIFICATION_KEYS.development.publicKey - case STAGE.staging: - return VERIFICATION_KEYS.staging.publicKey - case STAGE.test: - return VERIFICATION_KEYS.test.publicKey - default: - return VERIFICATION_KEYS.production.publicKey - } -} +export function getVerificationPublicKey(mode?: PackageMode): string { + const cached = verificationKeyCache.get() + if (cached) return cached -export { getSigningPublicKey, getVerificationPublicKey } + const key = (() => { + switch (mode) { + case STAGE.development: + return VERIFICATION_KEYS.development.publicKey + case STAGE.staging: + return VERIFICATION_KEYS.staging.publicKey + case STAGE.test: + return VERIFICATION_KEYS.test.publicKey + default: + return VERIFICATION_KEYS.production.publicKey + } + })() + + verificationKeyCache.set(key) + return key +} diff --git a/src/verification/index.ts b/src/verification/index.ts index 54cfb71..82fee16 100644 --- a/src/verification/index.ts +++ b/src/verification/index.ts @@ -8,7 +8,6 @@ import { decodeBase64, decodeUTF8, encodeBase64 } from 'tweetnacl-util' import { MissingPublicKeyError, MissingSecretKeyError } from '../errors' import { VerificationAuthenticateOptions, - VerificationOptions, VerificationSignatureOptions, } from '../types' import { parseVerificationSignature } from '../util/parser' @@ -16,14 +15,22 @@ import { parseVerificationSignature } from '../util/parser' import { formatToBaseString, isSignatureTimeValid } from './utils' export default class Verification { - verificationPublicKey?: string + getVerificationPublicKey: () => string verificationSecretKey?: string transactionExpiry?: number - constructor(params?: VerificationOptions) { - this.verificationPublicKey = params?.publicKey - this.verificationSecretKey = params?.secretKey - this.transactionExpiry = params?.transactionExpiry + constructor({ + getVerificationPublicKey, + secretKey, + transactionExpiry, + }: { + getVerificationPublicKey: () => string + secretKey?: string + transactionExpiry?: number + }) { + this.getVerificationPublicKey = getVerificationPublicKey + this.verificationSecretKey = secretKey + this.transactionExpiry = transactionExpiry } /** @@ -47,7 +54,8 @@ export default class Verification { ) } - if (!this.verificationPublicKey) { + const verificationPublicKey = this.getVerificationPublicKey() + if (!verificationPublicKey) { throw new MissingPublicKeyError() } @@ -77,7 +85,7 @@ export default class Verification { return nacl.sign.detached.verify( decodeUTF8(data), decodeBase64(signature), - decodeBase64(this.verificationPublicKey) + decodeBase64(verificationPublicKey) ) } else { console.info( diff --git a/src/webhooks.ts b/src/webhooks.ts index 9a0d7ff..43b625f 100644 --- a/src/webhooks.ts +++ b/src/webhooks.ts @@ -6,17 +6,17 @@ import { hasEpochExpired, isSignatureHeaderValid } from './util/webhooks' import { MissingSecretKeyError, WebhookAuthenticateError } from './errors' export default class Webhooks { - publicKey: string + getPublicKey: () => string secretKey?: string constructor({ - publicKey, + getPublicKey, secretKey, }: { - publicKey: string + getPublicKey: () => string secretKey?: string }) { - this.publicKey = publicKey + this.getPublicKey = getPublicKey this.secretKey = secretKey } @@ -37,8 +37,8 @@ export default class Webhooks { f: formId, } = signatureHeader - // Verify signature authenticity - if (!isSignatureHeaderValid(uri, signatureHeader, this.publicKey)) { + // Get fresh public key on each signature verification + if (!isSignatureHeaderValid(uri, signatureHeader, this.getPublicKey())) { throw new WebhookAuthenticateError( `Signature could not be verified for uri=${uri} submissionId=${submissionId} formId=${formId} epoch=${epoch} signature=${signature}` ) From 12716e6247081f72eaa548f12461cb1b8ac293d9 Mon Sep 17 00:00:00 2001 From: littlemight Date: Fri, 14 Feb 2025 17:38:41 +0800 Subject: [PATCH 06/38] test: make sure DIY cache works --- spec/util/cache.spec.ts | 65 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 spec/util/cache.spec.ts diff --git a/spec/util/cache.spec.ts b/spec/util/cache.spec.ts new file mode 100644 index 0000000..2970e2f --- /dev/null +++ b/spec/util/cache.spec.ts @@ -0,0 +1,65 @@ +import { Cache } from '../../src/util/cache' + +describe('Cache', () => { + let now: jest.SpyInstance + + beforeEach(() => { + const initialTime = 1000 + now = jest.spyOn(Date, 'now').mockImplementation(() => initialTime) + }) + + afterEach(() => { + now.mockRestore() + }) + + it('should return null for empty cache', () => { + const cache = new Cache(1000) + expect(cache.get()).toBeNull() + }) + + it('should return cached value within duration', () => { + const cache = new Cache(1000) + cache.set('test-value') + + // Still within cache duration + now.mockImplementation(() => 1500) + expect(cache.get()).toBe('test-value') + }) + + it('should return null for expired cache', () => { + const cache = new Cache(1000) + cache.set('test-value') + + // Advance time well beyond cache duration + now.mockImplementation(() => 2002) + expect(cache.get()).toBeNull() + }) + + it('should update cache value on set', () => { + const cache = new Cache(1000) + cache.set('test-value-1') + expect(cache.get()).toBe('test-value-1') + + cache.set('test-value-2') + expect(cache.get()).toBe('test-value-2') + }) + + it('should clear cache value', () => { + const cache = new Cache(1000) + cache.set('test-value') + expect(cache.get()).toBe('test-value') + + cache.clear() + expect(cache.get()).toBeNull() + }) + + it('should work with different data types', () => { + const numberCache = new Cache(1000) + numberCache.set(123) + expect(numberCache.get()).toBe(123) + + const objectCache = new Cache<{ test: string }>(1000) + objectCache.set({ test: 'value' }) + expect(objectCache.get()).toEqual({ test: 'value' }) + }) +}) From 0afa54a9788fc5da4e12b564c05aa2e3b9f84c41 Mon Sep 17 00:00:00 2001 From: littlemight Date: Fri, 14 Feb 2025 23:22:32 +0800 Subject: [PATCH 07/38] chore: readd comment back --- src/index.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/index.ts b/src/index.ts index 2421683..30804de 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,15 @@ import Webhooks from './webhooks' */ export = async function (config: PackageInitParams = {}) { const { webhookSecretKey, verificationOptions, jwks, mode } = config + + /** + * signingPublicKey is used for decrypting signed verified content in the `crypto` module, and + * also for verifying webhook signatures' authenticity in the `wehbooks` module. + * + * verificationPublicKey is used for verifying verified field signatures' authenticity in the `verification` module. + * + * Both keys are fetched from the JWKS endpoint if provided, else they are fetched from the static public keys. + */ const keyGetters = await getPublicKeys(jwks, mode) return { From efdabbad8c99ab2858f80455ef007fa243c90c62 Mon Sep 17 00:00:00 2001 From: littlemight Date: Sat, 15 Feb 2025 00:43:37 +0800 Subject: [PATCH 08/38] refactor: modules to use async pub key getters --- src/crypto.ts | 16 ++-- src/resource/mock-jwks.json | 14 +-- src/util/jwks.ts | 185 ++++++++++++------------------------ src/util/keys.ts | 52 +++++----- src/util/publicKey.ts | 81 +++++----------- src/verification/index.ts | 8 +- src/webhooks.ts | 9 +- 7 files changed, 134 insertions(+), 231 deletions(-) diff --git a/src/crypto.ts b/src/crypto.ts index 1d7afbd..4149fab 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -24,9 +24,13 @@ import { } from './types' export default class Crypto extends CryptoBase { - getSigningPublicKey: () => string + getSigningPublicKey: () => Promise - constructor({ getSigningPublicKey }: { getSigningPublicKey: () => string }) { + constructor({ + getSigningPublicKey, + }: { + getSigningPublicKey: () => Promise + }) { super() this.getSigningPublicKey = getSigningPublicKey } @@ -62,10 +66,10 @@ export default class Crypto extends CryptoBase { * @returns The decrypted content if successful. Else, null will be returned. * @throws {MissingPublicKeyError} if a public key is not provided when instantiating this class and is needed for verifying signed content. */ - decrypt = ( + decrypt = async ( formSecretKey: string, decryptParams: DecryptParams - ): DecryptedContent | null => { + ): Promise => { try { const { encryptedContent, verifiedContent } = decryptParams @@ -88,7 +92,7 @@ export default class Crypto extends CryptoBase { if (verifiedContent) { // Get fresh public key when verifying - const signingPublicKey = this.getSigningPublicKey() + const signingPublicKey = await this.getSigningPublicKey() if (!signingPublicKey) { throw new MissingPublicKeyError( 'Public signing key must be provided when instantiating the Crypto class in order to verify verified content' @@ -161,7 +165,7 @@ export default class Crypto extends CryptoBase { const attachmentRecords: EncryptedAttachmentRecords = decryptParams.attachmentDownloadUrls ?? {} - const decryptedContent = this.decrypt(formSecretKey, decryptParams) + const decryptedContent = await this.decrypt(formSecretKey, decryptParams) if (decryptedContent === null) return null // Retrieve all original filenames for attachments for easy lookup diff --git a/src/resource/mock-jwks.json b/src/resource/mock-jwks.json index aa69938..787f06b 100644 --- a/src/resource/mock-jwks.json +++ b/src/resource/mock-jwks.json @@ -1,16 +1,8 @@ { "keys": [ - { - "kty": "RSA", - "kid": "rsa-1234", - "use": "sig", - "alg": "RS256", - "n": "old-key-modulus", - "e": "AQAB" - }, { "kty": "OKP", - "kid": "signing-d26b11d1-4a03-40df-9b88-2234eac30ef7", + "kid": "signing-webhook-d26b11d1-4a03-40df-9b88-2234eac30ef7", "use": "sig", "alg": "EdDSA", "crv": "Ed25519", @@ -18,8 +10,8 @@ }, { "kty": "OKP", - "kid": "verification-09305bf4-b4da-469b-b502-afe318ac2a18", - "use": "sig", + "kid": "signing-otp-09305bf4-b4da-469b-b502-afe318ac2a18", + "use": "verify", "alg": "EdDSA", "crv": "Ed25519", "x": "bDgK1223JbrDNePFIrj7b0z02Z5nSiBzkRYRqDdVPfA" diff --git a/src/util/jwks.ts b/src/util/jwks.ts index 6c2bb87..bd21b6a 100644 --- a/src/util/jwks.ts +++ b/src/util/jwks.ts @@ -1,154 +1,91 @@ import axios from 'axios' -import mockJwks from '../resource/mock-jwks.json' import { JwksConfig } from '../types' +import { Cache } from './cache' import { DEFAULT_JWKS_CACHE_DURATION_MS, DEFAULT_JWKS_TIMEOUT_MS, - MAX_CACHE_DURATION_MS, } from './constants' -// import { toBase64Url } from './base64' - -export interface JwksKey { - kty: string +interface JwksKey { + kty: 'OKP' kid: string - use: string - alg: string - crv?: string - x?: string // For Ed25519 keys - y?: string // For EC keys - n?: string // For RSA keys - e?: string // For RSA keys + use: 'sig' | 'verify' + alg: 'EdDSA' + crv: 'Ed25519' + x: string } -// TODO: use actual cached JWKS -let cachedJwks = mockJwks -let lastFetchTime = 0 - -export const fetchJwks = async (config: JwksConfig): Promise => { - const { - url, - timeoutMs = DEFAULT_JWKS_TIMEOUT_MS, - cacheDurationMs = DEFAULT_JWKS_CACHE_DURATION_MS, - } = config +interface JwksResponse { + keys: JwksKey[] +} - // Enforce maximum cache duration to avoid potential issues - const safeCacheDuration = Math.min(cacheDurationMs, MAX_CACHE_DURATION_MS) +let jwksCache: Cache | null = null +let jwksConfig: JwksConfig | null = null - // Check cache validity - if (lastFetchTime && Date.now() - lastFetchTime < safeCacheDuration) { - return +/** + * Convert base64url to standard base64 + * Spec: https://tools.ietf.org/html/rfc4648#section-5 + */ +const base64UrlToBase64 = (base64url: string): string => { + // Convert URL-safe characters back to standard base64 characters + const converted = base64url.replace(/-/g, '+').replace(/_/g, '/') + + // Add padding if necessary + const pad = converted.length % 4 + if (pad) { + return converted + '='.repeat(4 - pad) } + return converted +} - try { - const { data } = await axios.get(url, { timeout: timeoutMs }) - cachedJwks = data - lastFetchTime = Date.now() - } catch (error) { - console.warn('Failed to fetch JWKS, falling back to mock JWKS:', error) +const findKeyByUse = (jwks: JwksResponse, use: 'sig' | 'verify'): string => { + const key = jwks.keys.find((k) => k.use === use) + if (!key) { + throw new Error(`No key with use="${use}" found in JWKS`) } + return base64UrlToBase64(key.x) } -export const getKeyFromJwks = ( - kid: string, - jwks: { keys: JwksKey[] } -): string | null => { - const key = jwks.keys.find((k) => k.kid === kid) - if (!key) return null +const getJwks = async (): Promise => { + if (!jwksConfig) throw new Error('JWKS not initialized - call initJwks first') - // Handle Ed25519 keys - a type of Edwards-curve key - if (key.kty === 'OKP' && key.crv === 'Ed25519' && key.x) { - const base64 = key.x.replace(/-/g, '+').replace(/_/g, '/') - return base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), '=') - } + const cached = jwksCache?.get() + if (cached) return cached - // Handle EC keys (like P-256, P-384, P-521) - if (key.kty === 'EC') { - // EC keys require both x and y coordinates - if (!key.x || !key.y) return null - // Return concatenated x and y coordinates - return key.x + key.y + if (!jwksCache) { + jwksCache = new Cache( + jwksConfig.cacheDurationMs ?? DEFAULT_JWKS_CACHE_DURATION_MS + ) } - // Handle RSA keys - if (key.kty === 'RSA') { - return key.n ?? null + try { + const { data } = await axios.get(jwksConfig.url, { + timeout: jwksConfig.timeoutMs ?? DEFAULT_JWKS_TIMEOUT_MS, + }) + jwksCache.set(data) + return data + } catch (error) { + throw new Error( + `Failed to fetch JWKS: ${ + error instanceof Error ? error.message : 'Unknown error' + }` + ) } - - return null } -export const getSigningPublicKeyFromJwks = (): string => { - const signingKid = 'signing-d26b11d1-4a03-40df-9b88-2234eac30ef7' - const key = getKeyFromJwks(signingKid, cachedJwks) - if (!key) throw new Error(`Unable to find signing key with kid=${signingKid}`) - return key +export const initJwks = (config: JwksConfig): void => { + jwksConfig = config + jwksCache = null } -export const getVerificationPublicKeyFromJwks = (): string => { - const verificationKid = 'verification-09305bf4-b4da-469b-b502-afe318ac2a18' - const key = getKeyFromJwks(verificationKid, cachedJwks) - if (!key) - throw new Error( - `Unable to find verification key with kid=${verificationKid}` - ) - return key +export const getSigningPublicKeyFromJwks = async (): Promise => { + const jwks = await getJwks() + return findKeyByUse(jwks, 'sig') } -// Example function to help create JWKS entries -// export const createEd25519JwksKey = ( -// kid: string, -// base64Key: string -// ): JwksKey => { -// return { -// kty: 'OKP', -// kid, -// use: 'sig', -// alg: 'EdDSA', -// crv: 'Ed25519', -// x: toBase64Url(base64Key), -// } -// } - -// // Example function to help create EC JWKS keys -// export const createEcJwksKey = ( -// kid: string, -// x: string, -// y: string, -// curve: 'P-256' | 'P-384' | 'P-521' = 'P-256' -// ): JwksKey => { -// return { -// kty: 'EC', -// kid, -// use: 'sig', -// alg: 'ES256', -// crv: curve, -// x: toBase64Url(x), -// y: toBase64Url(y), -// } -// } - -/** - * Example usage: - * const key = createEd25519JwksKey( - * 'signing-key-1', - * 'rjv41kYqZwcbe3r6ymMEEKQ+Vd+DPuogN+Gzq3lP2Og=' - * ) - * // Result: - * // { - * // kty: 'OKP', - * // kid: 'signing-key-1', - * // use: 'sig', - * // alg: 'EdDSA', - * // crv: 'Ed25519', - * // x: 'rjv41kYqZwcbe3r6ymMEEKQ-Vd-DPuogN-Gzq3lP2Og' - * // } - */ - -/** - * Note: Ed25519 is a specific Edwards curve designed for digital signatures - * while EC keys typically use NIST curves like P-256. They have different - * characteristics and security properties. - */ +export const getVerificationPublicKeyFromJwks = async (): Promise => { + const jwks = await getJwks() + return findKeyByUse(jwks, 'verify') +} diff --git a/src/util/keys.ts b/src/util/keys.ts index 1c0aaae..85d0a23 100644 --- a/src/util/keys.ts +++ b/src/util/keys.ts @@ -1,37 +1,37 @@ import { JwksConfig, PackageMode } from '../types' import { - fetchJwks, getSigningPublicKeyFromJwks, getVerificationPublicKeyFromJwks, + initJwks, } from './jwks' -import { - getSigningPublicKey, - getVerificationPublicKey, - initKeyCaches, -} from './publicKey' - -export async function getPublicKeys(jwks?: JwksConfig, mode?: PackageMode) { - initKeyCaches(jwks) +import { getSigningPublicKey, getVerificationPublicKey } from './publicKey' - if (!jwks?.url) { - return { - signingPublicKey: () => getSigningPublicKey(mode), - verificationPublicKey: () => getVerificationPublicKey(mode), - } +export const getPublicKeys = async (jwks?: JwksConfig, mode?: PackageMode) => { + if (jwks?.url) { + initJwks(jwks) } - try { - await fetchJwks(jwks) - return { - signingPublicKey: () => getSigningPublicKeyFromJwks(), - verificationPublicKey: () => getVerificationPublicKeyFromJwks(), - } - } catch (error) { - console.warn('Falling back to static public keys:', error) - return { - signingPublicKey: () => getSigningPublicKey(mode), - verificationPublicKey: () => getVerificationPublicKey(mode), - } + return { + signingPublicKey: async () => { + if (jwks?.url) { + try { + return await getSigningPublicKeyFromJwks() + } catch (error) { + console.warn('Failed to get signing key from JWKS:', error) + } + } + return getSigningPublicKey(mode) + }, + verificationPublicKey: async () => { + if (jwks?.url) { + try { + return await getVerificationPublicKeyFromJwks() + } catch (error) { + console.warn('Failed to get verification key from JWKS:', error) + } + } + return getVerificationPublicKey(mode) + }, } } diff --git a/src/util/publicKey.ts b/src/util/publicKey.ts index 6453b92..5679413 100644 --- a/src/util/publicKey.ts +++ b/src/util/publicKey.ts @@ -1,68 +1,37 @@ import { SIGNING_KEYS } from '../resource/signing-keys' import { VERIFICATION_KEYS } from '../resource/verification-keys' -import { JwksConfig, PackageMode } from '../types' +import { PackageMode } from '../types' -import { Cache } from './cache' -import { DEFAULT_JWKS_CACHE_DURATION_MS } from './constants' import STAGE from './stage' -const createKeyCache = (config?: JwksConfig) => { - const duration = config?.cacheDurationMs ?? DEFAULT_JWKS_CACHE_DURATION_MS - return new Cache(duration) -} - -let signingKeyCache: Cache -let verificationKeyCache: Cache - -export function initKeyCaches(config?: JwksConfig) { - signingKeyCache = createKeyCache(config) - verificationKeyCache = createKeyCache(config) -} - /** - * Gets the signing public key with caching + * Gets the signing public key */ -export function getSigningPublicKey(mode?: PackageMode): string { - const cached = signingKeyCache.get() - if (cached) return cached - - const key = (() => { - switch (mode) { - case STAGE.development: - return SIGNING_KEYS.development.publicKey - case STAGE.staging: - return SIGNING_KEYS.staging.publicKey - case STAGE.test: - return SIGNING_KEYS.test.publicKey - default: - return SIGNING_KEYS.production.publicKey - } - })() - - signingKeyCache.set(key) - return key +export const getSigningPublicKey = (mode?: PackageMode): string => { + switch (mode) { + case STAGE.development: + return SIGNING_KEYS.development.publicKey + case STAGE.staging: + return SIGNING_KEYS.staging.publicKey + case STAGE.test: + return SIGNING_KEYS.test.publicKey + default: + return SIGNING_KEYS.production.publicKey + } } /** - * Gets the verification public key with caching + * Gets the verification public key */ -export function getVerificationPublicKey(mode?: PackageMode): string { - const cached = verificationKeyCache.get() - if (cached) return cached - - const key = (() => { - switch (mode) { - case STAGE.development: - return VERIFICATION_KEYS.development.publicKey - case STAGE.staging: - return VERIFICATION_KEYS.staging.publicKey - case STAGE.test: - return VERIFICATION_KEYS.test.publicKey - default: - return VERIFICATION_KEYS.production.publicKey - } - })() - - verificationKeyCache.set(key) - return key +export const getVerificationPublicKey = (mode?: PackageMode): string => { + switch (mode) { + case STAGE.development: + return VERIFICATION_KEYS.development.publicKey + case STAGE.staging: + return VERIFICATION_KEYS.staging.publicKey + case STAGE.test: + return VERIFICATION_KEYS.test.publicKey + default: + return VERIFICATION_KEYS.production.publicKey + } } diff --git a/src/verification/index.ts b/src/verification/index.ts index 82fee16..6e1bebe 100644 --- a/src/verification/index.ts +++ b/src/verification/index.ts @@ -15,7 +15,7 @@ import { parseVerificationSignature } from '../util/parser' import { formatToBaseString, isSignatureTimeValid } from './utils' export default class Verification { - getVerificationPublicKey: () => string + getVerificationPublicKey: () => Promise verificationSecretKey?: string transactionExpiry?: number @@ -24,7 +24,7 @@ export default class Verification { secretKey, transactionExpiry, }: { - getVerificationPublicKey: () => string + getVerificationPublicKey: () => Promise secretKey?: string transactionExpiry?: number }) { @@ -42,7 +42,7 @@ export default class Verification { * @param {string} data.answer * @param {string} data.publicKey */ - authenticate = ({ + authenticate = async ({ signatureString, submissionCreatedAt, fieldId, @@ -54,7 +54,7 @@ export default class Verification { ) } - const verificationPublicKey = this.getVerificationPublicKey() + const verificationPublicKey = await this.getVerificationPublicKey() if (!verificationPublicKey) { throw new MissingPublicKeyError() } diff --git a/src/webhooks.ts b/src/webhooks.ts index 43b625f..666fde2 100644 --- a/src/webhooks.ts +++ b/src/webhooks.ts @@ -6,14 +6,14 @@ import { hasEpochExpired, isSignatureHeaderValid } from './util/webhooks' import { MissingSecretKeyError, WebhookAuthenticateError } from './errors' export default class Webhooks { - getPublicKey: () => string + getPublicKey: () => Promise secretKey?: string constructor({ getPublicKey, secretKey, }: { - getPublicKey: () => string + getPublicKey: () => Promise secretKey?: string }) { this.getPublicKey = getPublicKey @@ -27,7 +27,7 @@ export default class Webhooks { * @returns true if the header is verified * @throws {WebhookAuthenticateError} If the signature or uri cannot be verified */ - authenticate = (header: string, uri: string) => { + authenticate = async (header: string, uri: string) => { // Parse the header const signatureHeader = parseSignatureHeader(header) const { @@ -38,7 +38,8 @@ export default class Webhooks { } = signatureHeader // Get fresh public key on each signature verification - if (!isSignatureHeaderValid(uri, signatureHeader, this.getPublicKey())) { + const publicKey = await this.getPublicKey() + if (!isSignatureHeaderValid(uri, signatureHeader, publicKey)) { throw new WebhookAuthenticateError( `Signature could not be verified for uri=${uri} submissionId=${submissionId} formId=${formId} epoch=${epoch} signature=${signature}` ) From cec90c86f6b67b2423843b38d274cd0b898aa0a1 Mon Sep 17 00:00:00 2001 From: littlemight Date: Sat, 15 Feb 2025 01:05:11 +0800 Subject: [PATCH 09/38] chore: meaningful kid --- src/resource/mock-jwks.json | 4 ++-- src/resource/verification-keys.ts | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/resource/mock-jwks.json b/src/resource/mock-jwks.json index 787f06b..3dd555a 100644 --- a/src/resource/mock-jwks.json +++ b/src/resource/mock-jwks.json @@ -2,7 +2,7 @@ "keys": [ { "kty": "OKP", - "kid": "signing-webhook-d26b11d1-4a03-40df-9b88-2234eac30ef7", + "kid": "signing-webhook-key-staging", "use": "sig", "alg": "EdDSA", "crv": "Ed25519", @@ -10,7 +10,7 @@ }, { "kty": "OKP", - "kid": "signing-otp-09305bf4-b4da-469b-b502-afe318ac2a18", + "kid": "signing-otp-key-staging", "use": "verify", "alg": "EdDSA", "crv": "Ed25519", diff --git a/src/resource/verification-keys.ts b/src/resource/verification-keys.ts index 5c5b99b..3eadff8 100644 --- a/src/resource/verification-keys.ts +++ b/src/resource/verification-keys.ts @@ -6,7 +6,8 @@ export const VERIFICATION_KEYS = { }, development: { publicKey: 'SZ4pV0JXgj8dhFU69uHllqYcxTtliYmi+d6Ml56lnQU=', - secretKey: 'iGkfOuI6uxrlfw+7CZFFUZBwk86I+pu6v+g7EWA6qJpJnilXQleCPx2EVTr24eWWphzFO2WJiaL53oyXnqWdBQ==', + secretKey: + 'iGkfOuI6uxrlfw+7CZFFUZBwk86I+pu6v+g7EWA6qJpJnilXQleCPx2EVTr24eWWphzFO2WJiaL53oyXnqWdBQ==', }, production: { // production must never contain secret keys From c0585d7888d60cbd0b4de04078032d5be19a4799 Mon Sep 17 00:00:00 2001 From: littlemight Date: Sat, 15 Feb 2025 01:12:42 +0800 Subject: [PATCH 10/38] refactor: group util tests in same folder --- spec/{util.spec.ts => util/crypto.spec.ts} | 2 +- src/util/jwks.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename spec/{util.spec.ts => util/crypto.spec.ts} (93%) diff --git a/spec/util.spec.ts b/spec/util/crypto.spec.ts similarity index 93% rename from spec/util.spec.ts rename to spec/util/crypto.spec.ts index 34636e0..579ab18 100644 --- a/spec/util.spec.ts +++ b/spec/util/crypto.spec.ts @@ -1,4 +1,4 @@ -import { areAttachmentFieldIdsValid } from '../src/util/crypto' +import { areAttachmentFieldIdsValid } from '../../src/util/crypto' describe('utils', () => { describe('areAttachmentFieldIdsValid', () => { it('should return true when all the fieldIds are within the filenames', () => { diff --git a/src/util/jwks.ts b/src/util/jwks.ts index bd21b6a..9ead8b1 100644 --- a/src/util/jwks.ts +++ b/src/util/jwks.ts @@ -49,7 +49,7 @@ const findKeyByUse = (jwks: JwksResponse, use: 'sig' | 'verify'): string => { } const getJwks = async (): Promise => { - if (!jwksConfig) throw new Error('JWKS not initialized - call initJwks first') + if (!jwksConfig) throw new Error('JWKS not initialized') const cached = jwksCache?.get() if (cached) return cached From f4ca1ca18be145f7fc6b84fb87a1c4cd26e6b42b Mon Sep 17 00:00:00 2001 From: littlemight Date: Sat, 15 Feb 2025 01:34:55 +0800 Subject: [PATCH 11/38] test: jwks util --- spec/util/jwks.spec.ts | 133 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 spec/util/jwks.spec.ts diff --git a/spec/util/jwks.spec.ts b/spec/util/jwks.spec.ts new file mode 100644 index 0000000..a12539d --- /dev/null +++ b/spec/util/jwks.spec.ts @@ -0,0 +1,133 @@ +import axios from 'axios' +import { + initJwks, + getSigningPublicKeyFromJwks, + getVerificationPublicKeyFromJwks, +} from '../../src/util/jwks' +import { DEFAULT_JWKS_TIMEOUT_MS } from '../../src/util/constants' + +jest.mock('axios') +const mockedAxios = axios as jest.Mocked + +describe('jwks', () => { + const MOCK_JWKS_URL = 'https://test.example.com/.well-known/jwks.json' + const MOCK_JWKS_RESPONSE = { + keys: [ + { + kty: 'OKP', + kid: '1', + use: 'sig', + alg: 'EdDSA', + crv: 'Ed25519', + x: 'abc-123_test', // this will be converted from base64url to base64 + }, + { + kty: 'OKP', + kid: '2', + use: 'verify', + alg: 'EdDSA', + crv: 'Ed25519', + x: 'def-456_test', // this will be converted from base64url to base64 + }, + ], + } + + beforeEach(() => { + jest.clearAllMocks() + jest.useFakeTimers() + initJwks({ url: MOCK_JWKS_URL }) + }) + + it('should fetch and return signing public key', async () => { + mockedAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) + + const result = await getSigningPublicKeyFromJwks() + + expect(mockedAxios.get).toHaveBeenCalledWith(MOCK_JWKS_URL, { + timeout: DEFAULT_JWKS_TIMEOUT_MS, + }) + expect(result).toBe('abc+123/test') // converted from base64url to base64 + }) + + it('should fetch and return verification public key', async () => { + mockedAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) + + const result = await getVerificationPublicKeyFromJwks() + + expect(result).toBe('def+456/test') // converted from base64url to base64 + }) + + it('should respect custom timeout', async () => { + const customTimeout = 5000 + initJwks({ url: MOCK_JWKS_URL, timeoutMs: customTimeout }) + mockedAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) + + await getSigningPublicKeyFromJwks() + + expect(mockedAxios.get).toHaveBeenCalledWith(MOCK_JWKS_URL, { + timeout: customTimeout, + }) + }) + + it('should respect custom cache duration', async () => { + const customDuration = 2000 + initJwks({ url: MOCK_JWKS_URL, cacheDurationMs: customDuration }) + mockedAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) + + await getSigningPublicKeyFromJwks() + + jest.advanceTimersByTime(customDuration + 100) + + mockedAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) + await getSigningPublicKeyFromJwks() + + expect(mockedAxios.get).toHaveBeenCalledTimes(2) + }) + + it('should use cache for subsequent requests', async () => { + initJwks({ url: MOCK_JWKS_URL, cacheDurationMs: 20000 }) + mockedAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) + + await getSigningPublicKeyFromJwks() + await getSigningPublicKeyFromJwks() + await getSigningPublicKeyFromJwks() + + expect(mockedAxios.get).toHaveBeenCalledTimes(1) + }) + + it('should throw error if JWKS not initialized', async () => { + initJwks(null as any) + + await expect(getSigningPublicKeyFromJwks()).rejects.toThrow( + 'JWKS not initialized' + ) + }) + + it('should throw error if key not found', async () => { + mockedAxios.get.mockResolvedValueOnce({ + data: { keys: [{ use: 'other' }] }, + }) + + await expect(getSigningPublicKeyFromJwks()).rejects.toThrow( + 'No key with use="sig" found in JWKS' + ) + }) + + it('should throw error on network failure', async () => { + mockedAxios.get.mockRejectedValueOnce(new Error('Network error')) + + await expect(getSigningPublicKeyFromJwks()).rejects.toThrow( + 'Failed to fetch JWKS: Network error' + ) + }) + + it('should throw error when request times out', async () => { + const timeoutError = new Error('timeout of 5000ms exceeded') + timeoutError.name = 'TimeoutError' + mockedAxios.get.mockRejectedValueOnce(timeoutError) + + await expect(getSigningPublicKeyFromJwks()).rejects.toThrow( + 'Failed to fetch JWKS: timeout of 5000ms exceeded' + ) + }) +}) From 36707f462a8f630d4c60ff356739e2115f476a94 Mon Sep 17 00:00:00 2001 From: littlemight Date: Sat, 15 Feb 2025 01:50:42 +0800 Subject: [PATCH 12/38] test: init spec failing --- spec/init.spec.ts | 31 ++++++++++++++----------------- src/crypto.ts | 26 +++++++++++++++----------- 2 files changed, 29 insertions(+), 28 deletions(-) diff --git a/spec/init.spec.ts b/spec/init.spec.ts index 43e84d8..59dbe37 100644 --- a/spec/init.spec.ts +++ b/spec/init.spec.ts @@ -8,31 +8,29 @@ import { describe('FormSG SDK', () => { describe('Initialisation', () => { - it('should be able to initialise without arguments', () => { - const sdk = formsg() - // Should be autopopulated with production public keys. - expect(sdk.crypto.signingPublicKey).toEqual( - SIGNING_KEYS.production.publicKey - ) - expect(sdk.verification.verificationPublicKey).toEqual( - VERIFICATION_KEYS.production.publicKey - ) - expect(sdk.webhooks.publicKey).toEqual(SIGNING_KEYS.production.publicKey) + it('should be able to initialise without arguments', async () => { + const sdk = await formsg() + const signingKey = await sdk.crypto.getSigningPublicKey() + const verificationKey = await sdk.verification.getVerificationPublicKey() + const webhooksKey = await sdk.webhooks.getPublicKey() + + expect(signingKey).toEqual(SIGNING_KEYS.production.publicKey) + expect(verificationKey).toEqual(VERIFICATION_KEYS.production.publicKey) + expect(webhooksKey).toEqual(SIGNING_KEYS.production.publicKey) }) it('should correctly assign given webhook signing key', async () => { const mockSecretKey = 'mock secret key' - const sdk = formsg({ + const sdk = await formsg({ webhookSecretKey: mockSecretKey, }) expect(sdk.webhooks.secretKey).toEqual(mockSecretKey) }) - it('should be able to initialise with valid verification options', () => { - // Arrange + it('should be able to initialise with valid verification options', async () => { const TEST_TRANSACTION_EXPIRY = 10000 - const sdk = formsg({ + const sdk = await formsg({ mode: 'test', verificationOptions: { secretKey: VERIFICATION_KEYS.test.secretKey, @@ -40,9 +38,8 @@ describe('FormSG SDK', () => { }, }) - expect(sdk.verification.verificationPublicKey).toEqual( - VERIFICATION_KEYS.test.publicKey - ) + const verificationKey = await sdk.verification.getVerificationPublicKey() + expect(verificationKey).toEqual(VERIFICATION_KEYS.test.publicKey) expect(sdk.verification.verificationSecretKey).toEqual( VERIFICATION_KEYS.test.secretKey ) diff --git a/src/crypto.ts b/src/crypto.ts index 4149fab..02db0f0 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -133,20 +133,24 @@ export default class Crypto extends CryptoBase { * Returns true if a pair of public & secret keys are associated with each other * @param publicKey The public key to verify against. * @param secretKey The private key to verify against. + * @returns A promise that resolves to true if the keys are valid, false otherwise. */ - valid = (publicKey: string, secretKey: string) => { - const testResponse: FormField[] = [] - const internalValidationVersion = 1 - - const cipherResponse = this.encrypt(testResponse, publicKey) - // Use toString here since the return should be an empty array. - return ( - testResponse.toString() === - this.decrypt(secretKey, { + valid = async (publicKey: string, secretKey: string): Promise => { + try { + const testResponse: FormField[] = [] + const internalValidationVersion = 1 + + const cipherResponse = this.encrypt(testResponse, publicKey) + const decryptedResponse = await this.decrypt(secretKey, { encryptedContent: cipherResponse, version: internalValidationVersion, - })?.responses.toString() - ) + }) + + // Use toString here since the return should be an empty array. + return decryptedResponse?.responses.toString() === testResponse.toString() + } catch { + return false + } } /** From 1735e43f001b729e80ef20bea903e9979e446849 Mon Sep 17 00:00:00 2001 From: littlemight Date: Sat, 15 Feb 2025 03:19:33 +0800 Subject: [PATCH 13/38] feat: fetch jwks during init --- spec/util/jwks.spec.ts | 75 +++++++++++++++++++++++++++++++++++------- src/types.ts | 2 ++ src/util/jwks.ts | 13 +++++++- src/util/keys.ts | 2 +- 4 files changed, 78 insertions(+), 14 deletions(-) diff --git a/spec/util/jwks.spec.ts b/spec/util/jwks.spec.ts index a12539d..383dc65 100644 --- a/spec/util/jwks.spec.ts +++ b/spec/util/jwks.spec.ts @@ -35,12 +35,53 @@ describe('jwks', () => { beforeEach(() => { jest.clearAllMocks() jest.useFakeTimers() - initJwks({ url: MOCK_JWKS_URL }) + }) + + describe('initialization', () => { + it('should not pre-fetch JWKS when loadOnInit is explicitly false', async () => { + await initJwks({ url: MOCK_JWKS_URL, loadOnInit: false }) + expect(mockedAxios.get).not.toHaveBeenCalled() + }) + + it('should pre-fetch JWKS by default when loadOnInit is undefined', async () => { + mockedAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) + await initJwks({ url: MOCK_JWKS_URL }) + + expect(mockedAxios.get).toHaveBeenCalledWith(MOCK_JWKS_URL, { + timeout: DEFAULT_JWKS_TIMEOUT_MS, + }) + }) + + it('should pre-fetch JWKS when loadOnInit is true', async () => { + mockedAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) + await initJwks({ url: MOCK_JWKS_URL, loadOnInit: true }) + + expect(mockedAxios.get).toHaveBeenCalledWith(MOCK_JWKS_URL, { + timeout: DEFAULT_JWKS_TIMEOUT_MS, + }) + }) + + it('should not throw if pre-fetch fails during initialization', async () => { + mockedAxios.get.mockRejectedValueOnce(new Error('Network error')) + + await expect(initJwks({ url: MOCK_JWKS_URL })).resolves.not.toThrow() + }) + + it('should reset cache when reinitializing', async () => { + mockedAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) + await initJwks({ url: MOCK_JWKS_URL }) + + // Second initialization should trigger new fetch + mockedAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) + await initJwks({ url: MOCK_JWKS_URL }) + + expect(mockedAxios.get).toHaveBeenCalledTimes(2) + }) }) it('should fetch and return signing public key', async () => { mockedAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) - + await initJwks({ url: MOCK_JWKS_URL }) const result = await getSigningPublicKeyFromJwks() expect(mockedAxios.get).toHaveBeenCalledWith(MOCK_JWKS_URL, { @@ -51,18 +92,16 @@ describe('jwks', () => { it('should fetch and return verification public key', async () => { mockedAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) + await initJwks({ url: MOCK_JWKS_URL }) const result = await getVerificationPublicKeyFromJwks() - expect(result).toBe('def+456/test') // converted from base64url to base64 }) it('should respect custom timeout', async () => { const customTimeout = 5000 - initJwks({ url: MOCK_JWKS_URL, timeoutMs: customTimeout }) mockedAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) - - await getSigningPublicKeyFromJwks() + await initJwks({ url: MOCK_JWKS_URL, timeoutMs: customTimeout }) expect(mockedAxios.get).toHaveBeenCalledWith(MOCK_JWKS_URL, { timeout: customTimeout, @@ -71,11 +110,11 @@ describe('jwks', () => { it('should respect custom cache duration', async () => { const customDuration = 2000 - initJwks({ url: MOCK_JWKS_URL, cacheDurationMs: customDuration }) mockedAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) + await initJwks({ url: MOCK_JWKS_URL, cacheDurationMs: customDuration }) + // Should not call, as still cached await getSigningPublicKeyFromJwks() - jest.advanceTimersByTime(customDuration + 100) mockedAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) @@ -85,9 +124,10 @@ describe('jwks', () => { }) it('should use cache for subsequent requests', async () => { - initJwks({ url: MOCK_JWKS_URL, cacheDurationMs: 20000 }) mockedAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) + await initJwks({ url: MOCK_JWKS_URL, cacheDurationMs: 20000 }) + // These should use the cache from initialization await getSigningPublicKeyFromJwks() await getSigningPublicKeyFromJwks() await getSigningPublicKeyFromJwks() @@ -96,8 +136,7 @@ describe('jwks', () => { }) it('should throw error if JWKS not initialized', async () => { - initJwks(null as any) - + await initJwks(null as any) await expect(getSigningPublicKeyFromJwks()).rejects.toThrow( 'JWKS not initialized' ) @@ -107,7 +146,7 @@ describe('jwks', () => { mockedAxios.get.mockResolvedValueOnce({ data: { keys: [{ use: 'other' }] }, }) - + await initJwks({ url: MOCK_JWKS_URL }) await expect(getSigningPublicKeyFromJwks()).rejects.toThrow( 'No key with use="sig" found in JWKS' ) @@ -115,7 +154,10 @@ describe('jwks', () => { it('should throw error on network failure', async () => { mockedAxios.get.mockRejectedValueOnce(new Error('Network error')) + await initJwks({ url: MOCK_JWKS_URL }) + // Should still fail on subsequent request + mockedAxios.get.mockRejectedValueOnce(new Error('Network error')) await expect(getSigningPublicKeyFromJwks()).rejects.toThrow( 'Failed to fetch JWKS: Network error' ) @@ -124,10 +166,19 @@ describe('jwks', () => { it('should throw error when request times out', async () => { const timeoutError = new Error('timeout of 5000ms exceeded') timeoutError.name = 'TimeoutError' + + // Initialize with timeout error mockedAxios.get.mockRejectedValueOnce(timeoutError) + await initJwks({ url: MOCK_JWKS_URL }) + // Should still fail on subsequent request + mockedAxios.get.mockRejectedValueOnce(timeoutError) await expect(getSigningPublicKeyFromJwks()).rejects.toThrow( 'Failed to fetch JWKS: timeout of 5000ms exceeded' ) }) + + afterEach(() => { + jest.useRealTimers() + }) }) diff --git a/src/types.ts b/src/types.ts index 356397a..08b8065 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,6 +5,8 @@ export type JwksConfig = { timeoutMs?: number /** Duration in milliseconds to cache JWKS. Defaults to 3600000ms (1 hour) */ cacheDurationMs?: number + /** Whether to load JWKS during initialization. Defaults to true */ + loadOnInit?: boolean } export type PackageInitParams = { diff --git a/src/util/jwks.ts b/src/util/jwks.ts index 9ead8b1..d9d80fb 100644 --- a/src/util/jwks.ts +++ b/src/util/jwks.ts @@ -75,9 +75,20 @@ const getJwks = async (): Promise => { } } -export const initJwks = (config: JwksConfig): void => { +export const initJwks = async (config: JwksConfig): Promise => { jwksConfig = config jwksCache = null + + if (!jwksConfig) return + + // Default to true if not specified + if (jwksConfig.loadOnInit !== false) { + try { + await getJwks() + } catch (error) { + console.warn('Failed to pre-fetch JWKS during initialization:', error) + } + } } export const getSigningPublicKeyFromJwks = async (): Promise => { diff --git a/src/util/keys.ts b/src/util/keys.ts index 85d0a23..3e2ff83 100644 --- a/src/util/keys.ts +++ b/src/util/keys.ts @@ -9,7 +9,7 @@ import { getSigningPublicKey, getVerificationPublicKey } from './publicKey' export const getPublicKeys = async (jwks?: JwksConfig, mode?: PackageMode) => { if (jwks?.url) { - initJwks(jwks) + await initJwks(jwks) } return { From 8dd5e6a016d96f3f1f220e9ac306f323e567b14c Mon Sep 17 00:00:00 2001 From: littlemight Date: Sat, 15 Feb 2025 03:23:50 +0800 Subject: [PATCH 14/38] test: init SDK with JWKS --- spec/init.spec.ts | 69 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/spec/init.spec.ts b/spec/init.spec.ts index 59dbe37..5439e81 100644 --- a/spec/init.spec.ts +++ b/spec/init.spec.ts @@ -1,3 +1,4 @@ +import axios from 'axios' import formsg from '../src/index' import { SIGNING_KEYS } from '../src/resource/signing-keys' import { VERIFICATION_KEYS } from '../src/resource/verification-keys' @@ -6,7 +7,14 @@ import { getVerificationPublicKey, } from '../src/util/publicKey' +jest.mock('axios') +const mockedAxios = axios as jest.Mocked + describe('FormSG SDK', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + describe('Initialisation', () => { it('should be able to initialise without arguments', async () => { const sdk = await formsg() @@ -82,4 +90,65 @@ describe('FormSG SDK', () => { expect(getSigningPublicKey()).toBe(SIGNING_KEYS.production.publicKey) }) }) + + describe('JWKS Initialization', () => { + const MOCK_JWKS_URL = 'https://test-jwks-endpoint.com/.well-known/jwks.json' + const MOCK_JWKS_RESPONSE = { + keys: [ + { + kty: 'OKP', + kid: 'sig-1', + use: 'sig', + alg: 'EdDSA', + crv: 'Ed25519', + x: 'mock-signing-key', // this will be converted from base64url to base64 + }, + { + kty: 'OKP', + kid: 'verify-1', + use: 'verify', + alg: 'EdDSA', + crv: 'Ed25519', + x: 'mock-verification-key', // this will be converted from base64url to base64 + }, + ], + } + + it('should initialize with JWKS configuration', async () => { + mockedAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) + + const sdk = await formsg({ + jwks: { + url: MOCK_JWKS_URL, + }, + }) + + const signingKey = await sdk.crypto.getSigningPublicKey() + const verificationKey = await sdk.verification.getVerificationPublicKey() + + expect(mockedAxios.get).toHaveBeenCalledWith( + MOCK_JWKS_URL, + expect.any(Object) + ) + expect(signingKey).toBe('mock+signing+key') + expect(verificationKey).toBe('mock+verification+key===') + }) + + it('should fallback to static keys when JWKS endpoint fails', async () => { + mockedAxios.get.mockRejectedValueOnce(new Error('JWKS fetch failed')) + + const sdk = await formsg({ + mode: 'production', + jwks: { + url: MOCK_JWKS_URL, + }, + }) + + const signingKey = await sdk.crypto.getSigningPublicKey() + const verificationKey = await sdk.verification.getVerificationPublicKey() + + expect(signingKey).toBe(SIGNING_KEYS.production.publicKey) + expect(verificationKey).toBe(VERIFICATION_KEYS.production.publicKey) + }) + }) }) From 86556b8e49ceeef434461400faaba3e7b1c44b6f Mon Sep 17 00:00:00 2001 From: littlemight Date: Sat, 15 Feb 2025 04:15:17 +0800 Subject: [PATCH 15/38] test: webhook spec --- spec/webhooks.spec.ts | 41 +++++++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/spec/webhooks.spec.ts b/spec/webhooks.spec.ts index 44c8987..2978617 100644 --- a/spec/webhooks.spec.ts +++ b/spec/webhooks.spec.ts @@ -11,12 +11,12 @@ describe('Webhooks', () => { const formId = 'someFormId' const webhooks = new Webhooks({ - publicKey: webhooksPublicKey, + getPublicKey: () => Promise.resolve(webhooksPublicKey), secretKey: signingSecretKey, }) const webhooksNoSecret = new Webhooks({ - publicKey: webhooksPublicKey, + getPublicKey: () => Promise.resolve(webhooksPublicKey), }) /** @@ -57,33 +57,33 @@ describe('Webhooks', () => { ) }) - it('should authenticate a signature that was recently generated', () => { + it('should authenticate a signature that was recently generated', async () => { const epoch = Date.now() const signature = generateTestSignature(epoch) const header = constructTestHeader(epoch, signature) - const authentiateResult = webhooks.authenticate(header, uri) - expect(authentiateResult).toBe(true) + const authenticateResult = await webhooks.authenticate(header, uri) + expect(authenticateResult).toBe(true) }) - it('should reject signatures generated more than 5 minutes ago', () => { + it('should reject signatures generated more than 5 minutes ago', async () => { const epoch = Date.now() - 5 * 60 * 1000 - 1 const signature = generateTestSignature(epoch) const header = constructTestHeader(epoch, signature) - expect(() => webhooks.authenticate(header, uri)).toThrow( + await expect(webhooks.authenticate(header, uri)).rejects.toThrow( WebhookAuthenticateError ) }) - it('should reject invalid signature headers', () => { + it('should reject invalid signature headers', async () => { const invalidHeader = 'invalidHeader' - expect(() => webhooks.authenticate(invalidHeader, uri)).toThrow( + await expect(webhooks.authenticate(invalidHeader, uri)).rejects.toThrow( WebhookAuthenticateError ) }) - it('should reject if signature header cannot be verified', () => { + it('should reject if signature header cannot be verified', async () => { // Create valid header const epoch = Date.now() const signature = generateTestSignature(epoch) @@ -91,10 +91,11 @@ describe('Webhooks', () => { // Create a new Webhook class with a different publicKey const webhooksAlt = new Webhooks({ - publicKey: 'ReObXacwevg7CaNtg5QwvtW32S0V6md15up4szRdWUY=', + getPublicKey: () => + Promise.resolve('ReObXacwevg7CaNtg5QwvtW32S0V6md15up4szRdWUY='), }) - expect(() => webhooksAlt.authenticate(header, uri)).toThrow( + await expect(webhooksAlt.authenticate(header, uri)).rejects.toThrow( WebhookAuthenticateError ) }) @@ -176,7 +177,7 @@ describe('Webhooks', () => { ).toThrow(MissingSecretKeyError) }) - it('should reject signatures generated more than 5 minutes ago', () => { + it('should reject signatures generated more than 5 minutes ago', async () => { const epoch = Date.now() - 5 * 60 * 1000 - 1 // 5min 1s into the past const signature = webhooks.generateSignature({ uri, @@ -191,10 +192,10 @@ describe('Webhooks', () => { signature, }) as string - expect(() => webhooks.authenticate(header, uri)).toThrow() + await expect(webhooks.authenticate(header, uri)).rejects.toThrow() }) - it('should accept signatures generated within 5 minutes', () => { + it('should accept signatures generated within 5 minutes', async () => { const epoch = Date.now() - 5 * 60 * 1000 + 1000 // 4min 59s into the past const signature = webhooks.generateSignature({ uri, @@ -209,10 +210,10 @@ describe('Webhooks', () => { signature, }) as string - expect(() => webhooks.authenticate(header, uri)).not.toThrow() + await expect(webhooks.authenticate(header, uri)).resolves.not.toThrow() }) - it('should authenticate signatures if Form server drifts 4m59s into the future', () => { + it('should authenticate signatures if Form server drifts 4m59s into the future', async () => { const epoch = Date.now() + 5 * 60 * 1000 - 1000 // 4min 59s into the future const signature = webhooks.generateSignature({ uri, @@ -227,10 +228,10 @@ describe('Webhooks', () => { signature, }) as string - expect(() => webhooks.authenticate(header, uri)).not.toThrow() + await expect(webhooks.authenticate(header, uri)).resolves.not.toThrow() }) - it('should reject signatures if Form server drifts 5m1s into the future', () => { + it('should reject signatures if Form server drifts 5m1s into the future', async () => { const epoch = Date.now() + 5 * 60 * 1000 + 1000 // 5min 1s into the future const signature = webhooks.generateSignature({ uri, @@ -245,6 +246,6 @@ describe('Webhooks', () => { signature, }) as string - expect(() => webhooks.authenticate(header, uri)).toThrow() + await expect(webhooks.authenticate(header, uri)).rejects.toThrow() }) }) From 04dc5570cc4c74402340e5599f25a9f6cb62e1ce Mon Sep 17 00:00:00 2001 From: littlemight Date: Sat, 15 Feb 2025 04:21:37 +0800 Subject: [PATCH 16/38] test: crypto spec --- spec/crypto.spec.ts | 119 ++++++++++++++++++++++++++------------------ src/crypto.ts | 14 ++++-- 2 files changed, 81 insertions(+), 52 deletions(-) diff --git a/spec/crypto.spec.ts b/spec/crypto.spec.ts index 2ba29ca..f0cceb0 100644 --- a/spec/crypto.spec.ts +++ b/spec/crypto.spec.ts @@ -2,9 +2,7 @@ import mockAxios from 'jest-mock-axios' import Crypto from '../src/crypto' import { SIGNING_KEYS } from '../src/resource/signing-keys' -import { - encodeBase64, -} from 'tweetnacl-util' +import { encodeBase64 } from 'tweetnacl-util' import { plaintext, @@ -27,7 +25,9 @@ jest.mock('axios', () => mockAxios) describe('Crypto', function () { afterEach(() => mockAxios.reset()) - const crypto = new Crypto({ signingPublicKey: encryptionPublicKey }) + const crypto = new Crypto({ + getSigningPublicKey: () => Promise.resolve(encryptionPublicKey), + }) const mockVerifiedContent = { uinFin: 'S12345679Z', @@ -40,27 +40,27 @@ describe('Crypto', function () { expect(keypair).toHaveProperty('publicKey') }) - it('should generate a keypair that is valid', () => { + it('should generate a keypair that is valid', async () => { const { publicKey, secretKey } = crypto.generate() - expect(crypto.valid(publicKey, secretKey)).toBe(true) + expect(await crypto.valid(publicKey, secretKey)).toBe(true) }) - it('should validate an existing keypair', () => { - expect(crypto.valid(formPublicKey, formSecretKey)).toBe(true) + it('should validate an existing keypair', async () => { + expect(await crypto.valid(formPublicKey, formSecretKey)).toBe(true) }) - it('should invalidate unassociated keypairs', () => { + it('should invalidate unassociated keypairs', async () => { // Act const { secretKey } = crypto.generate() const { publicKey } = crypto.generate() // Assert - expect(crypto.valid(publicKey, secretKey)).toBe(false) + expect(await crypto.valid(publicKey, secretKey)).toBe(false) }) - it('should decrypt the submission ciphertext from 2020-03-22 successfully', () => { + it('should decrypt the submission ciphertext from 2020-03-22 successfully', async () => { // Act - const decrypted = crypto.decrypt(formSecretKey, { + const decrypted = await crypto.decrypt(formSecretKey, { encryptedContent: ciphertext, version: INTERNAL_TEST_VERSION, }) @@ -69,16 +69,16 @@ describe('Crypto', function () { expect(decrypted).toHaveProperty('responses', plaintext) }) - it('should return null on unsuccessful decryption', () => { + it('should return null on unsuccessful decryption', async () => { expect( - crypto.decrypt('random', { + await crypto.decrypt('random', { encryptedContent: ciphertext, version: INTERNAL_TEST_VERSION, }) ).toBe(null) }) - it('should return null when successfully decrypted content does not fit FormField type shape', () => { + it('should return null when successfully decrypted content does not fit FormField type shape', async () => { // Arrange const { publicKey, secretKey } = crypto.generate() const malformedContent = 'just a string, not an object with FormField shape' @@ -88,20 +88,20 @@ describe('Crypto', function () { // Using correct secret key, but the decrypted object should not fit the // expected shape and thus return null. expect( - crypto.decrypt(secretKey, { + await crypto.decrypt(secretKey, { encryptedContent: malformedEncrypt, version: INTERNAL_TEST_VERSION, }) ).toBe(null) }) - it('should be able to encrypt and decrypt submissions from 2020-03-22 end-to-end successfully', () => { + it('should be able to encrypt and decrypt submissions from 2020-03-22 end-to-end successfully', async () => { // Arrange const { publicKey, secretKey } = crypto.generate() // Act const ciphertext = crypto.encrypt(plaintext, publicKey) - const decrypted = crypto.decrypt(secretKey, { + const decrypted = await crypto.decrypt(secretKey, { encryptedContent: ciphertext, version: INTERNAL_TEST_VERSION, }) @@ -109,13 +109,13 @@ describe('Crypto', function () { expect(decrypted).toHaveProperty('responses', plaintext) }) - it('should be able to encrypt and decrypt multi-language submission from 2020-06-04 end-to-end successfully', () => { + it('should be able to encrypt and decrypt multi-language submission from 2020-06-04 end-to-end successfully', async () => { // Arrange const { publicKey, secretKey } = crypto.generate() // Act const ciphertext = crypto.encrypt(plaintextMultiLang, publicKey) - const decrypted = crypto.decrypt(secretKey, { + const decrypted = await crypto.decrypt(secretKey, { encryptedContent: ciphertext, version: INTERNAL_TEST_VERSION, }) @@ -123,29 +123,29 @@ describe('Crypto', function () { expect(decrypted).toHaveProperty('responses', plaintextMultiLang) }) - it('should be able to encrypt and decrypt submissions with empty field titles from 2022-11-14 end-to-end successfully', () => { + it('should be able to encrypt and decrypt submissions with empty field titles from 2022-11-14 end-to-end successfully', async () => { // Arrange const { publicKey, secretKey } = crypto.generate() // Act const ciphertext = crypto.encrypt(plaintextEmptyTitles, publicKey) - const decrypted = crypto.decrypt(secretKey, { + const decrypted = await crypto.decrypt(secretKey, { encryptedContent: ciphertext, version: INTERNAL_TEST_VERSION, }) - + // Assert expect(decrypted).toHaveProperty('responses', plaintextEmptyTitles) }) - it('should be able to encrypt submissions without signing if signingPrivateKey is missing', () => { + it('should be able to encrypt submissions without signing if signingPrivateKey is missing', async () => { // Arrange const { publicKey, secretKey } = crypto.generate() // Act // Signing key (last parameter) is omitted. const ciphertext = crypto.encrypt(plaintext, publicKey) - const decrypted = crypto.decrypt(secretKey, { + const decrypted = await crypto.decrypt(secretKey, { encryptedContent: ciphertext, version: INTERNAL_TEST_VERSION, }) @@ -154,7 +154,7 @@ describe('Crypto', function () { expect(decrypted).toHaveProperty('responses', plaintext) }) - it('should be able to encrypt and sign submissions if signingPrivateKey is given', () => { + it('should be able to encrypt and sign submissions if signingPrivateKey is given', async () => { // Arrange const { publicKey, secretKey } = crypto.generate() @@ -168,7 +168,7 @@ describe('Crypto', function () { signingSecretKey ) // Decrypt encrypted content along with our signed+encrypted content. - const decrypted = crypto.decrypt(secretKey, { + const decrypted = await crypto.decrypt(secretKey, { encryptedContent: ciphertext, verifiedContent: signedAndEncryptedText, version: INTERNAL_TEST_VERSION, @@ -213,7 +213,7 @@ describe('Crypto', function () { expect(decrypted).toBeNull() }) - it('should throw error if class was not instantiated with a public signing key while verifying decrypted content ', () => { + it('should throw error if class was not instantiated with a public signing key while verifying decrypted content ', async () => { // Arrange const cryptoNoKey = new Crypto() const { publicKey, secretKey } = cryptoNoKey.generate() @@ -231,16 +231,16 @@ describe('Crypto', function () { // Assert // Attempt to decrypt encrypted content along with our signed+encrypted // content should throw an error - expect(() => + await expect( cryptoNoKey.decrypt(secretKey, { encryptedContent: ciphertext, verifiedContent: signedAndEncryptedText, version: INTERNAL_TEST_VERSION, }) - ).toThrow(MissingPublicKeyError) + ).rejects.toThrow(MissingPublicKeyError) }) - it('should return null if decrypting encrypted verified content failed', () => { + it('should return null if decrypting encrypted verified content failed', async () => { // Arrange const { publicKey, secretKey } = crypto.generate() // Encrypt content that is not signed. @@ -249,7 +249,7 @@ describe('Crypto', function () { const rubbishVerifiedContent = 'abcdefg' // Act + Assert - const decryptResult = crypto.decrypt(secretKey, { + const decryptResult = await crypto.decrypt(secretKey, { encryptedContent: ciphertext, verifiedContent: rubbishVerifiedContent, version: INTERNAL_TEST_VERSION, @@ -277,22 +277,31 @@ describe('Crypto', function () { const uploadedFile = { submissionPublicKey: encryptedFile.submissionPublicKey, nonce: encryptedFile.nonce, - binary: encodeBase64(encryptedFile.binary) + binary: encodeBase64(encryptedFile.binary), } // Act const decryptedFilesPromise = crypto.decryptWithAttachments(secretKey, { encryptedContent: ciphertext, - attachmentDownloadUrls: { '6e771c946b3c5100240368e5': 'https://some.s3.url/some/encrypted/file' }, + attachmentDownloadUrls: { + '6e771c946b3c5100240368e5': 'https://some.s3.url/some/encrypted/file', + }, version: INTERNAL_TEST_VERSION, }) - mockAxios.mockResponse({ data: { encryptedFile: uploadedFile }}) + await Promise.resolve() // Wait for the request to be initiated + mockAxios.mockResponse({ data: { encryptedFile: uploadedFile } }) const decryptedContentWithAttachments = await decryptedFilesPromise const decryptedFiles = decryptedContentWithAttachments!.attachments // Assert - expect(mockAxios.get).toHaveBeenCalledWith('https://some.s3.url/some/encrypted/file', { responseType: 'json' }) - expect(decryptedFiles).toHaveProperty('6e771c946b3c5100240368e5', { filename: 'my-random-file.txt', content: testFileBuffer }) + expect(mockAxios.get).toHaveBeenCalledWith( + 'https://some.s3.url/some/encrypted/file', + { responseType: 'json' } + ) + expect(decryptedFiles).toHaveProperty('6e771c946b3c5100240368e5', { + filename: 'my-random-file.txt', + content: testFileBuffer, + }) }) it('should be able to handle fields without attachmentDownloadUrls', async () => { @@ -303,10 +312,13 @@ describe('Crypto', function () { const ciphertext = crypto.encrypt(plaintext, publicKey) // Act - const decryptedContentWithAttachments = await crypto.decryptWithAttachments(secretKey, { - encryptedContent: ciphertext, - version: INTERNAL_TEST_VERSION, - }) + const decryptedContentWithAttachments = await crypto.decryptWithAttachments( + secretKey, + { + encryptedContent: ciphertext, + version: INTERNAL_TEST_VERSION, + } + ) const decryptedFiles = decryptedContentWithAttachments!.attachments // Assert @@ -347,16 +359,19 @@ describe('Crypto', function () { const uploadedFile = { submissionPublicKey: encryptedFile.submissionPublicKey, nonce: encryptedFile.nonce, - binary: 'YmFkZW5jcnlwdGVkY29udGVudHM=', // invalid data + binary: 'YmFkZW5jcnlwdGVkY29udGVudHM=', // invalid data } // Act const decryptedFilesPromise = crypto.decryptWithAttachments(secretKey, { encryptedContent: ciphertext, - attachmentDownloadUrls: { '6e771c946b3c5100240368e5': 'https://some.s3.url/some/encrypted/file' }, + attachmentDownloadUrls: { + '6e771c946b3c5100240368e5': 'https://some.s3.url/some/encrypted/file', + }, version: INTERNAL_TEST_VERSION, }) - mockAxios.mockResponse({ data: { encryptedFile: uploadedFile }}) + await Promise.resolve() // Let the request be initiated + mockAxios.mockResponse({ data: { encryptedFile: uploadedFile } }) const decryptedContents = await decryptedFilesPromise // Assert @@ -376,13 +391,15 @@ describe('Crypto', function () { const uploadedFile = { submissionPublicKey: encryptedFile.submissionPublicKey, nonce: encryptedFile.nonce, - binary: encodeBase64(encryptedFile.binary) + binary: encodeBase64(encryptedFile.binary), } // Act const decryptedFilesPromise = crypto.decryptWithAttachments(secretKey, { encryptedContent: ciphertext, - attachmentDownloadUrls: { '6e771c946b3c5100240368e5': 'https://some.s3.url/some/encrypted/file' }, + attachmentDownloadUrls: { + '6e771c946b3c5100240368e5': 'https://some.s3.url/some/encrypted/file', + }, version: INTERNAL_TEST_VERSION, }) const decryptedContents = await decryptedFilesPromise @@ -409,9 +426,12 @@ describe('Crypto', function () { // Act const decryptedFilesPromise = crypto.decryptWithAttachments(secretKey, { encryptedContent: ciphertext, - attachmentDownloadUrls: { '6e771c946b3c5100240368e5': 'https://some.s3.url/some/encrypted/file' }, + attachmentDownloadUrls: { + '6e771c946b3c5100240368e5': 'https://some.s3.url/some/encrypted/file', + }, version: INTERNAL_TEST_VERSION, }) + await Promise.resolve() // Let the request be initiated mockAxios.mockResponse({ data: {}, status: 404, @@ -420,7 +440,10 @@ describe('Crypto', function () { const decryptedContents = await decryptedFilesPromise // Assert - expect(mockAxios.get).toHaveBeenCalledWith('https://some.s3.url/some/encrypted/file', { responseType: 'json' }) + expect(mockAxios.get).toHaveBeenCalledWith( + 'https://some.s3.url/some/encrypted/file', + { responseType: 'json' } + ) expect(decryptedContents).toBe(null) }) }) diff --git a/src/crypto.ts b/src/crypto.ts index 02db0f0..b7e57fc 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -24,13 +24,13 @@ import { } from './types' export default class Crypto extends CryptoBase { - getSigningPublicKey: () => Promise + getSigningPublicKey?: () => Promise constructor({ getSigningPublicKey, }: { - getSigningPublicKey: () => Promise - }) { + getSigningPublicKey?: () => Promise + } = {}) { super() this.getSigningPublicKey = getSigningPublicKey } @@ -64,7 +64,7 @@ export default class Crypto extends CryptoBase { * @param decryptParams.version The version of the payload. Used to determine the decryption process to decrypt the content with. * @param decryptParams.verifiedContent Optional. The encrypted and signed verified content. If given, the signingPublicKey will be used to attempt to open the signed message. * @returns The decrypted content if successful. Else, null will be returned. - * @throws {MissingPublicKeyError} if a public key is not provided when instantiating this class and is needed for verifying signed content. + * @throws {MissingPublicKeyError} if a public key getter is not provided when instantiating this class and is needed for verifying signed content. */ decrypt = async ( formSecretKey: string, @@ -91,6 +91,12 @@ export default class Crypto extends CryptoBase { } if (verifiedContent) { + if (!this.getSigningPublicKey) { + throw new MissingPublicKeyError( + 'Public signing key getter must be provided when instantiating the Crypto class in order to verify verified content' + ) + } + // Get fresh public key when verifying const signingPublicKey = await this.getSigningPublicKey() if (!signingPublicKey) { From ba43a2a82dc485795fa1497c8de159604f0c7d62 Mon Sep 17 00:00:00 2001 From: littlemight Date: Sat, 15 Feb 2025 11:56:06 +0800 Subject: [PATCH 17/38] fix: quirk when making public key getter optional --- spec/init.spec.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/init.spec.ts b/spec/init.spec.ts index 5439e81..2116cd8 100644 --- a/spec/init.spec.ts +++ b/spec/init.spec.ts @@ -18,7 +18,7 @@ describe('FormSG SDK', () => { describe('Initialisation', () => { it('should be able to initialise without arguments', async () => { const sdk = await formsg() - const signingKey = await sdk.crypto.getSigningPublicKey() + const signingKey = await sdk.crypto.getSigningPublicKey!() const verificationKey = await sdk.verification.getVerificationPublicKey() const webhooksKey = await sdk.webhooks.getPublicKey() @@ -123,7 +123,7 @@ describe('FormSG SDK', () => { }, }) - const signingKey = await sdk.crypto.getSigningPublicKey() + const signingKey = await sdk.crypto.getSigningPublicKey!() const verificationKey = await sdk.verification.getVerificationPublicKey() expect(mockedAxios.get).toHaveBeenCalledWith( @@ -144,7 +144,7 @@ describe('FormSG SDK', () => { }, }) - const signingKey = await sdk.crypto.getSigningPublicKey() + const signingKey = await sdk.crypto.getSigningPublicKey!() const verificationKey = await sdk.verification.getVerificationPublicKey() expect(signingKey).toBe(SIGNING_KEYS.production.publicKey) From 868f52f1f0f371cc8abcfcd69dd50d5be2d0daf6 Mon Sep 17 00:00:00 2001 From: littlemight Date: Sat, 15 Feb 2025 12:43:55 +0800 Subject: [PATCH 18/38] test: verification spec --- spec/init.spec.ts | 8 +++--- spec/verification/verification.spec.ts | 40 +++++++++++++------------- src/crypto.ts | 2 +- src/types.ts | 2 +- src/verification/index.ts | 12 ++++---- 5 files changed, 32 insertions(+), 32 deletions(-) diff --git a/spec/init.spec.ts b/spec/init.spec.ts index 2116cd8..3d3c0e6 100644 --- a/spec/init.spec.ts +++ b/spec/init.spec.ts @@ -19,7 +19,7 @@ describe('FormSG SDK', () => { it('should be able to initialise without arguments', async () => { const sdk = await formsg() const signingKey = await sdk.crypto.getSigningPublicKey!() - const verificationKey = await sdk.verification.getVerificationPublicKey() + const verificationKey = await sdk.verification.getVerificationPublicKey!() const webhooksKey = await sdk.webhooks.getPublicKey() expect(signingKey).toEqual(SIGNING_KEYS.production.publicKey) @@ -46,7 +46,7 @@ describe('FormSG SDK', () => { }, }) - const verificationKey = await sdk.verification.getVerificationPublicKey() + const verificationKey = await sdk.verification.getVerificationPublicKey!() expect(verificationKey).toEqual(VERIFICATION_KEYS.test.publicKey) expect(sdk.verification.verificationSecretKey).toEqual( VERIFICATION_KEYS.test.secretKey @@ -124,7 +124,7 @@ describe('FormSG SDK', () => { }) const signingKey = await sdk.crypto.getSigningPublicKey!() - const verificationKey = await sdk.verification.getVerificationPublicKey() + const verificationKey = await sdk.verification.getVerificationPublicKey!() expect(mockedAxios.get).toHaveBeenCalledWith( MOCK_JWKS_URL, @@ -145,7 +145,7 @@ describe('FormSG SDK', () => { }) const signingKey = await sdk.crypto.getSigningPublicKey!() - const verificationKey = await sdk.verification.getVerificationPublicKey() + const verificationKey = await sdk.verification.getVerificationPublicKey!() expect(signingKey).toBe(SIGNING_KEYS.production.publicKey) expect(verificationKey).toBe(VERIFICATION_KEYS.production.publicKey) diff --git a/spec/verification/verification.spec.ts b/spec/verification/verification.spec.ts index 02d8eea..bcb4b64 100644 --- a/spec/verification/verification.spec.ts +++ b/spec/verification/verification.spec.ts @@ -39,26 +39,28 @@ describe('Verification', () => { ) }) - it('should not authenticate if public key is not provided', () => { + it('should not authenticate if public key getter is not provided', async () => { const verification = new Verification({ - // No public key provided. + // No public key getter provided. transactionExpiry: TEST_TRANSACTION_EXPIRY, secretKey: TEST_SECRET_KEY, }) - expect(() => verification.authenticate(VALID_AUTH_PAYLOAD)).toThrow( - MissingPublicKeyError - ) + await expect( + verification.authenticate(VALID_AUTH_PAYLOAD) + ).rejects.toThrow(MissingPublicKeyError) }) - it('should not authenticate if transaction expiry is not provided', () => { + it('should not authenticate if transaction expiry is not provided', async () => { const verification = new Verification({ // No transaction expiry provided. - publicKey: TEST_PUBLIC_KEY, + getVerificationPublicKey: () => Promise.resolve(TEST_PUBLIC_KEY), secretKey: TEST_SECRET_KEY, }) - expect(() => verification.authenticate(VALID_AUTH_PAYLOAD)).toThrow( + await expect( + verification.authenticate(VALID_AUTH_PAYLOAD) + ).rejects.toThrow( 'Provide a transaction expiry when when initializing the FormSG SDK to use this function.' ) }) @@ -68,15 +70,13 @@ describe('Verification', () => { const verification = new Verification({ transactionExpiry: TEST_TRANSACTION_EXPIRY, secretKey: TEST_SECRET_KEY, - publicKey: TEST_PUBLIC_KEY, + getVerificationPublicKey: () => Promise.resolve(TEST_PUBLIC_KEY), }) let now: jest.MockInstance beforeAll(() => { - now = jest.spyOn(Date, 'now').mockImplementation(() => { - return TIME - }) + now = jest.spyOn(Date, 'now').mockImplementation(() => TIME) }) afterAll(() => { @@ -87,38 +87,38 @@ describe('Verification', () => { expect(verification.generateSignature(TEST_PARAMS)).toBe(VALID_SIGNATURE) }) - it('should successfully authenticate a valid signature', () => { - expect(verification.authenticate(VALID_AUTH_PAYLOAD)).toBe(true) + it('should successfully authenticate a valid signature', async () => { + expect(await verification.authenticate(VALID_AUTH_PAYLOAD)).toBe(true) }) - it('should fail to authenticate a valid signature if it is expired', () => { + it('should fail to authenticate a valid signature if it is expired', async () => { const payload = { signatureString: VALID_SIGNATURE, submissionCreatedAt: TIME + TEST_TRANSACTION_EXPIRY * 2000, fieldId: TEST_PARAMS.fieldId, answer: TEST_PARAMS.answer, } - expect(verification.authenticate(payload)).toBe(false) + expect(await verification.authenticate(payload)).toBe(false) }) - it('should fail to authenticate an invalid signature', () => { + it('should fail to authenticate an invalid signature', async () => { const payload = { signatureString: INVALID_SIGNATURE, submissionCreatedAt: TIME + 1, fieldId: TEST_PARAMS.fieldId, answer: TEST_PARAMS.answer, } - expect(verification.authenticate(payload)).toBe(false) + expect(await verification.authenticate(payload)).toBe(false) }) - it('should fail to authenticate a deformed signature', () => { + it('should fail to authenticate a deformed signature', async () => { const payload = { signatureString: DEFORMED_SIGNATURE, submissionCreatedAt: TIME + 1, fieldId: TEST_PARAMS.fieldId, answer: TEST_PARAMS.answer, } - expect(verification.authenticate(payload)).toBe(false) + expect(await verification.authenticate(payload)).toBe(false) }) }) }) diff --git a/src/crypto.ts b/src/crypto.ts index b7e57fc..f07f03c 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -63,7 +63,7 @@ export default class Crypto extends CryptoBase { * @param decryptParams.encryptedContent The encrypted content encoded with base-64. * @param decryptParams.version The version of the payload. Used to determine the decryption process to decrypt the content with. * @param decryptParams.verifiedContent Optional. The encrypted and signed verified content. If given, the signingPublicKey will be used to attempt to open the signed message. - * @returns The decrypted content if successful. Else, null will be returned. + * @returns A promise that resolves to the decrypted content if successful. Otherwise, resolves to null. * @throws {MissingPublicKeyError} if a public key getter is not provided when instantiating this class and is needed for verifying signed content. */ decrypt = async ( diff --git a/src/types.ts b/src/types.ts index 08b8065..0c70936 100644 --- a/src/types.ts +++ b/src/types.ts @@ -143,7 +143,7 @@ export type Keypair = { export type PackageMode = 'staging' | 'production' | 'development' | 'test' export type VerificationOptions = { - publicKey?: string + getVerificationPublicKey?: () => Promise secretKey?: string transactionExpiry?: number } diff --git a/src/verification/index.ts b/src/verification/index.ts index 6e1bebe..4940212 100644 --- a/src/verification/index.ts +++ b/src/verification/index.ts @@ -8,6 +8,7 @@ import { decodeBase64, decodeUTF8, encodeBase64 } from 'tweetnacl-util' import { MissingPublicKeyError, MissingSecretKeyError } from '../errors' import { VerificationAuthenticateOptions, + VerificationOptions, VerificationSignatureOptions, } from '../types' import { parseVerificationSignature } from '../util/parser' @@ -15,7 +16,7 @@ import { parseVerificationSignature } from '../util/parser' import { formatToBaseString, isSignatureTimeValid } from './utils' export default class Verification { - getVerificationPublicKey: () => Promise + getVerificationPublicKey?: () => Promise verificationSecretKey?: string transactionExpiry?: number @@ -23,11 +24,7 @@ export default class Verification { getVerificationPublicKey, secretKey, transactionExpiry, - }: { - getVerificationPublicKey: () => Promise - secretKey?: string - transactionExpiry?: number - }) { + }: VerificationOptions) { this.getVerificationPublicKey = getVerificationPublicKey this.verificationSecretKey = secretKey this.transactionExpiry = transactionExpiry @@ -54,6 +51,9 @@ export default class Verification { ) } + if (!this.getVerificationPublicKey) { + throw new MissingPublicKeyError() + } const verificationPublicKey = await this.getVerificationPublicKey() if (!verificationPublicKey) { throw new MissingPublicKeyError() From 99396557c2a4b864c338fbcd8e90ac19f7b0095d Mon Sep 17 00:00:00 2001 From: littlemight Date: Sat, 15 Feb 2025 12:47:17 +0800 Subject: [PATCH 19/38] chore: cleanup resource --- src/resource/signing-keys.ts | 3 +-- src/resource/verification-keys.ts | 3 +-- src/util/publicKey.ts | 14 ++++++++++---- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/src/resource/signing-keys.ts b/src/resource/signing-keys.ts index 17da951..1dbc120 100644 --- a/src/resource/signing-keys.ts +++ b/src/resource/signing-keys.ts @@ -6,8 +6,7 @@ export const SIGNING_KEYS = { }, development: { publicKey: 'Tl5gfszlKcQj99/0uafLwVpT6JAu4C0dHGvLq1cHzFE=', - secretKey: - 'HDBXpu+2/gu10bLHpy8HjpN89xbA6boH9GwibPGJA8BOXmB+zOUpxCP33/S5p8vBWlPokC7gLR0ca8urVwfMUQ==', + secretKey: 'HDBXpu+2/gu10bLHpy8HjpN89xbA6boH9GwibPGJA8BOXmB+zOUpxCP33/S5p8vBWlPokC7gLR0ca8urVwfMUQ==', }, production: { // production must never contain secret keys diff --git a/src/resource/verification-keys.ts b/src/resource/verification-keys.ts index 3eadff8..5c5b99b 100644 --- a/src/resource/verification-keys.ts +++ b/src/resource/verification-keys.ts @@ -6,8 +6,7 @@ export const VERIFICATION_KEYS = { }, development: { publicKey: 'SZ4pV0JXgj8dhFU69uHllqYcxTtliYmi+d6Ml56lnQU=', - secretKey: - 'iGkfOuI6uxrlfw+7CZFFUZBwk86I+pu6v+g7EWA6qJpJnilXQleCPx2EVTr24eWWphzFO2WJiaL53oyXnqWdBQ==', + secretKey: 'iGkfOuI6uxrlfw+7CZFFUZBwk86I+pu6v+g7EWA6qJpJnilXQleCPx2EVTr24eWWphzFO2WJiaL53oyXnqWdBQ==', }, production: { // production must never contain secret keys diff --git a/src/util/publicKey.ts b/src/util/publicKey.ts index 5679413..a187f00 100644 --- a/src/util/publicKey.ts +++ b/src/util/publicKey.ts @@ -5,9 +5,11 @@ import { PackageMode } from '../types' import STAGE from './stage' /** - * Gets the signing public key + * Retrieves the appropriate signing public key. + * Defaults to production. + * @param mode The package mode to retrieve the public key for. */ -export const getSigningPublicKey = (mode?: PackageMode): string => { +function getSigningPublicKey(mode?: PackageMode) { switch (mode) { case STAGE.development: return SIGNING_KEYS.development.publicKey @@ -21,9 +23,11 @@ export const getSigningPublicKey = (mode?: PackageMode): string => { } /** - * Gets the verification public key + * Retrieves the appropriate verification public key. + * Defaults to production. + * @param mode The package mode to retrieve the public key for. */ -export const getVerificationPublicKey = (mode?: PackageMode): string => { +function getVerificationPublicKey(mode?: PackageMode) { switch (mode) { case STAGE.development: return VERIFICATION_KEYS.development.publicKey @@ -35,3 +39,5 @@ export const getVerificationPublicKey = (mode?: PackageMode): string => { return VERIFICATION_KEYS.production.publicKey } } + +export { getSigningPublicKey, getVerificationPublicKey } From 18340eb3d757c1c804c98233848394063d1bbd30 Mon Sep 17 00:00:00 2001 From: littlemight Date: Sat, 15 Feb 2025 13:08:48 +0800 Subject: [PATCH 20/38] chore: alpha versioning --- newkey-request.txt | 0 original-request.txt | 19 +++++++++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 newkey-request.txt create mode 100644 original-request.txt diff --git a/newkey-request.txt b/newkey-request.txt new file mode 100644 index 0000000..e69de29 diff --git a/original-request.txt b/original-request.txt new file mode 100644 index 0000000..8d7f483 --- /dev/null +++ b/original-request.txt @@ -0,0 +1,19 @@ +POST /submissions HTTP/1.1 +Host: 256d-103-6-151-166.ngrok-free.app +User-Agent: axios/1.7.7 +Content-Length: 755 +Accept: application/json, text/plain, */* +Accept-Encoding: gzip, compress, deflate, br +Content-Type: application/json +Traceparent: 00-000000000000000055652cb5581da89e-239949fd0d6cd40e-01 +Tracestate: dd=o:rum;s:1;p:239949fd0d6cd40e +X-Datadog-Origin: rum +X-Datadog-Parent-Id: 2565162813964997646 +X-Datadog-Sampling-Priority: 1 +X-Datadog-Trace-Id: 6153373623250692254 +X-Formsg-Signature: t=1740717558471,s=67c13df6e75569539f4abccd,f=67c12c2c05ed623929929ed2,v1=eavDX1DlzEaDq+H+WZIZg1/9WQXZU/Yx8u639eyv4ZAgl2jC2jhyfnT0NNOGdnf14BXIFsO/jYzuLw77OQIpDw== +X-Forwarded-For: 18.140.124.240 +X-Forwarded-Host: 256d-103-6-151-166.ngrok-free.app +X-Forwarded-Proto: https + +{"data":{"formId":"67c12c2c05ed623929929ed2","submissionId":"67c13df6e75569539f4abccd","encryptedContent":"nXew6vPFyEht/Dqi/jJE8Z0WZL0VQxLBt+597ClVOHQ=;trtmfcs6raeCHMcfd9GmxCQ0tvVCPYjT:qPXm09rTBgcMVWFJK3YHXwMklXrZFgqblChdEjkK5iOsNQTcESlSsFHJMBquMDuhNqiwHhd8XRZEjhr5GRsbMSjEjhJ6TmKRucBqtx/GyeQAMkFQB1iBjg4MSiBIpGHvAh/G3j5Wu42shpk7TPBBkwYu4nVpKMX8ydQHNlxgro/8STZ1qPUOBjd5dmMaP6SVh4CNlZ9FRQ6ngO/c9lhuhPYLzW9LBaGMWqu4FOEyqUs45fyYTg34qNBiRz7gEIAI/UtO7wu17l2huHHLVF8yQsPted/NG5gBJwPs13qHHNuLThd+XWQUIXYkIGrKGw3koB8k5HhyBZpOVyRKpGSE2UWMqIfqPcHJd6bxta/qR38FV1L4pq8bZsBgOV697FdV/lkw6Tq/mHLd/XdVQ3wVwEkv/4Fy0406BgmVUndspslX/nh66Wsj5IQo3+1hpeZH+48dPxEWrj1sQqAdu4E=","version":2.1,"created":"2025-02-28T04:39:18.409Z","attachmentDownloadUrls":{},"paymentContent":{}}} From 372338c4d84e5791a0e3f4c1d6ff9844c1bd81c4 Mon Sep 17 00:00:00 2001 From: littlemight Date: Sat, 15 Feb 2025 13:35:59 +0800 Subject: [PATCH 21/38] feat: rotate thru keys --- src/crypto.ts | 37 +++++++++++++++++++++++++++---------- src/resource/mock-jwks.json | 20 ++++++++++++++++++-- src/types.ts | 2 +- src/util/jwks.ts | 18 +++++++++++------- src/verification/index.ts | 24 ++++++++++++++++-------- src/webhooks.ts | 34 ++++++++++++++++++---------------- 6 files changed, 91 insertions(+), 44 deletions(-) diff --git a/src/crypto.ts b/src/crypto.ts index f07f03c..dce2ac3 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -24,12 +24,12 @@ import { } from './types' export default class Crypto extends CryptoBase { - getSigningPublicKey?: () => Promise + getSigningPublicKey?: () => Promise constructor({ getSigningPublicKey, }: { - getSigningPublicKey?: () => Promise + getSigningPublicKey?: () => Promise } = {}) { super() this.getSigningPublicKey = getSigningPublicKey @@ -97,11 +97,11 @@ export default class Crypto extends CryptoBase { ) } - // Get fresh public key when verifying - const signingPublicKey = await this.getSigningPublicKey() - if (!signingPublicKey) { + // Get fresh public keys when verifying + const signingPublicKeys = await this.getSigningPublicKey() + if (!signingPublicKeys || signingPublicKeys.length === 0) { throw new MissingPublicKeyError( - 'Public signing key must be provided when instantiating the Crypto class in order to verify verified content' + 'Public signing keys must be provided when instantiating the Crypto class in order to verify verified content' ) } // Only care if it is the correct shape if verifiedContent exists, since @@ -115,10 +115,27 @@ export default class Crypto extends CryptoBase { // Returns null if decrypting verified content failed. throw new Error('Failed to decrypt verified content') } - const decryptedVerifiedObject = verifySignedMessage( - decryptedVerifiedContent, - signingPublicKey - ) + + let decryptedVerifiedObject = null + for (const publicKey of signingPublicKeys) { + try { + decryptedVerifiedObject = verifySignedMessage( + decryptedVerifiedContent, + publicKey + ) + if (decryptedVerifiedObject) { + break + } + } catch (err) { + continue + } + } + + if (!decryptedVerifiedObject) { + throw new Error( + 'Failed to verify signed content with provided public keys' + ) + } returnedObject.verified = decryptedVerifiedObject } diff --git a/src/resource/mock-jwks.json b/src/resource/mock-jwks.json index 3dd555a..e06aa6c 100644 --- a/src/resource/mock-jwks.json +++ b/src/resource/mock-jwks.json @@ -2,7 +2,15 @@ "keys": [ { "kty": "OKP", - "kid": "signing-webhook-key-staging", + "kid": "signing-webhook-key-staging-v2", + "use": "sig", + "alg": "EdDSA", + "crv": "Ed25519", + "x": "NEW_KEY_HERE_IN_BASE64" + }, + { + "kty": "OKP", + "kid": "signing-webhook-key-staging-v1", "use": "sig", "alg": "EdDSA", "crv": "Ed25519", @@ -10,7 +18,15 @@ }, { "kty": "OKP", - "kid": "signing-otp-key-staging", + "kid": "signing-otp-key-staging-v2", + "use": "verify", + "alg": "EdDSA", + "crv": "Ed25519", + "x": "NEW_KEY_HERE_IN_BASE64" + }, + { + "kty": "OKP", + "kid": "signing-otp-key-staging-v1", "use": "verify", "alg": "EdDSA", "crv": "Ed25519", diff --git a/src/types.ts b/src/types.ts index 0c70936..01b046e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -143,7 +143,7 @@ export type Keypair = { export type PackageMode = 'staging' | 'production' | 'development' | 'test' export type VerificationOptions = { - getVerificationPublicKey?: () => Promise + getVerificationPublicKey?: () => Promise secretKey?: string transactionExpiry?: number } diff --git a/src/util/jwks.ts b/src/util/jwks.ts index d9d80fb..1317b1c 100644 --- a/src/util/jwks.ts +++ b/src/util/jwks.ts @@ -40,12 +40,16 @@ const base64UrlToBase64 = (base64url: string): string => { return converted } -const findKeyByUse = (jwks: JwksResponse, use: 'sig' | 'verify'): string => { - const key = jwks.keys.find((k) => k.use === use) - if (!key) { - throw new Error(`No key with use="${use}" found in JWKS`) +const findKeyByUse = (jwks: JwksResponse, use: 'sig' | 'verify'): string[] => { + const keys = jwks.keys.filter((k) => k.use === use) + + if (keys.length === 0) { + throw new Error(`No keys with use="${use}" found in JWKS`) } - return base64UrlToBase64(key.x) + + // Keys should be used in the order they appear in the JWKS response + // Server should return keys in priority order + return keys.map((k) => base64UrlToBase64(k.x)) } const getJwks = async (): Promise => { @@ -91,12 +95,12 @@ export const initJwks = async (config: JwksConfig): Promise => { } } -export const getSigningPublicKeyFromJwks = async (): Promise => { +export const getSigningPublicKeyFromJwks = async (): Promise => { const jwks = await getJwks() return findKeyByUse(jwks, 'sig') } -export const getVerificationPublicKeyFromJwks = async (): Promise => { +export const getVerificationPublicKeyFromJwks = async (): Promise => { const jwks = await getJwks() return findKeyByUse(jwks, 'verify') } diff --git a/src/verification/index.ts b/src/verification/index.ts index 4940212..c313cf4 100644 --- a/src/verification/index.ts +++ b/src/verification/index.ts @@ -16,7 +16,7 @@ import { parseVerificationSignature } from '../util/parser' import { formatToBaseString, isSignatureTimeValid } from './utils' export default class Verification { - getVerificationPublicKey?: () => Promise + getVerificationPublicKey?: () => Promise verificationSecretKey?: string transactionExpiry?: number @@ -54,8 +54,8 @@ export default class Verification { if (!this.getVerificationPublicKey) { throw new MissingPublicKeyError() } - const verificationPublicKey = await this.getVerificationPublicKey() - if (!verificationPublicKey) { + const verificationPublicKeys = await this.getVerificationPublicKey() + if (!verificationPublicKeys.length) { throw new MissingPublicKeyError() } @@ -82,11 +82,19 @@ export default class Verification { time, }) - return nacl.sign.detached.verify( - decodeUTF8(data), - decodeBase64(signature), - decodeBase64(verificationPublicKey) - ) + // Try each public key until one works + for (const publicKey of verificationPublicKeys) { + if ( + nacl.sign.detached.verify( + decodeUTF8(data), + decodeBase64(signature), + decodeBase64(publicKey) + ) + ) { + return true + } + } + return false } else { console.info( `Signature was expired for signatureString="${signatureString}" signatureDate="${time}" submissionCreatedAt="${submissionCreatedAt}"` diff --git a/src/webhooks.ts b/src/webhooks.ts index 666fde2..6131e74 100644 --- a/src/webhooks.ts +++ b/src/webhooks.ts @@ -6,14 +6,14 @@ import { hasEpochExpired, isSignatureHeaderValid } from './util/webhooks' import { MissingSecretKeyError, WebhookAuthenticateError } from './errors' export default class Webhooks { - getPublicKey: () => Promise + getPublicKey: () => Promise secretKey?: string constructor({ getPublicKey, secretKey, }: { - getPublicKey: () => Promise + getPublicKey: () => Promise secretKey?: string }) { this.getPublicKey = getPublicKey @@ -37,23 +37,25 @@ export default class Webhooks { f: formId, } = signatureHeader - // Get fresh public key on each signature verification - const publicKey = await this.getPublicKey() - if (!isSignatureHeaderValid(uri, signatureHeader, publicKey)) { - throw new WebhookAuthenticateError( - `Signature could not be verified for uri=${uri} submissionId=${submissionId} formId=${formId} epoch=${epoch} signature=${signature}` - ) - } + // Get fresh public keys on each signature verification + const publicKeys = await this.getPublicKey() - // Verify epoch recency - if (hasEpochExpired(epoch)) { - throw new WebhookAuthenticateError( - `Signature is not recent for uri=${uri} submissionId=${submissionId} formId=${formId} epoch=${epoch} signature=${signature}` - ) + // Try each public key until one works or all fail + for (const publicKey of publicKeys) { + if (isSignatureHeaderValid(uri, signatureHeader, publicKey)) { + if (!hasEpochExpired(epoch)) { + return true + } + // If epoch expired, no need to try other keys + throw new WebhookAuthenticateError( + `Signature is not recent for uri=${uri} submissionId=${submissionId} formId=${formId} epoch=${epoch} signature=${signature}` + ) + } } - // All checks pass. - return true + throw new WebhookAuthenticateError( + `Signature could not be verified for uri=${uri} submissionId=${submissionId} formId=${formId} epoch=${epoch} signature=${signature}` + ) } /** From 67ee3c6ab90d3e72df682f0977808d99631b081f Mon Sep 17 00:00:00 2001 From: littlemight Date: Fri, 21 Feb 2025 15:33:23 +0800 Subject: [PATCH 22/38] chore: major ver bump --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d89e069..a8785d7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@opengovsg/formsg-sdk", - "version": "0.13.0", + "version": "1.0.0-alpha.0", "repository": { "type": "git", "url": "https://github.com/opengovsg/formsg-javascript-sdk.git" From b4e465c54522cb42d25cc0769e28bcec7c00e3d3 Mon Sep 17 00:00:00 2001 From: littlemight Date: Sat, 22 Feb 2025 02:55:13 +0800 Subject: [PATCH 23/38] chore: ver on package-lovk --- package-lock.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9c23884..08f932c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@opengovsg/formsg-sdk", - "version": "0.13.0", + "version": "1.0.0-alpha.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@opengovsg/formsg-sdk", - "version": "0.13.0", + "version": "1.0.0-alpha.0", "license": "MIT", "dependencies": { "axios": "^1.6.4", From 58cac69e2be3c7d40114742b4820bae16260af35 Mon Sep 17 00:00:00 2001 From: littlemight Date: Sat, 22 Feb 2025 02:55:41 +0800 Subject: [PATCH 24/38] fix: naming and spec --- spec/util/jwks.spec.ts | 32 ++++++++++++++++---------------- src/util/jwks.ts | 12 +++++++----- 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/spec/util/jwks.spec.ts b/spec/util/jwks.spec.ts index 383dc65..f862606 100644 --- a/spec/util/jwks.spec.ts +++ b/spec/util/jwks.spec.ts @@ -1,8 +1,8 @@ import axios from 'axios' import { initJwks, - getSigningPublicKeyFromJwks, - getVerificationPublicKeyFromJwks, + getSigningPublicKeysFromJwks, + getVerificationPublicKeysFromJwks, } from '../../src/util/jwks' import { DEFAULT_JWKS_TIMEOUT_MS } from '../../src/util/constants' @@ -82,20 +82,20 @@ describe('jwks', () => { it('should fetch and return signing public key', async () => { mockedAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) await initJwks({ url: MOCK_JWKS_URL }) - const result = await getSigningPublicKeyFromJwks() + const result = await getSigningPublicKeysFromJwks() expect(mockedAxios.get).toHaveBeenCalledWith(MOCK_JWKS_URL, { timeout: DEFAULT_JWKS_TIMEOUT_MS, }) - expect(result).toBe('abc+123/test') // converted from base64url to base64 + expect(result).toStrictEqual(['abc+123/test']) // converted from base64url to base64 }) it('should fetch and return verification public key', async () => { mockedAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) await initJwks({ url: MOCK_JWKS_URL }) - const result = await getVerificationPublicKeyFromJwks() - expect(result).toBe('def+456/test') // converted from base64url to base64 + const result = await getVerificationPublicKeysFromJwks() + expect(result).toStrictEqual(['def+456/test']) // converted from base64url to base64 }) it('should respect custom timeout', async () => { @@ -114,11 +114,11 @@ describe('jwks', () => { await initJwks({ url: MOCK_JWKS_URL, cacheDurationMs: customDuration }) // Should not call, as still cached - await getSigningPublicKeyFromJwks() + await getSigningPublicKeysFromJwks() jest.advanceTimersByTime(customDuration + 100) mockedAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) - await getSigningPublicKeyFromJwks() + await getSigningPublicKeysFromJwks() expect(mockedAxios.get).toHaveBeenCalledTimes(2) }) @@ -128,16 +128,16 @@ describe('jwks', () => { await initJwks({ url: MOCK_JWKS_URL, cacheDurationMs: 20000 }) // These should use the cache from initialization - await getSigningPublicKeyFromJwks() - await getSigningPublicKeyFromJwks() - await getSigningPublicKeyFromJwks() + await getSigningPublicKeysFromJwks() + await getSigningPublicKeysFromJwks() + await getSigningPublicKeysFromJwks() expect(mockedAxios.get).toHaveBeenCalledTimes(1) }) it('should throw error if JWKS not initialized', async () => { await initJwks(null as any) - await expect(getSigningPublicKeyFromJwks()).rejects.toThrow( + await expect(getSigningPublicKeysFromJwks()).rejects.toThrow( 'JWKS not initialized' ) }) @@ -147,8 +147,8 @@ describe('jwks', () => { data: { keys: [{ use: 'other' }] }, }) await initJwks({ url: MOCK_JWKS_URL }) - await expect(getSigningPublicKeyFromJwks()).rejects.toThrow( - 'No key with use="sig" found in JWKS' + await expect(getSigningPublicKeysFromJwks()).rejects.toThrow( + 'No keys with use="sig" found in JWKS' ) }) @@ -158,7 +158,7 @@ describe('jwks', () => { // Should still fail on subsequent request mockedAxios.get.mockRejectedValueOnce(new Error('Network error')) - await expect(getSigningPublicKeyFromJwks()).rejects.toThrow( + await expect(getSigningPublicKeysFromJwks()).rejects.toThrow( 'Failed to fetch JWKS: Network error' ) }) @@ -173,7 +173,7 @@ describe('jwks', () => { // Should still fail on subsequent request mockedAxios.get.mockRejectedValueOnce(timeoutError) - await expect(getSigningPublicKeyFromJwks()).rejects.toThrow( + await expect(getSigningPublicKeysFromJwks()).rejects.toThrow( 'Failed to fetch JWKS: timeout of 5000ms exceeded' ) }) diff --git a/src/util/jwks.ts b/src/util/jwks.ts index 1317b1c..4dd3ebc 100644 --- a/src/util/jwks.ts +++ b/src/util/jwks.ts @@ -40,7 +40,7 @@ const base64UrlToBase64 = (base64url: string): string => { return converted } -const findKeyByUse = (jwks: JwksResponse, use: 'sig' | 'verify'): string[] => { +const findKeysByUse = (jwks: JwksResponse, use: 'sig' | 'verify'): string[] => { const keys = jwks.keys.filter((k) => k.use === use) if (keys.length === 0) { @@ -95,12 +95,14 @@ export const initJwks = async (config: JwksConfig): Promise => { } } -export const getSigningPublicKeyFromJwks = async (): Promise => { +export const getSigningPublicKeysFromJwks = async (): Promise => { const jwks = await getJwks() - return findKeyByUse(jwks, 'sig') + return findKeysByUse(jwks, 'sig') } -export const getVerificationPublicKeyFromJwks = async (): Promise => { +export const getVerificationPublicKeysFromJwks = async (): Promise< + string[] +> => { const jwks = await getJwks() - return findKeyByUse(jwks, 'verify') + return findKeysByUse(jwks, 'verify') } From 37eefe1bbe10d0c49157b8aa411c2b0acce7604f Mon Sep 17 00:00:00 2001 From: littlemight Date: Wed, 26 Feb 2025 18:46:58 +0800 Subject: [PATCH 25/38] feat: exponential backoff and fix tests --- spec/crypto.spec.ts | 2 +- spec/init.spec.ts | 20 +++++++++++------- spec/util/jwks.spec.ts | 13 ++++++------ spec/verification/verification.spec.ts | 4 ++-- spec/webhooks.spec.ts | 8 +++---- src/crypto.ts | 12 +++++------ src/index.ts | 13 ++++++++---- src/types.ts | 2 +- src/util/constants.ts | 6 +++++- src/util/jwks.ts | 29 +++++++++++++++++++++----- src/util/keys.ts | 24 +++++++++++++-------- src/verification/index.ts | 10 ++++----- src/webhooks.ts | 10 ++++----- 13 files changed, 96 insertions(+), 57 deletions(-) diff --git a/spec/crypto.spec.ts b/spec/crypto.spec.ts index f0cceb0..f677911 100644 --- a/spec/crypto.spec.ts +++ b/spec/crypto.spec.ts @@ -26,7 +26,7 @@ describe('Crypto', function () { afterEach(() => mockAxios.reset()) const crypto = new Crypto({ - getSigningPublicKey: () => Promise.resolve(encryptionPublicKey), + getSigningPublicKeys: () => Promise.resolve([encryptionPublicKey]), }) const mockVerifiedContent = { diff --git a/spec/init.spec.ts b/spec/init.spec.ts index 3d3c0e6..167bb56 100644 --- a/spec/init.spec.ts +++ b/spec/init.spec.ts @@ -18,9 +18,10 @@ describe('FormSG SDK', () => { describe('Initialisation', () => { it('should be able to initialise without arguments', async () => { const sdk = await formsg() - const signingKey = await sdk.crypto.getSigningPublicKey!() - const verificationKey = await sdk.verification.getVerificationPublicKey!() - const webhooksKey = await sdk.webhooks.getPublicKey() + const [signingKey] = await sdk.crypto.getSigningPublicKeys!() + const [verificationKey] = await sdk.verification + .getVerificationPublicKeys!() + const [webhooksKey] = await sdk.webhooks.getPublicKeys() expect(signingKey).toEqual(SIGNING_KEYS.production.publicKey) expect(verificationKey).toEqual(VERIFICATION_KEYS.production.publicKey) @@ -46,7 +47,8 @@ describe('FormSG SDK', () => { }, }) - const verificationKey = await sdk.verification.getVerificationPublicKey!() + const [verificationKey] = await sdk.verification + .getVerificationPublicKeys!() expect(verificationKey).toEqual(VERIFICATION_KEYS.test.publicKey) expect(sdk.verification.verificationSecretKey).toEqual( VERIFICATION_KEYS.test.secretKey @@ -123,8 +125,9 @@ describe('FormSG SDK', () => { }, }) - const signingKey = await sdk.crypto.getSigningPublicKey!() - const verificationKey = await sdk.verification.getVerificationPublicKey!() + const [signingKey] = await sdk.crypto.getSigningPublicKeys!() + const [verificationKey] = await sdk.verification + .getVerificationPublicKeys!() expect(mockedAxios.get).toHaveBeenCalledWith( MOCK_JWKS_URL, @@ -144,8 +147,9 @@ describe('FormSG SDK', () => { }, }) - const signingKey = await sdk.crypto.getSigningPublicKey!() - const verificationKey = await sdk.verification.getVerificationPublicKey!() + const [signingKey] = await sdk.crypto.getSigningPublicKeys!() + const [verificationKey] = await sdk.verification + .getVerificationPublicKeys!() expect(signingKey).toBe(SIGNING_KEYS.production.publicKey) expect(verificationKey).toBe(VERIFICATION_KEYS.production.publicKey) diff --git a/spec/util/jwks.spec.ts b/spec/util/jwks.spec.ts index f862606..5f10e34 100644 --- a/spec/util/jwks.spec.ts +++ b/spec/util/jwks.spec.ts @@ -34,7 +34,6 @@ describe('jwks', () => { beforeEach(() => { jest.clearAllMocks() - jest.useFakeTimers() }) describe('initialization', () => { @@ -62,7 +61,7 @@ describe('jwks', () => { }) it('should not throw if pre-fetch fails during initialization', async () => { - mockedAxios.get.mockRejectedValueOnce(new Error('Network error')) + mockedAxios.get.mockRejectedValue(new Error('Network error')) await expect(initJwks({ url: MOCK_JWKS_URL })).resolves.not.toThrow() }) @@ -109,6 +108,8 @@ describe('jwks', () => { }) it('should respect custom cache duration', async () => { + jest.useFakeTimers() + const customDuration = 2000 mockedAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) await initJwks({ url: MOCK_JWKS_URL, cacheDurationMs: customDuration }) @@ -143,6 +144,7 @@ describe('jwks', () => { }) it('should throw error if key not found', async () => { + jest.useFakeTimers() mockedAxios.get.mockResolvedValueOnce({ data: { keys: [{ use: 'other' }] }, }) @@ -167,12 +169,11 @@ describe('jwks', () => { const timeoutError = new Error('timeout of 5000ms exceeded') timeoutError.name = 'TimeoutError' - // Initialize with timeout error - mockedAxios.get.mockRejectedValueOnce(timeoutError) - await initJwks({ url: MOCK_JWKS_URL }) + // Every get returns a timeout error, init shouldn't throw + mockedAxios.get.mockRejectedValue(timeoutError) + await expect(initJwks({ url: MOCK_JWKS_URL })).resolves.not.toThrow() // Should still fail on subsequent request - mockedAxios.get.mockRejectedValueOnce(timeoutError) await expect(getSigningPublicKeysFromJwks()).rejects.toThrow( 'Failed to fetch JWKS: timeout of 5000ms exceeded' ) diff --git a/spec/verification/verification.spec.ts b/spec/verification/verification.spec.ts index bcb4b64..f5b60bd 100644 --- a/spec/verification/verification.spec.ts +++ b/spec/verification/verification.spec.ts @@ -54,7 +54,7 @@ describe('Verification', () => { it('should not authenticate if transaction expiry is not provided', async () => { const verification = new Verification({ // No transaction expiry provided. - getVerificationPublicKey: () => Promise.resolve(TEST_PUBLIC_KEY), + getVerificationPublicKeys: () => Promise.resolve([TEST_PUBLIC_KEY]), secretKey: TEST_SECRET_KEY, }) @@ -70,7 +70,7 @@ describe('Verification', () => { const verification = new Verification({ transactionExpiry: TEST_TRANSACTION_EXPIRY, secretKey: TEST_SECRET_KEY, - getVerificationPublicKey: () => Promise.resolve(TEST_PUBLIC_KEY), + getVerificationPublicKeys: () => Promise.resolve([TEST_PUBLIC_KEY]), }) let now: jest.MockInstance diff --git a/spec/webhooks.spec.ts b/spec/webhooks.spec.ts index 2978617..231e3f5 100644 --- a/spec/webhooks.spec.ts +++ b/spec/webhooks.spec.ts @@ -11,12 +11,12 @@ describe('Webhooks', () => { const formId = 'someFormId' const webhooks = new Webhooks({ - getPublicKey: () => Promise.resolve(webhooksPublicKey), + getPublicKeys: () => Promise.resolve([webhooksPublicKey]), secretKey: signingSecretKey, }) const webhooksNoSecret = new Webhooks({ - getPublicKey: () => Promise.resolve(webhooksPublicKey), + getPublicKeys: () => Promise.resolve([webhooksPublicKey]), }) /** @@ -91,8 +91,8 @@ describe('Webhooks', () => { // Create a new Webhook class with a different publicKey const webhooksAlt = new Webhooks({ - getPublicKey: () => - Promise.resolve('ReObXacwevg7CaNtg5QwvtW32S0V6md15up4szRdWUY='), + getPublicKeys: () => + Promise.resolve(['ReObXacwevg7CaNtg5QwvtW32S0V6md15up4szRdWUY=']), }) await expect(webhooksAlt.authenticate(header, uri)).rejects.toThrow( diff --git a/src/crypto.ts b/src/crypto.ts index dce2ac3..ff35bce 100644 --- a/src/crypto.ts +++ b/src/crypto.ts @@ -24,15 +24,15 @@ import { } from './types' export default class Crypto extends CryptoBase { - getSigningPublicKey?: () => Promise + getSigningPublicKeys?: () => Promise constructor({ - getSigningPublicKey, + getSigningPublicKeys, }: { - getSigningPublicKey?: () => Promise + getSigningPublicKeys?: () => Promise } = {}) { super() - this.getSigningPublicKey = getSigningPublicKey + this.getSigningPublicKeys = getSigningPublicKeys } /** @@ -91,14 +91,14 @@ export default class Crypto extends CryptoBase { } if (verifiedContent) { - if (!this.getSigningPublicKey) { + if (!this.getSigningPublicKeys) { throw new MissingPublicKeyError( 'Public signing key getter must be provided when instantiating the Crypto class in order to verify verified content' ) } // Get fresh public keys when verifying - const signingPublicKeys = await this.getSigningPublicKey() + const signingPublicKeys = await this.getSigningPublicKeys() if (!signingPublicKeys || signingPublicKeys.length === 0) { throw new MissingPublicKeyError( 'Public signing keys must be provided when instantiating the Crypto class in order to verify verified content' diff --git a/src/index.ts b/src/index.ts index 30804de..3592bfb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,7 +13,12 @@ import Webhooks from './webhooks' * @param {string?} [config.webhookSecretKey] Optional. base64 secret key for signing webhooks. If provided, enables generating signature and headers to authenticate webhook data. * @param {VerificationOptions?} [config.verificationOptions] Optional. If provided, enables the usage of the verification module. */ -export = async function (config: PackageInitParams = {}) { +export = async function (config: PackageInitParams = {}): Promise<{ + webhooks: Webhooks + crypto: Crypto + cryptoV3: CryptoV3 + verification: Verification +}> { const { webhookSecretKey, verificationOptions, jwks, mode } = config /** @@ -28,15 +33,15 @@ export = async function (config: PackageInitParams = {}) { return { webhooks: new Webhooks({ - getPublicKey: keyGetters.signingPublicKey, + getPublicKeys: keyGetters.signingPublicKeys, secretKey: webhookSecretKey, }), crypto: new Crypto({ - getSigningPublicKey: keyGetters.signingPublicKey, + getSigningPublicKeys: keyGetters.signingPublicKeys, }), cryptoV3: new CryptoV3(), verification: new Verification({ - getVerificationPublicKey: keyGetters.verificationPublicKey, + getVerificationPublicKeys: keyGetters.verificationPublicKeys, secretKey: verificationOptions?.secretKey, transactionExpiry: verificationOptions?.transactionExpiry, }), diff --git a/src/types.ts b/src/types.ts index 01b046e..0de3eb5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -143,7 +143,7 @@ export type Keypair = { export type PackageMode = 'staging' | 'production' | 'development' | 'test' export type VerificationOptions = { - getVerificationPublicKey?: () => Promise + getVerificationPublicKeys?: () => Promise secretKey?: string transactionExpiry?: number } diff --git a/src/util/constants.ts b/src/util/constants.ts index 8a3aad2..afa92c9 100644 --- a/src/util/constants.ts +++ b/src/util/constants.ts @@ -1,4 +1,8 @@ // Safe max value to avoid floating point precision issues (Number.MAX_SAFE_INTEGER = 9007199254740991) export const DEFAULT_JWKS_CACHE_DURATION_MS = 3_600_000 // 1 hour -export const DEFAULT_JWKS_TIMEOUT_MS = 5_000 // 5 seconds +export const DEFAULT_JWKS_TIMEOUT_MS = 5_000 export const MAX_CACHE_DURATION_MS = 86_400_000 // 24 hours, current upper bound +export const DEFAULT_MAX_RETRIES = 3 +export const DEFAULT_INITIAL_RETRY_DELAY_MS = 100 // 100ms +export const DEFAULT_MAX_RETRY_DELAY_MS = 1000 // 1 second +export const DEFAULT_BACKOFF_MULTIPLIER = 2 // Double the delay after each retry diff --git a/src/util/jwks.ts b/src/util/jwks.ts index 4dd3ebc..56ba533 100644 --- a/src/util/jwks.ts +++ b/src/util/jwks.ts @@ -65,11 +65,30 @@ const getJwks = async (): Promise => { } try { - const { data } = await axios.get(jwksConfig.url, { - timeout: jwksConfig.timeoutMs ?? DEFAULT_JWKS_TIMEOUT_MS, - }) - jwksCache.set(data) - return data + // FIXME: dummy values for now + const maxRetries = 3 + let currentRetry = 0 + let lastError + + while (currentRetry <= maxRetries) { + try { + const { data } = await axios.get(jwksConfig.url, { + timeout: jwksConfig.timeoutMs ?? DEFAULT_JWKS_TIMEOUT_MS, + }) + jwksCache.set(data) + + return data + } catch (error) { + lastError = error + if (currentRetry === maxRetries) break + + const backoffTime = Math.pow(2, currentRetry) * 100 + await new Promise((resolve) => setTimeout(resolve, backoffTime)) + currentRetry++ + } + } + + throw lastError } catch (error) { throw new Error( `Failed to fetch JWKS: ${ diff --git a/src/util/keys.ts b/src/util/keys.ts index 3e2ff83..2022cfa 100644 --- a/src/util/keys.ts +++ b/src/util/keys.ts @@ -1,37 +1,43 @@ import { JwksConfig, PackageMode } from '../types' import { - getSigningPublicKeyFromJwks, - getVerificationPublicKeyFromJwks, + getSigningPublicKeysFromJwks, + getVerificationPublicKeysFromJwks, initJwks, } from './jwks' import { getSigningPublicKey, getVerificationPublicKey } from './publicKey' -export const getPublicKeys = async (jwks?: JwksConfig, mode?: PackageMode) => { +export const getPublicKeys = async ( + jwks?: JwksConfig, + mode?: PackageMode +): Promise<{ + signingPublicKeys: () => Promise + verificationPublicKeys: () => Promise +}> => { if (jwks?.url) { await initJwks(jwks) } return { - signingPublicKey: async () => { + signingPublicKeys: async () => { if (jwks?.url) { try { - return await getSigningPublicKeyFromJwks() + return await getSigningPublicKeysFromJwks() } catch (error) { console.warn('Failed to get signing key from JWKS:', error) } } - return getSigningPublicKey(mode) + return [getSigningPublicKey(mode)] }, - verificationPublicKey: async () => { + verificationPublicKeys: async () => { if (jwks?.url) { try { - return await getVerificationPublicKeyFromJwks() + return await getVerificationPublicKeysFromJwks() } catch (error) { console.warn('Failed to get verification key from JWKS:', error) } } - return getVerificationPublicKey(mode) + return [getVerificationPublicKey(mode)] }, } } diff --git a/src/verification/index.ts b/src/verification/index.ts index c313cf4..1a9d4c3 100644 --- a/src/verification/index.ts +++ b/src/verification/index.ts @@ -16,16 +16,16 @@ import { parseVerificationSignature } from '../util/parser' import { formatToBaseString, isSignatureTimeValid } from './utils' export default class Verification { - getVerificationPublicKey?: () => Promise + getVerificationPublicKeys?: () => Promise verificationSecretKey?: string transactionExpiry?: number constructor({ - getVerificationPublicKey, + getVerificationPublicKeys, secretKey, transactionExpiry, }: VerificationOptions) { - this.getVerificationPublicKey = getVerificationPublicKey + this.getVerificationPublicKeys = getVerificationPublicKeys this.verificationSecretKey = secretKey this.transactionExpiry = transactionExpiry } @@ -51,10 +51,10 @@ export default class Verification { ) } - if (!this.getVerificationPublicKey) { + if (!this.getVerificationPublicKeys) { throw new MissingPublicKeyError() } - const verificationPublicKeys = await this.getVerificationPublicKey() + const verificationPublicKeys = await this.getVerificationPublicKeys() if (!verificationPublicKeys.length) { throw new MissingPublicKeyError() } diff --git a/src/webhooks.ts b/src/webhooks.ts index 6131e74..c0f8b06 100644 --- a/src/webhooks.ts +++ b/src/webhooks.ts @@ -6,17 +6,17 @@ import { hasEpochExpired, isSignatureHeaderValid } from './util/webhooks' import { MissingSecretKeyError, WebhookAuthenticateError } from './errors' export default class Webhooks { - getPublicKey: () => Promise + getPublicKeys: () => Promise secretKey?: string constructor({ - getPublicKey, + getPublicKeys, secretKey, }: { - getPublicKey: () => Promise + getPublicKeys: () => Promise secretKey?: string }) { - this.getPublicKey = getPublicKey + this.getPublicKeys = getPublicKeys this.secretKey = secretKey } @@ -38,7 +38,7 @@ export default class Webhooks { } = signatureHeader // Get fresh public keys on each signature verification - const publicKeys = await this.getPublicKey() + const publicKeys = await this.getPublicKeys() // Try each public key until one works or all fail for (const publicKey of publicKeys) { From 91c1719c6f53a4084728358b2ebd709b050e4301 Mon Sep 17 00:00:00 2001 From: littlemight Date: Wed, 26 Feb 2025 22:43:20 +0800 Subject: [PATCH 26/38] feat: use axios-retry instead + integration test --- package-lock.json | 199 +++++++++++++++++++++++++++++ package.json | 2 + spec/util/jwks.integration.spec.ts | 59 +++++++++ spec/util/jwks.spec.ts | 77 ++++------- spec/util/testUtils.ts | 22 ++++ src/util/constants.ts | 9 +- src/util/jwks.ts | 45 ++++--- 7 files changed, 336 insertions(+), 77 deletions(-) create mode 100644 spec/util/jwks.integration.spec.ts create mode 100644 spec/util/testUtils.ts diff --git a/package-lock.json b/package-lock.json index 08f932c..73078d3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "axios": "^1.6.4", + "axios-retry": "^4.5.0", "tweetnacl": "^1.0.3", "tweetnacl-util": "^0.15.1" }, @@ -30,6 +31,7 @@ "eslint-plugin-typesafe": "^0.5.2", "jest": "^29.7.0", "jest-mock-axios": "^4.7.3", + "nock": "^14.0.1", "ts-jest": "^29.1.1", "typescript": "^4.9.5" } @@ -2301,6 +2303,24 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mswjs/interceptors": { + "version": "0.37.6", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.37.6.tgz", + "integrity": "sha512-wK+5pLK5XFmgtH3aQ2YVvA3HohS3xqV/OxuVOdNx9Wpnz7VE/fnC+e1A7ln6LFYeck7gOJ/dsZV6OLplOtAJ2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@nicolo-ribaudo/chokidar-2": { "version": "2.1.8-no-fsevents.3", "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/chokidar-2/-/chokidar-2-2.1.8-no-fsevents.3.tgz", @@ -2343,6 +2363,31 @@ "node": ">= 8" } }, + "node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true, + "license": "MIT" + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -3114,6 +3159,18 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/axios-retry": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/axios-retry/-/axios-retry-4.5.0.tgz", + "integrity": "sha512-aR99oXhpEDGo0UuAlYcn2iGRds30k366Zfa05XWScR9QaQD4JYiP3/1Qt1u7YlefUOK+cn0CcwoL1oefavQUlQ==", + "license": "Apache-2.0", + "dependencies": { + "is-retry-allowed": "^2.2.0" + }, + "peerDependencies": { + "axios": "0.x || 1.x" + } + }, "node_modules/axios/node_modules/form-data": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", @@ -5190,6 +5247,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true, + "license": "MIT" + }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -5227,6 +5291,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-retry-allowed": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-2.2.0.tgz", + "integrity": "sha512-XVm7LOeLpTW4jV19QSH38vkswxoLud8sQ57YwJVTPWdiaI9I8keEhGFpBlslyVsgdQy4Opg8QOLb8YRgsyZiQg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -7187,6 +7263,21 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "dev": true }, + "node_modules/nock": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/nock/-/nock-14.0.1.tgz", + "integrity": "sha512-IJN4O9pturuRdn60NjQ7YkFt6Rwei7ZKaOwb1tvUIIqTgeD0SDDAX3vrqZD4wcXczeEy/AsUXxpGpP/yHqV7xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@mswjs/interceptors": "^0.37.3", + "json-stringify-safe": "^5.0.1", + "propagate": "^2.0.0" + }, + "engines": { + "node": ">=18.20.0 <20 || >=20.12.1" + } + }, "node_modules/node-fetch": { "version": "2.6.7", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", @@ -7361,6 +7452,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true, + "license": "MIT" + }, "node_modules/p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", @@ -7615,6 +7713,16 @@ "node": ">= 6" } }, + "node_modules/propagate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", + "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -8270,6 +8378,13 @@ "node": ">=8" } }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true, + "license": "MIT" + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", @@ -10688,6 +10803,20 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "@mswjs/interceptors": { + "version": "0.37.6", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.37.6.tgz", + "integrity": "sha512-wK+5pLK5XFmgtH3aQ2YVvA3HohS3xqV/OxuVOdNx9Wpnz7VE/fnC+e1A7ln6LFYeck7gOJ/dsZV6OLplOtAJ2w==", + "dev": true, + "requires": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + } + }, "@nicolo-ribaudo/chokidar-2": { "version": "2.1.8-no-fsevents.3", "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/chokidar-2/-/chokidar-2-2.1.8-no-fsevents.3.tgz", @@ -10721,6 +10850,28 @@ "fastq": "^1.6.0" } }, + "@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "dev": true + }, + "@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "dev": true, + "requires": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "dev": true + }, "@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -11293,6 +11444,14 @@ } } }, + "axios-retry": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/axios-retry/-/axios-retry-4.5.0.tgz", + "integrity": "sha512-aR99oXhpEDGo0UuAlYcn2iGRds30k366Zfa05XWScR9QaQD4JYiP3/1Qt1u7YlefUOK+cn0CcwoL1oefavQUlQ==", + "requires": { + "is-retry-allowed": "^2.2.0" + } + }, "babel-eslint": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/babel-eslint/-/babel-eslint-10.1.0.tgz", @@ -12847,6 +13006,12 @@ "integrity": "sha512-2z6JzQvZRa9A2Y7xC6dQQm4FSTSTNWjKIYYTt4246eMTJmIo0Q+ZyOsU66X8lxK1AbB92dFeglPLrhwpeRKO6w==", "dev": true }, + "is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "dev": true + }, "is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -12869,6 +13034,11 @@ "has-symbols": "^1.0.2" } }, + "is-retry-allowed": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-2.2.0.tgz", + "integrity": "sha512-XVm7LOeLpTW4jV19QSH38vkswxoLud8sQ57YwJVTPWdiaI9I8keEhGFpBlslyVsgdQy4Opg8QOLb8YRgsyZiQg==" + }, "is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -14383,6 +14553,17 @@ "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "dev": true }, + "nock": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/nock/-/nock-14.0.1.tgz", + "integrity": "sha512-IJN4O9pturuRdn60NjQ7YkFt6Rwei7ZKaOwb1tvUIIqTgeD0SDDAX3vrqZD4wcXczeEy/AsUXxpGpP/yHqV7xg==", + "dev": true, + "requires": { + "@mswjs/interceptors": "^0.37.3", + "json-stringify-safe": "^5.0.1", + "propagate": "^2.0.0" + } + }, "node-fetch": { "version": "2.6.7", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.7.tgz", @@ -14515,6 +14696,12 @@ "mimic-fn": "^2.1.0" } }, + "outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "dev": true + }, "p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", @@ -14690,6 +14877,12 @@ "sisteransi": "^1.0.5" } }, + "propagate": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/propagate/-/propagate-2.0.1.tgz", + "integrity": "sha512-vGrhOavPSTz4QVNuBNdcNXePNdNMaO1xj9yBeH1ScQPjk/rhg9sSlCXPhMkFuaNNW/syTvYqsnbIJxMBfRbbag==", + "dev": true + }, "proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -15188,6 +15381,12 @@ } } }, + "strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "dev": true + }, "string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", diff --git a/package.json b/package.json index a8785d7..3cdf63f 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "license": "MIT", "dependencies": { "axios": "^1.6.4", + "axios-retry": "^4.5.0", "tweetnacl": "^1.0.3", "tweetnacl-util": "^0.15.1" }, @@ -45,6 +46,7 @@ "eslint-plugin-typesafe": "^0.5.2", "jest": "^29.7.0", "jest-mock-axios": "^4.7.3", + "nock": "^14.0.1", "ts-jest": "^29.1.1", "typescript": "^4.9.5" } diff --git a/spec/util/jwks.integration.spec.ts b/spec/util/jwks.integration.spec.ts new file mode 100644 index 0000000..bd87d24 --- /dev/null +++ b/spec/util/jwks.integration.spec.ts @@ -0,0 +1,59 @@ +import nock from 'nock' +import { initJwks, getSigningPublicKeysFromJwks } from '../../src/util/jwks' +import { MOCK_JWKS_URL, MOCK_JWKS_RESPONSE } from './testUtils' + +// mock http response instead of mocking axios, as mocked axios isn't aware of axios-retry +describe('jwks integration', () => { + beforeEach(() => { + nock.cleanAll() + }) + + it('should retry failed requests with exponential backoff and eventually succeed', async () => { + await initJwks({ + url: MOCK_JWKS_URL, + timeoutMs: 50, + loadOnInit: false, // don't populate cache + }) + + // retry scenario + nock('https://test.example.com') + .get('/.well-known/jwks.json') + .times(1) + .replyWithError('Network failure') + nock('https://test.example.com') + .get('/.well-known/jwks.json') + .times(1) + .reply(500, 'Server error') + nock('https://test.example.com') + .get('/.well-known/jwks.json') + .reply(200, MOCK_JWKS_RESPONSE) + + const result = await getSigningPublicKeysFromJwks() + + expect(result).toStrictEqual(['abc+123/test']) + expect(nock.isDone()).toBe(true) + }, 10_000) + + it('should throw error when all retry attempts fail', async () => { + await initJwks({ + url: MOCK_JWKS_URL, + timeoutMs: 50, + loadOnInit: false, + }) + + nock('https://test.example.com') + .get('/.well-known/jwks.json') + .times(4) // always fail + .reply(500, 'Server error') + + await expect(getSigningPublicKeysFromJwks()).rejects.toThrow( + 'Failed to fetch JWKS: Request failed with status code 500' + ) + expect(nock.isDone()).toBe(true) + }, 20_000) + + afterEach(() => { + jest.useRealTimers() + nock.cleanAll() + }) +}) diff --git a/spec/util/jwks.spec.ts b/spec/util/jwks.spec.ts index 5f10e34..9f35666 100644 --- a/spec/util/jwks.spec.ts +++ b/spec/util/jwks.spec.ts @@ -1,96 +1,72 @@ -import axios from 'axios' +import mockAxios from 'jest-mock-axios' import { initJwks, getSigningPublicKeysFromJwks, getVerificationPublicKeysFromJwks, } from '../../src/util/jwks' import { DEFAULT_JWKS_TIMEOUT_MS } from '../../src/util/constants' +import { MOCK_JWKS_URL, MOCK_JWKS_RESPONSE } from './testUtils' -jest.mock('axios') -const mockedAxios = axios as jest.Mocked +jest.mock('axios', () => mockAxios) describe('jwks', () => { - const MOCK_JWKS_URL = 'https://test.example.com/.well-known/jwks.json' - const MOCK_JWKS_RESPONSE = { - keys: [ - { - kty: 'OKP', - kid: '1', - use: 'sig', - alg: 'EdDSA', - crv: 'Ed25519', - x: 'abc-123_test', // this will be converted from base64url to base64 - }, - { - kty: 'OKP', - kid: '2', - use: 'verify', - alg: 'EdDSA', - crv: 'Ed25519', - x: 'def-456_test', // this will be converted from base64url to base64 - }, - ], - } - beforeEach(() => { jest.clearAllMocks() + mockAxios.reset() }) describe('initialization', () => { it('should not pre-fetch JWKS when loadOnInit is explicitly false', async () => { await initJwks({ url: MOCK_JWKS_URL, loadOnInit: false }) - expect(mockedAxios.get).not.toHaveBeenCalled() + expect(mockAxios.get).not.toHaveBeenCalled() }) it('should pre-fetch JWKS by default when loadOnInit is undefined', async () => { - mockedAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) + mockAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) await initJwks({ url: MOCK_JWKS_URL }) - expect(mockedAxios.get).toHaveBeenCalledWith(MOCK_JWKS_URL, { + expect(mockAxios.get).toHaveBeenCalledWith(MOCK_JWKS_URL, { timeout: DEFAULT_JWKS_TIMEOUT_MS, }) }) it('should pre-fetch JWKS when loadOnInit is true', async () => { - mockedAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) + mockAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) await initJwks({ url: MOCK_JWKS_URL, loadOnInit: true }) - expect(mockedAxios.get).toHaveBeenCalledWith(MOCK_JWKS_URL, { + expect(mockAxios.get).toHaveBeenCalledWith(MOCK_JWKS_URL, { timeout: DEFAULT_JWKS_TIMEOUT_MS, }) }) it('should not throw if pre-fetch fails during initialization', async () => { - mockedAxios.get.mockRejectedValue(new Error('Network error')) + mockAxios.get.mockRejectedValue(new Error('Network error')) await expect(initJwks({ url: MOCK_JWKS_URL })).resolves.not.toThrow() }) it('should reset cache when reinitializing', async () => { - mockedAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) + mockAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) await initJwks({ url: MOCK_JWKS_URL }) // Second initialization should trigger new fetch - mockedAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) + mockAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) await initJwks({ url: MOCK_JWKS_URL }) - expect(mockedAxios.get).toHaveBeenCalledTimes(2) + expect(mockAxios.get).toHaveBeenCalledTimes(2) }) }) it('should fetch and return signing public key', async () => { - mockedAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) + mockAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) await initJwks({ url: MOCK_JWKS_URL }) const result = await getSigningPublicKeysFromJwks() - expect(mockedAxios.get).toHaveBeenCalledWith(MOCK_JWKS_URL, { - timeout: DEFAULT_JWKS_TIMEOUT_MS, - }) expect(result).toStrictEqual(['abc+123/test']) // converted from base64url to base64 }) it('should fetch and return verification public key', async () => { - mockedAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) + mockAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) await initJwks({ url: MOCK_JWKS_URL }) const result = await getVerificationPublicKeysFromJwks() @@ -99,10 +75,11 @@ describe('jwks', () => { it('should respect custom timeout', async () => { const customTimeout = 5000 - mockedAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) + mockAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) + await initJwks({ url: MOCK_JWKS_URL, timeoutMs: customTimeout }) - expect(mockedAxios.get).toHaveBeenCalledWith(MOCK_JWKS_URL, { + expect(mockAxios.get).toHaveBeenCalledWith(MOCK_JWKS_URL, { timeout: customTimeout, }) }) @@ -111,21 +88,21 @@ describe('jwks', () => { jest.useFakeTimers() const customDuration = 2000 - mockedAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) + mockAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) await initJwks({ url: MOCK_JWKS_URL, cacheDurationMs: customDuration }) // Should not call, as still cached await getSigningPublicKeysFromJwks() jest.advanceTimersByTime(customDuration + 100) - mockedAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) + mockAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) await getSigningPublicKeysFromJwks() - expect(mockedAxios.get).toHaveBeenCalledTimes(2) + expect(mockAxios.get).toHaveBeenCalledTimes(2) }) it('should use cache for subsequent requests', async () => { - mockedAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) + mockAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) await initJwks({ url: MOCK_JWKS_URL, cacheDurationMs: 20000 }) // These should use the cache from initialization @@ -133,7 +110,7 @@ describe('jwks', () => { await getSigningPublicKeysFromJwks() await getSigningPublicKeysFromJwks() - expect(mockedAxios.get).toHaveBeenCalledTimes(1) + expect(mockAxios.get).toHaveBeenCalledTimes(1) }) it('should throw error if JWKS not initialized', async () => { @@ -145,7 +122,7 @@ describe('jwks', () => { it('should throw error if key not found', async () => { jest.useFakeTimers() - mockedAxios.get.mockResolvedValueOnce({ + mockAxios.get.mockResolvedValueOnce({ data: { keys: [{ use: 'other' }] }, }) await initJwks({ url: MOCK_JWKS_URL }) @@ -155,11 +132,11 @@ describe('jwks', () => { }) it('should throw error on network failure', async () => { - mockedAxios.get.mockRejectedValueOnce(new Error('Network error')) + mockAxios.get.mockRejectedValueOnce(new Error('Network error')) await initJwks({ url: MOCK_JWKS_URL }) // Should still fail on subsequent request - mockedAxios.get.mockRejectedValueOnce(new Error('Network error')) + mockAxios.get.mockRejectedValueOnce(new Error('Network error')) await expect(getSigningPublicKeysFromJwks()).rejects.toThrow( 'Failed to fetch JWKS: Network error' ) @@ -170,7 +147,7 @@ describe('jwks', () => { timeoutError.name = 'TimeoutError' // Every get returns a timeout error, init shouldn't throw - mockedAxios.get.mockRejectedValue(timeoutError) + mockAxios.get.mockRejectedValue(timeoutError) await expect(initJwks({ url: MOCK_JWKS_URL })).resolves.not.toThrow() // Should still fail on subsequent request diff --git a/spec/util/testUtils.ts b/spec/util/testUtils.ts new file mode 100644 index 0000000..a4984eb --- /dev/null +++ b/spec/util/testUtils.ts @@ -0,0 +1,22 @@ +export const MOCK_JWKS_URL = 'https://test.example.com/.well-known/jwks.json' + +export const MOCK_JWKS_RESPONSE = { + keys: [ + { + kty: 'OKP', + kid: '1', + use: 'sig', + alg: 'EdDSA', + crv: 'Ed25519', + x: 'abc-123_test', // base64url which should be converted to base64 + }, + { + kty: 'OKP', + kid: '2', + use: 'verify', + alg: 'EdDSA', + crv: 'Ed25519', + x: 'def-456_test', + }, + ], +} diff --git a/src/util/constants.ts b/src/util/constants.ts index afa92c9..bb9a3fd 100644 --- a/src/util/constants.ts +++ b/src/util/constants.ts @@ -2,7 +2,8 @@ export const DEFAULT_JWKS_CACHE_DURATION_MS = 3_600_000 // 1 hour export const DEFAULT_JWKS_TIMEOUT_MS = 5_000 export const MAX_CACHE_DURATION_MS = 86_400_000 // 24 hours, current upper bound -export const DEFAULT_MAX_RETRIES = 3 -export const DEFAULT_INITIAL_RETRY_DELAY_MS = 100 // 100ms -export const DEFAULT_MAX_RETRY_DELAY_MS = 1000 // 1 second -export const DEFAULT_BACKOFF_MULTIPLIER = 2 // Double the delay after each retry + +// JWKS retry configuration +export const JWKS_MAX_RETRIES = 3 +export const JWKS_RETRY_STATUS_CODES = [408, 429, 500, 502, 503, 504] +export const JWKS_INITIAL_BACKOFF_MS = 1000 // Initial backoff period for exponential delay diff --git a/src/util/jwks.ts b/src/util/jwks.ts index 56ba533..f74941a 100644 --- a/src/util/jwks.ts +++ b/src/util/jwks.ts @@ -1,4 +1,5 @@ import axios from 'axios' +import axiosRetry from 'axios-retry' import { JwksConfig } from '../types' @@ -6,6 +7,9 @@ import { Cache } from './cache' import { DEFAULT_JWKS_CACHE_DURATION_MS, DEFAULT_JWKS_TIMEOUT_MS, + JWKS_INITIAL_BACKOFF_MS, + JWKS_MAX_RETRIES, + JWKS_RETRY_STATUS_CODES, } from './constants' interface JwksKey { @@ -52,6 +56,19 @@ const findKeysByUse = (jwks: JwksResponse, use: 'sig' | 'verify'): string[] => { return keys.map((k) => base64UrlToBase64(k.x)) } +axiosRetry(axios, { + retries: JWKS_MAX_RETRIES, + retryDelay: (...arg) => + axiosRetry.exponentialDelay(...arg, JWKS_INITIAL_BACKOFF_MS), + retryCondition: (error) => { + return ( + axiosRetry.isNetworkOrIdempotentRequestError(error) || + JWKS_RETRY_STATUS_CODES.includes(error.response?.status ?? 0) + ) + }, + shouldResetTimeout: true, +}) + const getJwks = async (): Promise => { if (!jwksConfig) throw new Error('JWKS not initialized') @@ -65,30 +82,12 @@ const getJwks = async (): Promise => { } try { - // FIXME: dummy values for now - const maxRetries = 3 - let currentRetry = 0 - let lastError - - while (currentRetry <= maxRetries) { - try { - const { data } = await axios.get(jwksConfig.url, { - timeout: jwksConfig.timeoutMs ?? DEFAULT_JWKS_TIMEOUT_MS, - }) - jwksCache.set(data) - - return data - } catch (error) { - lastError = error - if (currentRetry === maxRetries) break - - const backoffTime = Math.pow(2, currentRetry) * 100 - await new Promise((resolve) => setTimeout(resolve, backoffTime)) - currentRetry++ - } - } + const { data } = await axios.get(jwksConfig.url, { + timeout: jwksConfig.timeoutMs ?? DEFAULT_JWKS_TIMEOUT_MS, + }) + jwksCache.set(data) - throw lastError + return data } catch (error) { throw new Error( `Failed to fetch JWKS: ${ From f7188ea27dad0d9160228088ed8f4761db6a4518 Mon Sep 17 00:00:00 2001 From: littlemight Date: Wed, 26 Feb 2025 23:06:36 +0800 Subject: [PATCH 27/38] feat: allow overriding backoffs --- spec/util/jwks.integration.spec.ts | 24 ++++++++++++++++------ spec/util/jwks.spec.ts | 5 ++++- src/types.ts | 32 ++++++++++++++++++++++++++---- src/util/jwks.ts | 31 ++++++++++++++++------------- 4 files changed, 67 insertions(+), 25 deletions(-) diff --git a/spec/util/jwks.integration.spec.ts b/spec/util/jwks.integration.spec.ts index bd87d24..c7f8773 100644 --- a/spec/util/jwks.integration.spec.ts +++ b/spec/util/jwks.integration.spec.ts @@ -2,8 +2,8 @@ import nock from 'nock' import { initJwks, getSigningPublicKeysFromJwks } from '../../src/util/jwks' import { MOCK_JWKS_URL, MOCK_JWKS_RESPONSE } from './testUtils' -// mock http response instead of mocking axios, as mocked axios isn't aware of axios-retry -describe('jwks integration', () => { +// mock http response instead of mocking axios, as mocked axios wouldn't be aware of axios-retry +describe('jwks retries', () => { beforeEach(() => { nock.cleanAll() }) @@ -11,8 +11,14 @@ describe('jwks integration', () => { it('should retry failed requests with exponential backoff and eventually succeed', async () => { await initJwks({ url: MOCK_JWKS_URL, - timeoutMs: 50, loadOnInit: false, // don't populate cache + requestConfig: { + timeoutMs: 50, + retry: { + maxRetries: 3, + initialBackoffMs: 100, + }, + }, }) // retry scenario @@ -32,13 +38,19 @@ describe('jwks integration', () => { expect(result).toStrictEqual(['abc+123/test']) expect(nock.isDone()).toBe(true) - }, 10_000) + }) it('should throw error when all retry attempts fail', async () => { await initJwks({ url: MOCK_JWKS_URL, - timeoutMs: 50, loadOnInit: false, + requestConfig: { + timeoutMs: 50, + retry: { + maxRetries: 3, + initialBackoffMs: 100, + }, + }, }) nock('https://test.example.com') @@ -50,7 +62,7 @@ describe('jwks integration', () => { 'Failed to fetch JWKS: Request failed with status code 500' ) expect(nock.isDone()).toBe(true) - }, 20_000) + }) afterEach(() => { jest.useRealTimers() diff --git a/spec/util/jwks.spec.ts b/spec/util/jwks.spec.ts index 9f35666..36d44a9 100644 --- a/spec/util/jwks.spec.ts +++ b/spec/util/jwks.spec.ts @@ -77,7 +77,10 @@ describe('jwks', () => { const customTimeout = 5000 mockAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) - await initJwks({ url: MOCK_JWKS_URL, timeoutMs: customTimeout }) + await initJwks({ + url: MOCK_JWKS_URL, + requestConfig: { timeoutMs: customTimeout }, + }) expect(mockAxios.get).toHaveBeenCalledWith(MOCK_JWKS_URL, { timeout: customTimeout, diff --git a/src/types.ts b/src/types.ts index 0de3eb5..c5cfabb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,12 +1,36 @@ export type JwksConfig = { /** URL to fetch JWKS from */ url: string - /** Timeout in milliseconds for JWKS fetch request. Defaults to 5000ms */ - timeoutMs?: number - /** Duration in milliseconds to cache JWKS. Defaults to 3600000ms (1 hour) */ + /** + * Duration in milliseconds to cache JWKS. + * @default DEFAULT_JWKS_CACHE_DURATION_MS from constants.ts + */ cacheDurationMs?: number - /** Whether to load JWKS during initialization. Defaults to true */ + /** + * Whether to load JWKS during initialization. + * @default true + */ loadOnInit?: boolean + /** HTTP request configuration for JWKS fetching */ + requestConfig?: { + /** + * Timeout in milliseconds for JWKS fetch request. + * @default DEFAULT_JWKS_TIMEOUT_MS from constants.ts + */ + timeoutMs?: number + retry?: { + /** + * Maximum number of retries for JWKS fetch request. + * @default JWKS_MAX_RETRIES from constants.ts + */ + maxRetries?: number + /** + * Initial backoff duration in milliseconds for JWKS fetch request retries. + * @default JWKS_INITIAL_BACKOFF_MS from constants.ts + */ + initialBackoffMs?: number + } + } } export type PackageInitParams = { diff --git a/src/util/jwks.ts b/src/util/jwks.ts index f74941a..67e90c9 100644 --- a/src/util/jwks.ts +++ b/src/util/jwks.ts @@ -56,19 +56,6 @@ const findKeysByUse = (jwks: JwksResponse, use: 'sig' | 'verify'): string[] => { return keys.map((k) => base64UrlToBase64(k.x)) } -axiosRetry(axios, { - retries: JWKS_MAX_RETRIES, - retryDelay: (...arg) => - axiosRetry.exponentialDelay(...arg, JWKS_INITIAL_BACKOFF_MS), - retryCondition: (error) => { - return ( - axiosRetry.isNetworkOrIdempotentRequestError(error) || - JWKS_RETRY_STATUS_CODES.includes(error.response?.status ?? 0) - ) - }, - shouldResetTimeout: true, -}) - const getJwks = async (): Promise => { if (!jwksConfig) throw new Error('JWKS not initialized') @@ -83,7 +70,7 @@ const getJwks = async (): Promise => { try { const { data } = await axios.get(jwksConfig.url, { - timeout: jwksConfig.timeoutMs ?? DEFAULT_JWKS_TIMEOUT_MS, + timeout: jwksConfig.requestConfig?.timeoutMs ?? DEFAULT_JWKS_TIMEOUT_MS, }) jwksCache.set(data) @@ -103,6 +90,22 @@ export const initJwks = async (config: JwksConfig): Promise => { if (!jwksConfig) return + axiosRetry(axios, { + retries: config.requestConfig?.retry?.maxRetries ?? JWKS_MAX_RETRIES, + retryDelay: (...arg) => + axiosRetry.exponentialDelay( + ...arg, + config.requestConfig?.retry?.initialBackoffMs ?? JWKS_INITIAL_BACKOFF_MS + ), + retryCondition: (error) => { + return ( + axiosRetry.isNetworkOrIdempotentRequestError(error) || + JWKS_RETRY_STATUS_CODES.includes(error.response?.status ?? 0) + ) + }, + shouldResetTimeout: true, // each retry will wait for the full timeout duration + }) + // Default to true if not specified if (jwksConfig.loadOnInit !== false) { try { From a3e8665cad99caaf342f831ced7c1eefd402008e Mon Sep 17 00:00:00 2001 From: littlemight Date: Tue, 4 Mar 2025 15:07:55 +0800 Subject: [PATCH 28/38] chore: add examples folder for easier SDK sandbox --- examples/webhook-server/.env.example | 14 + examples/webhook-server/.gitignore | 2 + examples/webhook-server/README.md | 66 + examples/webhook-server/package-lock.json | 1540 +++++++++++++++++++++ examples/webhook-server/package.json | 20 + examples/webhook-server/tsconfig.json | 13 + examples/webhook-server/webhook-server.ts | 142 ++ 7 files changed, 1797 insertions(+) create mode 100644 examples/webhook-server/.env.example create mode 100644 examples/webhook-server/.gitignore create mode 100644 examples/webhook-server/README.md create mode 100644 examples/webhook-server/package-lock.json create mode 100644 examples/webhook-server/package.json create mode 100644 examples/webhook-server/tsconfig.json create mode 100644 examples/webhook-server/webhook-server.ts diff --git a/examples/webhook-server/.env.example b/examples/webhook-server/.env.example new file mode 100644 index 0000000..45b9c85 --- /dev/null +++ b/examples/webhook-server/.env.example @@ -0,0 +1,14 @@ +# Your form's secret key downloaded from FormSG +FORM_SECRET_KEY=YOUR_FORM_SECRET_KEY_HERE + +# Set to true if your form has file attachments +HAS_ATTACHMENTS=false + +# FormSG environment +FORMSG_ENV=staging + +# Server port +PORT=3000 + +# Optional: JWKS URL if needed +# JWKS_URL=https://your-jwks-server/.well-known/jwks.json diff --git a/examples/webhook-server/.gitignore b/examples/webhook-server/.gitignore new file mode 100644 index 0000000..2d7ec5c --- /dev/null +++ b/examples/webhook-server/.gitignore @@ -0,0 +1,2 @@ +.env +node_modules/ diff --git a/examples/webhook-server/README.md b/examples/webhook-server/README.md new file mode 100644 index 0000000..de07c66 --- /dev/null +++ b/examples/webhook-server/README.md @@ -0,0 +1,66 @@ +# FormSG Webhook Demo Server + +A simple Express server that demonstrates how to use the FormSG JavaScript SDK to receive and process form submissions via webhooks. + +## Setup + +1. Copy `.env.example` to `.env`: + ```bash + cp .env.example .env + ``` + +2. Edit `.env` and add your FormSG form secret key + +3. Install dependencies: + ```bash + npm install + ``` + +4. Start the server: + ```bash + npx nodemon webhook-server.ts + ``` + + Or use the npm script: + ```bash + npm start + ``` + +## Exposing to the internet with ngrok + +To receive webhooks from FormSG, your server needs to be accessible from the internet. You can use ngrok for this: + +1. Install ngrok if you haven't already. You can do so via brew/any other means, here's how to using npm + ```bash + npm install -g ngrok + ``` + +2. Start ngrok in a new terminal: + ```bash + ngrok http 3000 + ``` + + Or use the npm script: + ```bash + npm run start:ngrok + ``` + +3. Copy the HTTPS URL provided by ngrok (example: `https://a1b2c3d4.ngrok.io`) + +4. Configure your FormSG form's webhook to point to this URL + `/submissions` (e.g., `https://a1b2c3d4.ngrok.io/submissions`) + +## How it works + +This example server: + +1. Authenticates incoming webhook requests using the FormSG signature +2. Decrypts the form submission using your form secret key +3. Handles form submissions with or without file attachments +4. Logs the decrypted submission data + +## Environment Variables + +- `FORM_SECRET_KEY`: Your form's secret key from FormSG (required) +- `HAS_ATTACHMENTS`: Set to 'true' if your form contains file upload fields +- `FORMSG_ENV`: 'production' or 'staging' depending on which FormSG environment you're using +- `PORT`: The port to run the server on (default: 3000) diff --git a/examples/webhook-server/package-lock.json b/examples/webhook-server/package-lock.json new file mode 100644 index 0000000..6a36142 --- /dev/null +++ b/examples/webhook-server/package-lock.json @@ -0,0 +1,1540 @@ +{ + "name": "formsg-webhook-demo", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "formsg-webhook-demo", + "version": "1.0.0", + "dependencies": { + "dotenv": "^16.3.1", + "express": "^4.18.2" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "nodemon": "^3.0.1", + "ts-node": "^10.9.1", + "typescript": "^5.2.2" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", + "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/body-parser": { + "version": "1.19.5", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", + "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", + "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "*" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.6", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", + "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", + "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.13.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.5.tgz", + "integrity": "sha512-+lTU0PxZXn0Dr1NBtC7Y8cR21AJr87dLLU953CWA6pMxxv/UDc7jYAY90upcrie1nRcD6XNG5HOYEDtgW5TxAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.20.0" + } + }, + "node_modules/@types/qs": { + "version": "6.9.18", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.18.tgz", + "integrity": "sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", + "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.7", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz", + "integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "*" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz", + "integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "license": "MIT" + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dotenv": { + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.7.1", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.3.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.12", + "proxy-addr": "~2.0.7", + "qs": "6.13.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.2", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ignore-by-default": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ignore-by-default/-/ignore-by-default-1.0.1.tgz", + "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nodemon": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.9.tgz", + "integrity": "sha512-hdr1oIb2p6ZSxu3PB2JWWYS7ZQ0qvaZsc3hK8DR8f02kRzc8rjYmxAIvdz+aYC+8F2IjNaB7HMcSDg8nQpJxyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.2", + "debug": "^4", + "ignore-by-default": "^1.0.1", + "minimatch": "^3.1.2", + "pstree.remy": "^1.1.8", + "semver": "^7.5.3", + "simple-update-notifier": "^2.0.0", + "supports-color": "^5.5.0", + "touch": "^3.1.0", + "undefsafe": "^2.0.5" + }, + "bin": { + "nodemon": "bin/nodemon.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nodemon" + } + }, + "node_modules/nodemon/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/nodemon/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pstree.remy": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/pstree.remy/-/pstree.remy-1.1.8.tgz", + "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", + "dev": true, + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.19.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-update-notifier": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", + "integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/touch": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz", + "integrity": "sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==", + "dev": true, + "license": "ISC", + "bin": { + "nodetouch": "bin/nodetouch.js" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undefsafe": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", + "integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", + "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + } + } +} diff --git a/examples/webhook-server/package.json b/examples/webhook-server/package.json new file mode 100644 index 0000000..ddc3d09 --- /dev/null +++ b/examples/webhook-server/package.json @@ -0,0 +1,20 @@ +{ + "name": "formsg-webhook-demo", + "version": "1.0.0", + "description": "A minimal server for testing FormSG webhooks", + "main": "webhook-server.ts", + "scripts": { + "start": "nodemon webhook-server.ts", + "start:ngrok": "ngrok http 3000" + }, + "dependencies": { + "express": "^4.18.2", + "dotenv": "^16.3.1" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "nodemon": "^3.0.1", + "ts-node": "^10.9.1", + "typescript": "^5.2.2" + } +} diff --git a/examples/webhook-server/tsconfig.json b/examples/webhook-server/tsconfig.json new file mode 100644 index 0000000..82d82e6 --- /dev/null +++ b/examples/webhook-server/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2018", + "module": "CommonJS", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "moduleResolution": "node" + }, + "include": ["./*.ts"] +} diff --git a/examples/webhook-server/webhook-server.ts b/examples/webhook-server/webhook-server.ts new file mode 100644 index 0000000..36af5ad --- /dev/null +++ b/examples/webhook-server/webhook-server.ts @@ -0,0 +1,142 @@ +import express from 'express' +import * as dotenv from 'dotenv' +import formSgSDK from '../../src' + +dotenv.config() + +const app = express() +const PORT = process.env.PORT || 3000 + +// Get the form secret key from environment variables +const formSecretKey = process.env.FORM_SECRET_KEY! +if (!formSecretKey) { + console.error('FORM_SECRET_KEY environment variable is required') + process.exit(1) +} + +const HAS_ATTACHMENTS = process.env.HAS_ATTACHMENTS === 'true' + +async function initializeFormSg() { + const formsg = await formSgSDK({ + mode: (process.env.FORMSG_ENV as 'staging' | 'production') || 'production', + ...(process.env.JWKS_URL && { + jwks: { + url: process.env.JWKS_URL, + cacheDurationMs: 60_000, // 1 minute + requestConfig: { + timeoutMs: 5_000, + retry: { + maxRetries: 3, + initialBackoffMs: 1_000, + }, + }, + }, + }), + }) + + // This should match the webhook URI you configure in FormSG + const WEBHOOK_PATH = '/submissions' + const webhookUrl = `https://256d-103-6-151-166.ngrok-free.app${WEBHOOK_PATH}` + + app.use((req, res, next) => { + console.log(`${new Date().toISOString()} - ${req.method} ${req.path}`) + console.log('Headers:', JSON.stringify(req.headers, null, 2)) + next() + }) + + app.post( + WEBHOOK_PATH, + // Authenticate the webhook signature + (req, res, next) => { + try { + const signature = req.get('X-FormSG-Signature') + if (!signature) { + console.error('No signature found in headers') + return res.status(401).send({ message: 'Signature missing' }) + } + + formsg.webhooks.authenticate(signature, webhookUrl) + console.log('Webhook authenticated successfully') + return next() + } catch (e) { + console.error('Authentication failed:', e) + return res.status(401).send({ message: 'Unauthorized' }) + } + }, + // Parse JSON from raw body + express.json(), + // Decrypt the submission + async (req, res) => { + try { + console.log('Processing submission...') + + if (!req.body.data) { + return res.status(400).send({ message: 'No data provided' }) + } + + const submission = HAS_ATTACHMENTS + ? await formsg.crypto.decryptWithAttachments( + formSecretKey, + req.body.data + ) + : formsg.crypto.decrypt(formSecretKey, req.body.data) + + if (submission) { + console.log('Submission decrypted successfully') + + // Print submission details (redacted for privacy) + if (HAS_ATTACHMENTS && 'attachments' in submission) { + console.log( + 'Contains attachments with field IDs:', + Object.keys(submission.attachments) + ) + + // Just log attachment names, not the content + Object.entries(submission.attachments).forEach( + ([fieldId, file]) => { + console.log( + `Field ${fieldId}: ${file.filename} (${file.content.byteLength} bytes)` + ) + } + ) + + console.log( + 'Form responses:', + submission.content.responses.map((field) => ({ + id: field._id, + question: field.question, + })) + ) + } + + return res + .status(200) + .send({ message: 'Submission processed successfully' }) + } else { + console.error('Could not decrypt submission') + return res.status(400).send({ message: 'Decryption failed' }) + } + } catch (e) { + console.error('Error processing submission:', e) + return res.status(500).send({ message: 'Internal server error' }) + } + } + ) + + app.listen(PORT, () => { + console.log(` + FormSG Webhook Demo Server + + Server running at http://localhost:${PORT} + Webhook endpoint: ${webhookUrl} + + To expose your local server to the internet: + Run 'npm run start:ngrok' in another terminal + `) + }) +} + +initializeFormSg().catch((error) => { + console.error('Failed to initialize FormSG SDK:', error) + process.exit(1) +}) From c8a15c036fd1a5f0c5fbc3333b7b62637445a5a4 Mon Sep 17 00:00:00 2001 From: littlemight Date: Tue, 4 Mar 2025 15:10:35 +0800 Subject: [PATCH 29/38] chore: node keypair generator --- key.js | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 key.js diff --git a/key.js b/key.js new file mode 100644 index 0000000..9f44ebe --- /dev/null +++ b/key.js @@ -0,0 +1,45 @@ +const nacl = require('tweetnacl') +const crypto = require('crypto') // For UUID generation + +// Generate Ed25519 keypair using NaCl +const generateKeysAndJWKS = () => { + // Generate a key ID (kid) - two options: + // 1. UUID-based (cryptographically random) + const generateUuidKid = () => { + return crypto.randomUUID() + } + + const keypair = nacl.sign.keyPair() + // console.log('keypair', keypair) + + // Convert keys to Base64 for storage/display + const publicKeyBase64 = Buffer.from(keypair.publicKey).toString('base64') + const privateKeyBase64 = Buffer.from(keypair.secretKey).toString('base64') + + // Convert public key to base64url format (required for JWK) + const publicKeyBase64Url = publicKeyBase64 + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + + return { + privateKey: privateKeyBase64, + publicKey: publicKeyBase64, + publicKeyBase64Url: publicKeyBase64Url, + kid: generateUuidKid(), + } +} + +// Run the function and output results +const result = generateKeysAndJWKS() + +console.log('Generated Ed25519 keypair:') +console.log('-------------------------') +console.log('Private key (base64):') +console.log(result.privateKey) +console.log('\nPublic key (base64):') +console.log(result.publicKey) +console.log('\nPublic key (base64url, what to put in JWKS):') +console.log(result.publicKeyBase64Url) +console.log('\nKey ID (UUID format):') +console.log(result.kid) From 2fae6963846526be2132f23da550c0d328e032ff Mon Sep 17 00:00:00 2001 From: littlemight Date: Tue, 4 Mar 2025 20:17:23 +0800 Subject: [PATCH 30/38] docs: init migration.md --- MIGRATION.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 MIGRATION.md diff --git a/MIGRATION.md b/MIGRATION.md new file mode 100644 index 0000000..84283fd --- /dev/null +++ b/MIGRATION.md @@ -0,0 +1,9 @@ +# Migration + +- [From version <= 0.13.0 to 1.0.0] + - What's breaking? + - Why is it an array of keys and not a single key? + - Why JWKS? + - Key specifics + - Why base64url? + - Why custom public keys? From 1ad412e7782ec5324f10dcef2251a5c32ce4e126 Mon Sep 17 00:00:00 2001 From: littlemight Date: Fri, 7 Mar 2025 10:25:40 +0800 Subject: [PATCH 31/38] docs: redo MIGRATION.md --- MIGRATION.md | 122 +++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 113 insertions(+), 9 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 84283fd..b28a98b 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -1,9 +1,113 @@ -# Migration - -- [From version <= 0.13.0 to 1.0.0] - - What's breaking? - - Why is it an array of keys and not a single key? - - Why JWKS? - - Key specifics - - Why base64url? - - Why custom public keys? +# Migration Guide for FormSG SDK from 0.x.x to 1.0.0 + +## Major Changes Regarding Public Keys +Prior to version 1.0.0, **all signing keys are hardcoded in the SDK**. +Making the SDK convenient to use, but with poor security posture in case of a key compromise. + +With hardcoded keys, key rotation involves changing the hardcoded keys, publish a new patch version, and tell all clients using our SDK to urgently update to that new version. + +**Anyway, why does rotating keys matter?** +1. **Limiting exposure after compromise**: If a private key is compromised, having a rotation process ensures the exposure window is limited to the rotation period rather than indefinitely. +2. **Defense against undetected breaches**: Even if a key compromise goes undetected, regular rotation ensures the compromised key eventually becomes invalid. + +### Open Source and International Consideration +Given the open source nature of FormSG. Moving away from hardcoded keys to better key management significantly improves FormSG's ecosystem as an open source project. +1. **Separation of security concerns**: With JWKS, sensitive key material is no longer embedded in the open source codebase, allowing anyone to use, review, and contribute to the SDK without access to production keys. +2. **Environment flexibility**: Open source contributors can point the SDK to their own JWKS endpoints for development and testing, making contributions easier without depending on official FormSG infrastructure. + +We already have fully working forks of FormSG, if interested parties want to further explore FormSG's capabilities, they will likely need this SDK down the line. + +### Fetching keys from JWKS endpoint +Blabla +```json +{ + "keys": [ + { + "kty": "OKP", + "kid": "signing-webhook-key-staging-v1", + "use": "sig", + "alg": "EdDSA", + "crv": "Ed25519", + "x": "" + }, + { + "kty": "OKP", + "kid": "signing-otp-key-staging-v1", + "use": "verify", + "alg": "EdDSA", + "crv": "Ed25519", + "x": "" + } + ] +} +``` + + +#### Why JWKS? +JWKS (JSON Web Key Set) provides a standardized way to distribute cryptographic keys used for signature verification. It offers several advantages: + +1. Keys can be rotated without requiring SDK updates +2. All public keys are hosted in one discoverable location +3. Follows well-established security standards (RFC 7517) +5. Allows multiple key versions to exist simultaneously during rotation periods + +#### Why does the key need to be in base64url format? +The key is encoded in base64url format as per the JWKS specification. This encoding ensures the key material can be safely transported in URLs and JSON documents without special character escaping issues. Base64url is a URL-safe variant of base64 that replaces '+' with '-', '/' with '_', and omits padding characters ('='). + +So a base64 key such as +``` +``` + +In base64url it would be +``` +``` + +#### What happens during key rotation? +1. A new key pair is generated and the new public key is added to the JWKS endpoint with a new kid (key ID) +2. Both the old and new keys remain available in the JWKS for a transition period +3. The SDK fetches the latest keys from the JWKS endpoint automatically +4. When verifying signatures, SDK tries keys matching the kid in the signature header +5. This allows for a seamless transition as systems gradually start using the new key +6. After the transition period, the old key may be removed from the JWKS + +#### How do I generate a new pair of keys? +The keys are ED25519 keys, which, in theory, can be generated in any way you want. Be it via a script using `openssl`, or any cryptographic library you're comfortable with. + +For convenience, we have provided a `generateKey.ts` using `nacl.sign.keypair()` to generate the keys. You can just run it and just copy paste the public key in base64url format. + +See examples/generate/README.md for more details. + +#### Lightweight Caching +The JWKS response are cached in-memory, this is useful for long running applications. + +When a key rotation happens, the cache will have old keys, since we're passing the `kid` in the signature header, SDK will try refetching JWKS to get the fresh keys, ensuring no failed signature verification due to stale cache. + +### Using custom keys injected at SDK initialisation +If you don't have a JWKS endpoint set up, you can inject your custom keys when initialising the SDK instance. +```typescript +import { FormSgSdk } from '@opengovsg/formsg-sdk' + +const formsg = new FormSgSdk({ + mode: 'production', + ... +}) +``` + +#### What happens during a key rotation? +When using custom keys, the SDK instance does not automatically update when keys are rotated. You'll need to manually re-initialise the SDK instance with the fresh set of keys. This requires code changes to update the keys and restart any services using the SDK to pick up the new keys. + +### Hardcoded FormSG keys + +### Key Fetching Fallback Order +Cache -> JWKS (if provided) -> Injected static custom keys (if provided) -> Hardcoded keys +The hardcoded keys will slowly be phased out in the future, since 1.0.0 is not in pilot yet. + +## Method Changes +### Sync +table + +### Async +table + +## Example Migrations +some code From 4aac9aa8c82857e2439442659d15fcb8fb8f89d8 Mon Sep 17 00:00:00 2001 From: littlemight Date: Sat, 8 Mar 2025 13:14:13 +0800 Subject: [PATCH 32/38] feat: pass optional keyId on headers, allow custom static pubkey on init --- spec/init.spec.ts | 4 +- spec/util/jwks.integration.spec.ts | 2 +- spec/util/jwks.spec.ts | 32 ++++++++++++-- spec/util/testUtils.ts | 12 +++++- spec/verification/verification.spec.ts | 7 +++ spec/webhooks.spec.ts | 17 +++++++- src/index.ts | 11 +++-- src/types.ts | 16 +++++-- src/util/jwks.ts | 60 ++++++++++++++++++++------ src/util/keys.ts | 24 ++++++++--- src/util/parser.ts | 4 ++ src/verification/index.ts | 21 ++++++--- src/webhooks.ts | 20 ++++++--- 13 files changed, 184 insertions(+), 46 deletions(-) diff --git a/spec/init.spec.ts b/spec/init.spec.ts index 167bb56..4f91265 100644 --- a/spec/init.spec.ts +++ b/spec/init.spec.ts @@ -31,7 +31,9 @@ describe('FormSG SDK', () => { it('should correctly assign given webhook signing key', async () => { const mockSecretKey = 'mock secret key' const sdk = await formsg({ - webhookSecretKey: mockSecretKey, + webhookOptions: { + secretKey: mockSecretKey, + }, }) expect(sdk.webhooks.secretKey).toEqual(mockSecretKey) diff --git a/spec/util/jwks.integration.spec.ts b/spec/util/jwks.integration.spec.ts index c7f8773..a43a8bf 100644 --- a/spec/util/jwks.integration.spec.ts +++ b/spec/util/jwks.integration.spec.ts @@ -36,7 +36,7 @@ describe('jwks retries', () => { const result = await getSigningPublicKeysFromJwks() - expect(result).toStrictEqual(['abc+123/test']) + expect(result).toStrictEqual(['abc+123/test', 'abc+789/test']) expect(nock.isDone()).toBe(true) }) diff --git a/spec/util/jwks.spec.ts b/spec/util/jwks.spec.ts index 36d44a9..83b9ace 100644 --- a/spec/util/jwks.spec.ts +++ b/spec/util/jwks.spec.ts @@ -57,12 +57,20 @@ describe('jwks', () => { }) }) - it('should fetch and return signing public key', async () => { + it('should fetch and return all signing public keys if keyId is not given', async () => { mockAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) await initJwks({ url: MOCK_JWKS_URL }) const result = await getSigningPublicKeysFromJwks() - expect(result).toStrictEqual(['abc+123/test']) // converted from base64url to base64 + expect(result).toStrictEqual(['abc+123/test', 'abc+789/test']) // converted from base64url to base64 + }) + + it('should fetch and return signing public key by keyId', async () => { + mockAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) + await initJwks({ url: MOCK_JWKS_URL }) + const result = await getSigningPublicKeysFromJwks('some-new-key-id') + + expect(result).toStrictEqual(['abc+789/test']) }) it('should fetch and return verification public key', async () => { @@ -70,7 +78,7 @@ describe('jwks', () => { await initJwks({ url: MOCK_JWKS_URL }) const result = await getVerificationPublicKeysFromJwks() - expect(result).toStrictEqual(['def+456/test']) // converted from base64url to base64 + expect(result).toStrictEqual(['def+456/test']) }) it('should respect custom timeout', async () => { @@ -124,7 +132,6 @@ describe('jwks', () => { }) it('should throw error if key not found', async () => { - jest.useFakeTimers() mockAxios.get.mockResolvedValueOnce({ data: { keys: [{ use: 'other' }] }, }) @@ -134,6 +141,23 @@ describe('jwks', () => { ) }) + it('should force refreshing cache and try refetch if keyId not found', async () => { + mockAxios.get.mockResolvedValueOnce({ data: { keys: [] } }) + mockAxios.get.mockResolvedValueOnce({ data: MOCK_JWKS_RESPONSE }) + await initJwks({ url: MOCK_JWKS_URL }) + expect(await getSigningPublicKeysFromJwks('1-old-key')).toStrictEqual([ + 'abc+123/test', + ]) + }) + + it('should throw error if keyId not found even after refreshing cache', async () => { + mockAxios.get.mockResolvedValue({ data: MOCK_JWKS_RESPONSE }) + await initJwks({ url: MOCK_JWKS_URL }) + await expect( + getSigningPublicKeysFromJwks('nonexistent-key-id') + ).rejects.toThrow('Key with kid="nonexistent-key-id" not found in JWKS') + }) + it('should throw error on network failure', async () => { mockAxios.get.mockRejectedValueOnce(new Error('Network error')) await initJwks({ url: MOCK_JWKS_URL }) diff --git a/spec/util/testUtils.ts b/spec/util/testUtils.ts index a4984eb..5e10911 100644 --- a/spec/util/testUtils.ts +++ b/spec/util/testUtils.ts @@ -4,7 +4,7 @@ export const MOCK_JWKS_RESPONSE = { keys: [ { kty: 'OKP', - kid: '1', + kid: '1-old-key', use: 'sig', alg: 'EdDSA', crv: 'Ed25519', @@ -12,11 +12,19 @@ export const MOCK_JWKS_RESPONSE = { }, { kty: 'OKP', - kid: '2', + kid: '2-old-key', use: 'verify', alg: 'EdDSA', crv: 'Ed25519', x: 'def-456_test', }, + { + kty: 'OKP', + kid: 'some-new-key-id', + use: 'sig', + alg: 'EdDSA', + crv: 'Ed25519', + x: 'abc-789_test', + }, ], } diff --git a/spec/verification/verification.spec.ts b/spec/verification/verification.spec.ts index f5b60bd..38121a2 100644 --- a/spec/verification/verification.spec.ts +++ b/spec/verification/verification.spec.ts @@ -14,6 +14,7 @@ const TEST_PARAMS = { } const TIME = 1588658696255 const VALID_SIGNATURE = `f=formId,v=transactionId,t=${TIME},s=XLF1V4RDu8dEJLq1yK3UN92TwiekVoif7PX4V8cXr5ERfIQXlOcO+ZOFAawawKWhFSqScg5z1Ro+Y+bMeNmRAg==` +const VALID_SIGNATURE_WITH_KEY_ID = `f=formId,v=transactionId,t=${TIME},s=XLF1V4RDu8dEJLq1yK3UN92TwiekVoif7PX4V8cXr5ERfIQXlOcO+ZOFAawawKWhFSqScg5z1Ro+Y+bMeNmRAg==,kid=some-key-id` const INVALID_SIGNATURE = `f=formId,v=transactionId,t=${TIME},s=InvalidSignatureyK3UN92TwiekVoif7PX4V8cXr5ERfIQXlOcO+ZOFAawawKWhFSqScg5z1Ro+Y+bMeNmRAg==` const DEFORMED_SIGNATURE = `abcdefg` @@ -87,6 +88,12 @@ describe('Verification', () => { expect(verification.generateSignature(TEST_PARAMS)).toBe(VALID_SIGNATURE) }) + it('should generate a signature with keyId in the header', () => { + expect( + verification.generateSignature({ ...TEST_PARAMS, keyId: 'some-key-id' }) + ).toBe(VALID_SIGNATURE_WITH_KEY_ID) + }) + it('should successfully authenticate a valid signature', async () => { expect(await verification.authenticate(VALID_AUTH_PAYLOAD)).toBe(true) }) diff --git a/spec/webhooks.spec.ts b/spec/webhooks.spec.ts index 231e3f5..48dfe2e 100644 --- a/spec/webhooks.spec.ts +++ b/spec/webhooks.spec.ts @@ -34,12 +34,17 @@ describe('Webhooks', () => { /** * Helper method to construct a test header. */ - const constructTestHeader = (epoch: number, signature: string) => { + const constructTestHeader = ( + epoch: number, + signature: string, + keyId?: string + ) => { return webhooks.constructHeader({ epoch, submissionId, formId, signature, + keyId, }) } @@ -57,6 +62,16 @@ describe('Webhooks', () => { ) }) + it('should include kid in X-FormSG-Signature header if keyId is given', () => { + const epoch = 1583136171649 + const signature = 'some-signature' + const header = constructTestHeader(epoch, signature, 'some-new-key-id') + + expect(header).toBe( + `t=1583136171649,s=someSubmissionId,f=someFormId,v1=some-signature,kid=some-new-key-id` + ) + }) + it('should authenticate a signature that was recently generated', async () => { const epoch = Date.now() const signature = generateTestSignature(epoch) diff --git a/src/index.ts b/src/index.ts index 3592bfb..e4be704 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,7 +19,7 @@ export = async function (config: PackageInitParams = {}): Promise<{ cryptoV3: CryptoV3 verification: Verification }> { - const { webhookSecretKey, verificationOptions, jwks, mode } = config + const { webhookOptions, verificationOptions, jwks, mode } = config /** * signingPublicKey is used for decrypting signed verified content in the `crypto` module, and @@ -29,12 +29,17 @@ export = async function (config: PackageInitParams = {}): Promise<{ * * Both keys are fetched from the JWKS endpoint if provided, else they are fetched from the static public keys. */ - const keyGetters = await getPublicKeys(jwks, mode) + const keyGetters = await getPublicKeys( + jwks, + webhookOptions?.publicKey, + verificationOptions?.publicKey, + mode + ) return { webhooks: new Webhooks({ getPublicKeys: keyGetters.signingPublicKeys, - secretKey: webhookSecretKey, + secretKey: webhookOptions?.secretKey, }), crypto: new Crypto({ getSigningPublicKeys: keyGetters.signingPublicKeys, diff --git a/src/types.ts b/src/types.ts index c5cfabb..7afb09a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -34,10 +34,17 @@ export type JwksConfig = { } export type PackageInitParams = { - /** base64 secret key for signing webhooks. If provided, enables generating signature and headers to authenticate webhook data. */ - webhookSecretKey?: string + webhookOptions?: { + /** base64 secret key for signing webhooks. If provided, enables generating signature and headers to authenticate webhook data. */ + secretKey?: string + publicKey?: string + } /** If provided, enables the usage of the verification module. */ - verificationOptions?: VerificationOptions + verificationOptions?: { + publicKey?: string + secretKey?: string + transactionExpiry?: number + } /** Initializes public key used for verifying and decrypting in this package. If not given, will default to "production". */ mode?: PackageMode /** JWKS configuration */ @@ -167,7 +174,7 @@ export type Keypair = { export type PackageMode = 'staging' | 'production' | 'development' | 'test' export type VerificationOptions = { - getVerificationPublicKeys?: () => Promise + getVerificationPublicKeys?: (keyId?: string) => Promise secretKey?: string transactionExpiry?: number } @@ -182,6 +189,7 @@ export type VerifiedAnswer = { export type VerificationSignatureOptions = VerifiedAnswer & { transactionId: string formId: string + keyId?: string } // Creating a basestring requires the epoch in addition to signature requirements diff --git a/src/util/jwks.ts b/src/util/jwks.ts index 67e90c9..118c7a5 100644 --- a/src/util/jwks.ts +++ b/src/util/jwks.ts @@ -44,9 +44,20 @@ const base64UrlToBase64 = (base64url: string): string => { return converted } -const findKeysByUse = (jwks: JwksResponse, use: 'sig' | 'verify'): string[] => { - const keys = jwks.keys.filter((k) => k.use === use) +const findKeysByUse = ( + jwks: JwksResponse, + use: 'sig' | 'verify', + keyId?: string +): string[] => { + if (keyId) { + const key = jwks.keys.find((k) => k.kid === keyId) + if (!key) { + throw new Error(`Key with kid="${keyId}" not found in JWKS`) + } + return [base64UrlToBase64(key.x)] + } + const keys = jwks.keys.filter((k) => k.use === use) if (keys.length === 0) { throw new Error(`No keys with use="${use}" found in JWKS`) } @@ -56,11 +67,14 @@ const findKeysByUse = (jwks: JwksResponse, use: 'sig' | 'verify'): string[] => { return keys.map((k) => base64UrlToBase64(k.x)) } -const getJwks = async (): Promise => { +const getJwks = async (getJwksOptions?: { + forceCacheRefresh?: boolean +}): Promise => { if (!jwksConfig) throw new Error('JWKS not initialized') + const forceCacheRefresh = getJwksOptions?.forceCacheRefresh ?? false const cached = jwksCache?.get() - if (cached) return cached + if (cached && !forceCacheRefresh) return cached if (!jwksCache) { jwksCache = new Cache( @@ -116,14 +130,36 @@ export const initJwks = async (config: JwksConfig): Promise => { } } -export const getSigningPublicKeysFromJwks = async (): Promise => { - const jwks = await getJwks() - return findKeysByUse(jwks, 'sig') +export const getSigningPublicKeysFromJwks = async ( + keyId?: string +): Promise => { + try { + const jwks = await getJwks() + return findKeysByUse(jwks, 'sig', keyId) + } catch (error) { + if (keyId) { + // force a cache refresh and try again in case of stale cache + const refreshedJwks = await getJwks({ forceCacheRefresh: true }) + return findKeysByUse(refreshedJwks, 'sig', keyId) + } + + throw error + } } -export const getVerificationPublicKeysFromJwks = async (): Promise< - string[] -> => { - const jwks = await getJwks() - return findKeysByUse(jwks, 'verify') +export const getVerificationPublicKeysFromJwks = async ( + keyId?: string +): Promise => { + try { + const jwks = await getJwks() + return findKeysByUse(jwks, 'verify', keyId) + } catch (error) { + if (keyId) { + // force a cache refresh and try again in case of stale cache + const refreshedJwks = await getJwks({ forceCacheRefresh: true }) + return findKeysByUse(refreshedJwks, 'verify', keyId) + } + + throw error + } } diff --git a/src/util/keys.ts b/src/util/keys.ts index 2022cfa..b55c530 100644 --- a/src/util/keys.ts +++ b/src/util/keys.ts @@ -9,34 +9,46 @@ import { getSigningPublicKey, getVerificationPublicKey } from './publicKey' export const getPublicKeys = async ( jwks?: JwksConfig, + webhookPublicKey?: string, + verificationPublicKey?: string, mode?: PackageMode ): Promise<{ - signingPublicKeys: () => Promise - verificationPublicKeys: () => Promise + signingPublicKeys: (keyId?: string) => Promise + verificationPublicKeys: (keyId?: string) => Promise }> => { if (jwks?.url) { await initJwks(jwks) } return { - signingPublicKeys: async () => { + signingPublicKeys: async (keyId?: string) => { if (jwks?.url) { try { - return await getSigningPublicKeysFromJwks() + return await getSigningPublicKeysFromJwks(keyId) } catch (error) { console.warn('Failed to get signing key from JWKS:', error) } } + + if (webhookPublicKey) { + return [webhookPublicKey] + } + return [getSigningPublicKey(mode)] }, - verificationPublicKeys: async () => { + verificationPublicKeys: async (keyId?: string) => { if (jwks?.url) { try { - return await getVerificationPublicKeysFromJwks() + return await getVerificationPublicKeysFromJwks(keyId) } catch (error) { console.warn('Failed to get verification key from JWKS:', error) } } + + if (verificationPublicKey) { + return [verificationPublicKey] + } + return [getVerificationPublicKey(mode)] }, } diff --git a/src/util/parser.ts b/src/util/parser.ts index b059831..33e2424 100644 --- a/src/util/parser.ts +++ b/src/util/parser.ts @@ -8,6 +8,8 @@ export type HeaderSignature = { s: string // The form ID, usually the MongoDB form ObjectId f: string + // The public key ID used for signing, optional + kid?: string } // The constituents of the verification signature @@ -22,6 +24,8 @@ export type VerificationSignature = { s: string // The form ID, usually the MongoDB form ObjectId f: string + // The public key ID used for signing, optional + kid?: string } /** diff --git a/src/verification/index.ts b/src/verification/index.ts index 1a9d4c3..317f211 100644 --- a/src/verification/index.ts +++ b/src/verification/index.ts @@ -16,7 +16,7 @@ import { parseVerificationSignature } from '../util/parser' import { formatToBaseString, isSignatureTimeValid } from './utils' export default class Verification { - getVerificationPublicKeys?: () => Promise + getVerificationPublicKeys?: (keyId?: string) => Promise verificationSecretKey?: string transactionExpiry?: number @@ -54,10 +54,6 @@ export default class Verification { if (!this.getVerificationPublicKeys) { throw new MissingPublicKeyError() } - const verificationPublicKeys = await this.getVerificationPublicKeys() - if (!verificationPublicKeys.length) { - throw new MissingPublicKeyError() - } try { const { @@ -65,12 +61,18 @@ export default class Verification { t: time, f: formId, s: signature, + kid: keyId, } = parseVerificationSignature(signatureString) if (!time) { throw new Error('Malformed signature string was passed into function') } + const verificationPublicKeys = await this.getVerificationPublicKeys(keyId) + if (!verificationPublicKeys.length) { + throw new MissingPublicKeyError() + } + if ( isSignatureTimeValid(time, submissionCreatedAt, this.transactionExpiry) ) { @@ -117,6 +119,7 @@ export default class Verification { formId, fieldId, answer, + keyId, }: VerificationSignatureOptions): string => { if (!this.verificationSecretKey) { throw new MissingSecretKeyError( @@ -136,8 +139,14 @@ export default class Verification { decodeUTF8(data), decodeBase64(this.verificationSecretKey) ) - return `f=${formId},v=${transactionId},t=${time},s=${encodeBase64( + + const result = `f=${formId},v=${transactionId},t=${time},s=${encodeBase64( signature )}` + if (keyId) { + return `${result},kid=${keyId}` + } + + return result } } diff --git a/src/webhooks.ts b/src/webhooks.ts index c0f8b06..d233448 100644 --- a/src/webhooks.ts +++ b/src/webhooks.ts @@ -6,14 +6,14 @@ import { hasEpochExpired, isSignatureHeaderValid } from './util/webhooks' import { MissingSecretKeyError, WebhookAuthenticateError } from './errors' export default class Webhooks { - getPublicKeys: () => Promise + getPublicKeys: (keyId?: string) => Promise secretKey?: string constructor({ getPublicKeys, secretKey, }: { - getPublicKeys: () => Promise + getPublicKeys: (keyId?: string) => Promise secretKey?: string }) { this.getPublicKeys = getPublicKeys @@ -35,12 +35,13 @@ export default class Webhooks { t: epoch, s: submissionId, f: formId, + kid: keyId, } = signatureHeader - // Get fresh public keys on each signature verification - const publicKeys = await this.getPublicKeys() + // Get fresh public keys on each signature verification, and try to get keyId if provided + const publicKeys = await this.getPublicKeys(keyId) - // Try each public key until one works or all fail + // If keyId isn't provided. Try each public key until one works or all fail for (const publicKey of publicKeys) { if (isSignatureHeaderValid(uri, signatureHeader, publicKey)) { if (!hasEpochExpired(epoch)) { @@ -110,16 +111,23 @@ export default class Webhooks { submissionId, formId, signature, + keyId, }: { epoch: number submissionId: string formId: string signature: string + keyId?: string }) => { if (!this.secretKey) { throw new MissingSecretKeyError() } - return `t=${epoch},s=${submissionId},f=${formId},v1=${signature}` + const header = `t=${epoch},s=${submissionId},f=${formId},v1=${signature}` + if (keyId) { + return `${header},kid=${keyId}` + } + + return header } } From cb67814d29b5ce49c11cc439d4274929f5422288 Mon Sep 17 00:00:00 2001 From: littlemight Date: Sat, 8 Mar 2025 13:14:27 +0800 Subject: [PATCH 33/38] refactor: examples folder --- examples/{webhook-server => }/.env.example | 0 examples/{webhook-server => }/.gitignore | 0 examples/{webhook-server => }/README.md | 0 key.js => examples/generate-keys.ts | 18 ++++++++---------- .../{webhook-server => }/package-lock.json | 0 examples/{webhook-server => }/package.json | 3 ++- examples/{webhook-server => }/tsconfig.json | 0 .../{webhook-server => }/webhook-server.ts | 2 +- 8 files changed, 11 insertions(+), 12 deletions(-) rename examples/{webhook-server => }/.env.example (100%) rename examples/{webhook-server => }/.gitignore (100%) rename examples/{webhook-server => }/README.md (100%) rename key.js => examples/generate-keys.ts (72%) rename examples/{webhook-server => }/package-lock.json (100%) rename examples/{webhook-server => }/package.json (83%) rename examples/{webhook-server => }/tsconfig.json (100%) rename examples/{webhook-server => }/webhook-server.ts (99%) diff --git a/examples/webhook-server/.env.example b/examples/.env.example similarity index 100% rename from examples/webhook-server/.env.example rename to examples/.env.example diff --git a/examples/webhook-server/.gitignore b/examples/.gitignore similarity index 100% rename from examples/webhook-server/.gitignore rename to examples/.gitignore diff --git a/examples/webhook-server/README.md b/examples/README.md similarity index 100% rename from examples/webhook-server/README.md rename to examples/README.md diff --git a/key.js b/examples/generate-keys.ts similarity index 72% rename from key.js rename to examples/generate-keys.ts index 9f44ebe..b5c6f39 100644 --- a/key.js +++ b/examples/generate-keys.ts @@ -1,22 +1,22 @@ -const nacl = require('tweetnacl') -const crypto = require('crypto') // For UUID generation +import * as nacl from 'tweetnacl' +import crypto from 'crypto' // Generate Ed25519 keypair using NaCl const generateKeysAndJWKS = () => { - // Generate a key ID (kid) - two options: - // 1. UUID-based (cryptographically random) const generateUuidKid = () => { return crypto.randomUUID() } const keypair = nacl.sign.keyPair() - // console.log('keypair', keypair) + + // generate timestamp to epoch + const timestamp = Math.floor(Date.now() / 1000) // Convert keys to Base64 for storage/display const publicKeyBase64 = Buffer.from(keypair.publicKey).toString('base64') const privateKeyBase64 = Buffer.from(keypair.secretKey).toString('base64') - // Convert public key to base64url format (required for JWK) + // Convert public key to base64url format (required for JWKS format) const publicKeyBase64Url = publicKeyBase64 .replace(/\+/g, '-') .replace(/\//g, '_') @@ -26,13 +26,11 @@ const generateKeysAndJWKS = () => { privateKey: privateKeyBase64, publicKey: publicKeyBase64, publicKeyBase64Url: publicKeyBase64Url, - kid: generateUuidKid(), + kid: `${timestamp}-${generateUuidKid()}`, } } -// Run the function and output results const result = generateKeysAndJWKS() - console.log('Generated Ed25519 keypair:') console.log('-------------------------') console.log('Private key (base64):') @@ -41,5 +39,5 @@ console.log('\nPublic key (base64):') console.log(result.publicKey) console.log('\nPublic key (base64url, what to put in JWKS):') console.log(result.publicKeyBase64Url) -console.log('\nKey ID (UUID format):') +console.log('\nExample Key ID (UUID format):') console.log(result.kid) diff --git a/examples/webhook-server/package-lock.json b/examples/package-lock.json similarity index 100% rename from examples/webhook-server/package-lock.json rename to examples/package-lock.json diff --git a/examples/webhook-server/package.json b/examples/package.json similarity index 83% rename from examples/webhook-server/package.json rename to examples/package.json index ddc3d09..c905a86 100644 --- a/examples/webhook-server/package.json +++ b/examples/package.json @@ -5,7 +5,8 @@ "main": "webhook-server.ts", "scripts": { "start": "nodemon webhook-server.ts", - "start:ngrok": "ngrok http 3000" + "start:ngrok": "ngrok http 3000", + "generate-keys": "ts-node generate-keys.ts" }, "dependencies": { "express": "^4.18.2", diff --git a/examples/webhook-server/tsconfig.json b/examples/tsconfig.json similarity index 100% rename from examples/webhook-server/tsconfig.json rename to examples/tsconfig.json diff --git a/examples/webhook-server/webhook-server.ts b/examples/webhook-server.ts similarity index 99% rename from examples/webhook-server/webhook-server.ts rename to examples/webhook-server.ts index 36af5ad..f8c62ce 100644 --- a/examples/webhook-server/webhook-server.ts +++ b/examples/webhook-server.ts @@ -1,6 +1,6 @@ import express from 'express' import * as dotenv from 'dotenv' -import formSgSDK from '../../src' +import formSgSDK from '../src' dotenv.config() From 9b26722d2ebca3eb4bbd17223669bc98b59a5728 Mon Sep 17 00:00:00 2001 From: littlemight Date: Sat, 8 Mar 2025 13:35:22 +0800 Subject: [PATCH 34/38] test: key fallbacks --- spec/util/keys.spec.ts | 190 +++++++++++++++++++++++++++++++++++++++++ src/index.ts | 10 +-- src/util/keys.ts | 15 ++-- 3 files changed, 205 insertions(+), 10 deletions(-) create mode 100644 spec/util/keys.spec.ts diff --git a/spec/util/keys.spec.ts b/spec/util/keys.spec.ts new file mode 100644 index 0000000..e08080d --- /dev/null +++ b/spec/util/keys.spec.ts @@ -0,0 +1,190 @@ +import { JwksConfig } from '../../src/types' +import { getPublicKeys } from '../../src/util/keys' +import * as jwksModule from '../../src/util/jwks' +import * as publicKeyModule from '../../src/util/publicKey' +import { mock } from 'node:test' + +// Mock imported modules +jest.mock('../../src/util/jwks') +jest.mock('../../src/util/publicKey') + +describe('getPublicKeys', () => { + const mockJwks: JwksConfig = { url: 'https://example.com/jwks.json' } + const mockWebhookPublicKey = 'mock-webhook-public-key' + const mockVerificationPublicKey = 'mock-verification-public-key' + const mockSigningKey = 'mock-signing-key' + const mockVerificationKey = 'mock-verification-key' + const mockJwksSigningKeys = [ + 'mock-jwks-signing-key-1', + 'mock-jwks-signing-key-2', + ] + const mockJwksVerificationKeys = [ + 'mock-jwks-verification-key-1', + 'mock-jwks-verification-key-2', + ] + + beforeEach(() => { + jest.resetAllMocks() + + // Mock jwks module + jest.mocked(jwksModule.initJwks).mockResolvedValue(undefined) + jest + .mocked(jwksModule.getSigningPublicKeysFromJwks) + .mockResolvedValue(mockJwksSigningKeys) + jest + .mocked(jwksModule.getVerificationPublicKeysFromJwks) + .mockResolvedValue(mockJwksVerificationKeys) + + // Mock publicKey module + jest + .mocked(publicKeyModule.getSigningPublicKey) + .mockReturnValue(mockSigningKey) + jest + .mocked(publicKeyModule.getVerificationPublicKey) + .mockReturnValue(mockVerificationKey) + }) + + describe('signingPublicKeys', () => { + it('should return keys from JWKS when JWKS URL is provided', async () => { + const { signingPublicKeys } = await getPublicKeys({ jwks: mockJwks }) + const keys = await signingPublicKeys() + + expect(jwksModule.initJwks).toHaveBeenCalledWith(mockJwks) + expect(jwksModule.getSigningPublicKeysFromJwks).toHaveBeenCalled() + expect(keys).toEqual(mockJwksSigningKeys) + }) + + it('should pass keyId to getSigningPublicKeysFromJwks when provided', async () => { + const keyId = 'test-key-id' + const { signingPublicKeys } = await getPublicKeys({ jwks: mockJwks }) + await signingPublicKeys(keyId) + + expect(jwksModule.getSigningPublicKeysFromJwks).toHaveBeenCalledWith( + keyId + ) + }) + + it('should return webhookPublicKey when JWKS URL is not provided but webhookPublicKey is', async () => { + const { signingPublicKeys } = await getPublicKeys({ + webhookPublicKey: mockWebhookPublicKey, + }) + const keys = await signingPublicKeys() + + expect(jwksModule.initJwks).not.toHaveBeenCalled() + expect(keys).toEqual([mockWebhookPublicKey]) + }) + + it('should return default signing key when neither JWKS URL nor webhookPublicKey are provided', async () => { + const mode = 'production' + const { signingPublicKeys } = await getPublicKeys({ mode: mode }) + const keys = await signingPublicKeys() + + expect(publicKeyModule.getSigningPublicKey).toHaveBeenCalledWith(mode) + expect(keys).toEqual([mockSigningKey]) + }) + + it('should fallback to webhookPublicKey when JWKS retrieval fails', async () => { + ;(jwksModule.getSigningPublicKeysFromJwks as jest.Mock).mockRejectedValue( + new Error('JWKS error') + ) + const { signingPublicKeys } = await getPublicKeys({ + jwks: mockJwks, + webhookPublicKey: mockWebhookPublicKey, + }) + const keys = await signingPublicKeys() + + expect(jwksModule.getSigningPublicKeysFromJwks).toHaveBeenCalled() + expect(keys).toEqual([mockWebhookPublicKey]) + }) + + it('should fallback to default key when JWKS fails and no webhookPublicKey is provided', async () => { + ;(jwksModule.getSigningPublicKeysFromJwks as jest.Mock).mockRejectedValue( + new Error('JWKS error') + ) + const mode = 'production' + const { signingPublicKeys } = await getPublicKeys({ + jwks: mockJwks, + mode: mode, + }) + const keys = await signingPublicKeys() + + expect(jwksModule.getSigningPublicKeysFromJwks).toHaveBeenCalled() + expect(publicKeyModule.getSigningPublicKey).toHaveBeenCalledWith(mode) + expect(keys).toEqual([mockSigningKey]) + }) + }) + + describe('verificationPublicKeys', () => { + it('should return keys from JWKS when JWKS URL is provided', async () => { + const { verificationPublicKeys } = await getPublicKeys({ jwks: mockJwks }) + const keys = await verificationPublicKeys() + + expect(jwksModule.initJwks).toHaveBeenCalledWith(mockJwks) + expect(jwksModule.getVerificationPublicKeysFromJwks).toHaveBeenCalled() + expect(keys).toEqual(mockJwksVerificationKeys) + }) + + it('should pass keyId to getVerificationPublicKeysFromJwks when provided', async () => { + const keyId = 'test-key-id' + const { verificationPublicKeys } = await getPublicKeys({ jwks: mockJwks }) + await verificationPublicKeys(keyId) + + expect(jwksModule.getVerificationPublicKeysFromJwks).toHaveBeenCalledWith( + keyId + ) + }) + + it('should return verificationPublicKey when JWKS URL is not provided but verificationPublicKey is', async () => { + const { verificationPublicKeys } = await getPublicKeys({ + verificationPublicKey: mockVerificationPublicKey, + }) + const keys = await verificationPublicKeys() + + expect(jwksModule.initJwks).not.toHaveBeenCalled() + expect(keys).toEqual([mockVerificationPublicKey]) + }) + + it('should return default verification key when neither JWKS URL nor verificationPublicKey are provided', async () => { + const mode = 'production' + const { verificationPublicKeys } = await getPublicKeys({ mode: mode }) + const keys = await verificationPublicKeys() + + expect(publicKeyModule.getVerificationPublicKey).toHaveBeenCalledWith( + mode + ) + expect(keys).toEqual([mockVerificationKey]) + }) + + it('should fallback to verificationPublicKey when JWKS retrieval fails', async () => { + ;( + jwksModule.getVerificationPublicKeysFromJwks as jest.Mock + ).mockRejectedValue(new Error('JWKS error')) + const { verificationPublicKeys } = await getPublicKeys({ + jwks: mockJwks, + verificationPublicKey: mockVerificationPublicKey, + }) + const keys = await verificationPublicKeys() + + expect(jwksModule.getVerificationPublicKeysFromJwks).toHaveBeenCalled() + expect(keys).toEqual([mockVerificationPublicKey]) + }) + + it('should fallback to default key when JWKS fails and no verificationPublicKey is provided', async () => { + ;( + jwksModule.getVerificationPublicKeysFromJwks as jest.Mock + ).mockRejectedValue(new Error('JWKS error')) + const mode = 'production' + const { verificationPublicKeys } = await getPublicKeys({ + jwks: mockJwks, + mode: mode, + }) + const keys = await verificationPublicKeys() + + expect(jwksModule.getVerificationPublicKeysFromJwks).toHaveBeenCalled() + expect(publicKeyModule.getVerificationPublicKey).toHaveBeenCalledWith( + mode + ) + expect(keys).toEqual([mockVerificationKey]) + }) + }) +}) diff --git a/src/index.ts b/src/index.ts index e4be704..fc8670f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -29,12 +29,12 @@ export = async function (config: PackageInitParams = {}): Promise<{ * * Both keys are fetched from the JWKS endpoint if provided, else they are fetched from the static public keys. */ - const keyGetters = await getPublicKeys( + const keyGetters = await getPublicKeys({ jwks, - webhookOptions?.publicKey, - verificationOptions?.publicKey, - mode - ) + webhookPublicKey: webhookOptions?.publicKey, + verificationPublicKey: verificationOptions?.publicKey, + mode, + }) return { webhooks: new Webhooks({ diff --git a/src/util/keys.ts b/src/util/keys.ts index b55c530..ce09ccf 100644 --- a/src/util/keys.ts +++ b/src/util/keys.ts @@ -7,12 +7,17 @@ import { } from './jwks' import { getSigningPublicKey, getVerificationPublicKey } from './publicKey' -export const getPublicKeys = async ( - jwks?: JwksConfig, - webhookPublicKey?: string, - verificationPublicKey?: string, +export const getPublicKeys = async ({ + jwks, + webhookPublicKey, + verificationPublicKey, + mode, +}: { + jwks?: JwksConfig + webhookPublicKey?: string + verificationPublicKey?: string mode?: PackageMode -): Promise<{ +}): Promise<{ signingPublicKeys: (keyId?: string) => Promise verificationPublicKeys: (keyId?: string) => Promise }> => { From 16809ef22d0f0694f6d38fe3884dec7ab58b08cd Mon Sep 17 00:00:00 2001 From: littlemight Date: Sat, 8 Mar 2025 21:12:10 +0800 Subject: [PATCH 35/38] docs: comment regarding compile and runtime validation --- src/util/jwks.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/util/jwks.ts b/src/util/jwks.ts index 118c7a5..34ffdf2 100644 --- a/src/util/jwks.ts +++ b/src/util/jwks.ts @@ -83,9 +83,12 @@ const getJwks = async (getJwksOptions?: { } try { - const { data } = await axios.get(jwksConfig.url, { + // NOTE: is compile-time type assertion, if the endpoint returns a malformed response, it will still throw an error + const { data } = await axios.get(jwksConfig.url, { timeout: jwksConfig.requestConfig?.timeoutMs ?? DEFAULT_JWKS_TIMEOUT_MS, }) + + // NOTE: better if we do runtime validation here using zod/ajv, but that would need to import a new dependency jwksCache.set(data) return data From 080bd8b65e4222edb3cd069088c31cb3e6b297bf Mon Sep 17 00:00:00 2001 From: littlemight Date: Mon, 10 Mar 2025 04:52:17 +0800 Subject: [PATCH 36/38] docs: b64 vs b64url diff --- MIGRATION.md | 41 +++++++++++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index b28a98b..a0df635 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -56,10 +56,19 @@ The key is encoded in base64url format as per the JWKS specification. This encod So a base64 key such as ``` +Tl5gfszlKcQj99/0uafLwVpT6JAu4C0dHGvLq1cHzFE= ``` In base64url it would be ``` +Tl5gfszlKcQj99_0uafLwVpT6JAu4C0dHGvLq1cHzFE +``` + +Notice the difference +``` +```diff +- Tl5gfszlKcQj99/0uafLwVpT6JAu4C0dHGvLq1cHzFE= ++ Tl5gfszlKcQj99_0uafLwVpT6JAu4C0dHGvLq1cHzFE ``` #### What happens during key rotation? @@ -91,6 +100,7 @@ const formsg = new FormSgSdk({ mode: 'production', ... }) +todo... ``` #### What happens during a key rotation? @@ -98,16 +108,31 @@ When using custom keys, the SDK instance does not automatically update when keys ### Hardcoded FormSG keys -### Key Fetching Fallback Order -Cache -> JWKS (if provided) -> Injected static custom keys (if provided) -> Hardcoded keys -The hardcoded keys will slowly be phased out in the future, since 1.0.0 is not in pilot yet. +### Key Resolution Strategy +The SDK follows a hierarchical approach to resolving keys: + +1. **In-memory Cache**: First checks for cached keys to minimize network requests +2. **JWKS Endpoint**: If cache misses or verification fails, fetches fresh keys from the JWKS endpoint (when configured) +3. **Custom Injected Keys**: Falls back to keys provided during SDK initialization (if available) +4. **Hardcoded Keys**: As a final fallback, uses built-in keys (these will be deprecated in future versions) + +This strategy ensures maximum reliability while transitioning to the new key management system. Note that hardcoded keys will be gradually phased out once version 1.0.0 is fully adopted. ## Method Changes -### Sync -table -### Async -table +| 0.x.x | 1.0.0 | Notes | +|-------|-------|-------| +| `some.method.before (sync)` | `some.method.after (async)` | The method is now part of the webhook verifier class | ## Example Migrations -some code +```typescript +// 0.x.x +const { FormSgSdk } = require('@opengovsg/formsg-sdk') +const formsg = FormSgSdk() +todo... + +// 1.0.0 +const { FormSgSdk } = require('@opengovsg/formsg-sdk') +const formsg = new FormSgSdk() +todo... +``` From 848cee4f761ff92b3bb86f7ad6c596f83ed3dc7a Mon Sep 17 00:00:00 2001 From: littlemight Date: Wed, 12 Mar 2025 02:51:27 +0800 Subject: [PATCH 37/38] fix(test): trust me bro --- jest.config.js | 2 +- package-lock.json | 529 ++++++++++++++++++++++++++++++++++------------ package.json | 3 +- 3 files changed, 401 insertions(+), 133 deletions(-) diff --git a/jest.config.js b/jest.config.js index f01ddfe..c4107de 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,5 +1,5 @@ module.exports = { - transform: { '^.+\\.ts?$': 'ts-jest' }, + transform: { '^.+\\.ts?$': '@swc/jest' }, testEnvironment: 'node', testRegex: '/spec/.*\\.(test|spec)?\\.(ts|tsx)$', moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], diff --git a/package-lock.json b/package-lock.json index 73078d3..96df85b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,8 @@ "@babel/cli": "^7.8.4", "@babel/core": "^7.9.0", "@babel/preset-env": "^7.9.0", + "@swc/core": "^1.11.8", + "@swc/jest": "^0.2.37", "@types/jest": "^29.5.8", "@types/node": "^18.18.9", "@typescript-eslint/eslint-plugin": "^4.25.0", @@ -32,7 +34,6 @@ "jest": "^29.7.0", "jest-mock-axios": "^4.7.3", "nock": "^14.0.1", - "ts-jest": "^29.1.1", "typescript": "^4.9.5" } }, @@ -1849,6 +1850,19 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/@jest/create-cache-key-function": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/create-cache-key-function/-/create-cache-key-function-29.7.0.tgz", + "integrity": "sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/@jest/environment": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", @@ -2412,6 +2426,250 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@swc/core": { + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.11.8.tgz", + "integrity": "sha512-UAL+EULxrc0J73flwYHfu29mO8CONpDJiQv1QPDXsyCvDUcEhqAqUROVTgC+wtJCFFqMQdyr4stAA5/s0KSOmA==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.19" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.11.8", + "@swc/core-darwin-x64": "1.11.8", + "@swc/core-linux-arm-gnueabihf": "1.11.8", + "@swc/core-linux-arm64-gnu": "1.11.8", + "@swc/core-linux-arm64-musl": "1.11.8", + "@swc/core-linux-x64-gnu": "1.11.8", + "@swc/core-linux-x64-musl": "1.11.8", + "@swc/core-win32-arm64-msvc": "1.11.8", + "@swc/core-win32-ia32-msvc": "1.11.8", + "@swc/core-win32-x64-msvc": "1.11.8" + }, + "peerDependencies": { + "@swc/helpers": "*" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.11.8.tgz", + "integrity": "sha512-rrSsunyJWpHN+5V1zumndwSSifmIeFQBK9i2RMQQp15PgbgUNxHK5qoET1n20pcUrmZeT6jmJaEWlQchkV//Og==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.11.8.tgz", + "integrity": "sha512-44goLqQuuo0HgWnG8qC+ZFw/qnjCVVeqffhzFr9WAXXotogVaxM8ze6egE58VWrfEc8me8yCcxOYL9RbtjhS/Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.11.8.tgz", + "integrity": "sha512-Mzo8umKlhTWwF1v8SLuTM1z2A+P43UVhf4R8RZDhzIRBuB2NkeyE+c0gexIOJBuGSIATryuAF4O4luDu727D1w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.11.8.tgz", + "integrity": "sha512-EyhO6U+QdoGYC1MeHOR0pyaaSaKYyNuT4FQNZ1eZIbnuueXpuICC7iNmLIOfr3LE5bVWcZ7NKGVPlM2StJEcgA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.11.8.tgz", + "integrity": "sha512-QU6wOkZnS6/QuBN1MHD6G2BgFxB0AclvTVGbqYkRA7MsVkcC29PffESqzTXnypzB252/XkhQjoB2JIt9rPYf6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.11.8.tgz", + "integrity": "sha512-r72onUEIU1iJi9EUws3R28pztQ/eM3EshNpsPRBfuLwKy+qn3et55vXOyDhIjGCUph5Eg2Yn8H3h6MTxDdLd+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.11.8.tgz", + "integrity": "sha512-294k8cLpO103++f4ZUEDr3vnBeUfPitW6G0a3qeVZuoXFhFgaW7ANZIWknUc14WiLOMfMecphJAEiy9C8OeYSw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.11.8.tgz", + "integrity": "sha512-EbjOzQ+B85rumHyeesBYxZ+hq3ZQn+YAAT1ZNE9xW1/8SuLoBmHy/K9YniRGVDq/2NRmp5kI5+5h5TX0asIS9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.11.8.tgz", + "integrity": "sha512-Z+FF5kgLHfQWIZ1KPdeInToXLzbY0sMAashjd/igKeP1Lz0qKXVAK+rpn6ASJi85Fn8wTftCGCyQUkRVn0bTDg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.11.8.tgz", + "integrity": "sha512-j6B6N0hChCeAISS6xp/hh6zR5CSCr037BAjCxNLsT8TGe5D+gYZ57heswUWXRH8eMKiRDGiLCYpPB2pkTqxCSw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@swc/jest": { + "version": "0.2.37", + "resolved": "https://registry.npmjs.org/@swc/jest/-/jest-0.2.37.tgz", + "integrity": "sha512-CR2BHhmXKGxTiFr21DYPRHQunLkX3mNIFGFkxBGji6r9uyIR5zftTOVYj1e0sFNMV2H7mf/+vpaglqaryBtqfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/create-cache-key-function": "^29.7.0", + "@swc/counter": "^0.1.3", + "jsonc-parser": "^3.2.0" + }, + "engines": { + "npm": ">= 7.0.0" + }, + "peerDependencies": { + "@swc/core": "*" + } + }, + "node_modules/@swc/types": { + "version": "0.1.19", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.19.tgz", + "integrity": "sha512-WkAZaAfj44kh/UFdAQcrMP1I0nwRqpt27u+08LMBYMqmQfwwMofYoMh/48NGkMMRfC4ynpfwRbJuu8ErfNloeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, "node_modules/@types/babel__core": { "version": "7.1.16", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.16.tgz", @@ -3363,18 +3621,6 @@ "url": "https://opencollective.com/browserslist" } }, - "node_modules/bs-logger": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", - "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", - "dev": true, - "dependencies": { - "fast-json-stable-stringify": "2.x" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/bser": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", @@ -5508,6 +5754,7 @@ "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, + "license": "MIT", "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -6972,6 +7219,13 @@ "node": ">=6" } }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, "node_modules/jsprim": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", @@ -7089,12 +7343,6 @@ "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=", "dev": true }, - "node_modules/lodash.memoize": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", - "integrity": "sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=", - "dev": true - }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -7153,12 +7401,6 @@ "node": ">=6" } }, - "node_modules/make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true - }, "node_modules/makeerror": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", @@ -8584,64 +8826,6 @@ "node": ">=8.0" } }, - "node_modules/ts-jest": { - "version": "29.1.1", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.1.1.tgz", - "integrity": "sha512-D6xjnnbP17cC85nliwGiL+tpoKN0StpgE0TeOjXQTU6MVCfsB4v7aW05CgQ/1OywGb0x/oy9hHFnN+sczTiRaA==", - "dev": true, - "dependencies": { - "bs-logger": "0.x", - "fast-json-stable-stringify": "2.x", - "jest-util": "^29.0.0", - "json5": "^2.2.3", - "lodash.memoize": "4.x", - "make-error": "1.x", - "semver": "^7.5.3", - "yargs-parser": "^21.0.1" - }, - "bin": { - "ts-jest": "cli.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - }, - "peerDependencies": { - "@babel/core": ">=7.0.0-beta.0 <8", - "@jest/types": "^29.0.0", - "babel-jest": "^29.0.0", - "jest": "^29.0.0", - "typescript": ">=4.3 <6" - }, - "peerDependenciesMeta": { - "@babel/core": { - "optional": true - }, - "@jest/types": { - "optional": true - }, - "babel-jest": { - "optional": true - }, - "esbuild": { - "optional": true - } - } - }, - "node_modules/ts-jest/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/tsconfig-paths": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.9.0.tgz", @@ -10448,6 +10632,15 @@ } } }, + "@jest/create-cache-key-function": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/create-cache-key-function/-/create-cache-key-function-29.7.0.tgz", + "integrity": "sha512-4QqS3LY5PBmTRHj9sAg1HLoPzqAI0uOX6wI/TRqHIcOxlFidy6YEmCQJk6FSZjNLGCeubDMfmkWL+qaLKhSGQA==", + "dev": true, + "requires": { + "@jest/types": "^29.6.3" + } + }, "@jest/environment": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", @@ -10896,6 +11089,122 @@ "@sinonjs/commons": "^3.0.0" } }, + "@swc/core": { + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.11.8.tgz", + "integrity": "sha512-UAL+EULxrc0J73flwYHfu29mO8CONpDJiQv1QPDXsyCvDUcEhqAqUROVTgC+wtJCFFqMQdyr4stAA5/s0KSOmA==", + "dev": true, + "requires": { + "@swc/core-darwin-arm64": "1.11.8", + "@swc/core-darwin-x64": "1.11.8", + "@swc/core-linux-arm-gnueabihf": "1.11.8", + "@swc/core-linux-arm64-gnu": "1.11.8", + "@swc/core-linux-arm64-musl": "1.11.8", + "@swc/core-linux-x64-gnu": "1.11.8", + "@swc/core-linux-x64-musl": "1.11.8", + "@swc/core-win32-arm64-msvc": "1.11.8", + "@swc/core-win32-ia32-msvc": "1.11.8", + "@swc/core-win32-x64-msvc": "1.11.8", + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.19" + } + }, + "@swc/core-darwin-arm64": { + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.11.8.tgz", + "integrity": "sha512-rrSsunyJWpHN+5V1zumndwSSifmIeFQBK9i2RMQQp15PgbgUNxHK5qoET1n20pcUrmZeT6jmJaEWlQchkV//Og==", + "dev": true, + "optional": true + }, + "@swc/core-darwin-x64": { + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.11.8.tgz", + "integrity": "sha512-44goLqQuuo0HgWnG8qC+ZFw/qnjCVVeqffhzFr9WAXXotogVaxM8ze6egE58VWrfEc8me8yCcxOYL9RbtjhS/Q==", + "dev": true, + "optional": true + }, + "@swc/core-linux-arm-gnueabihf": { + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.11.8.tgz", + "integrity": "sha512-Mzo8umKlhTWwF1v8SLuTM1z2A+P43UVhf4R8RZDhzIRBuB2NkeyE+c0gexIOJBuGSIATryuAF4O4luDu727D1w==", + "dev": true, + "optional": true + }, + "@swc/core-linux-arm64-gnu": { + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.11.8.tgz", + "integrity": "sha512-EyhO6U+QdoGYC1MeHOR0pyaaSaKYyNuT4FQNZ1eZIbnuueXpuICC7iNmLIOfr3LE5bVWcZ7NKGVPlM2StJEcgA==", + "dev": true, + "optional": true + }, + "@swc/core-linux-arm64-musl": { + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.11.8.tgz", + "integrity": "sha512-QU6wOkZnS6/QuBN1MHD6G2BgFxB0AclvTVGbqYkRA7MsVkcC29PffESqzTXnypzB252/XkhQjoB2JIt9rPYf6A==", + "dev": true, + "optional": true + }, + "@swc/core-linux-x64-gnu": { + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.11.8.tgz", + "integrity": "sha512-r72onUEIU1iJi9EUws3R28pztQ/eM3EshNpsPRBfuLwKy+qn3et55vXOyDhIjGCUph5Eg2Yn8H3h6MTxDdLd+w==", + "dev": true, + "optional": true + }, + "@swc/core-linux-x64-musl": { + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.11.8.tgz", + "integrity": "sha512-294k8cLpO103++f4ZUEDr3vnBeUfPitW6G0a3qeVZuoXFhFgaW7ANZIWknUc14WiLOMfMecphJAEiy9C8OeYSw==", + "dev": true, + "optional": true + }, + "@swc/core-win32-arm64-msvc": { + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.11.8.tgz", + "integrity": "sha512-EbjOzQ+B85rumHyeesBYxZ+hq3ZQn+YAAT1ZNE9xW1/8SuLoBmHy/K9YniRGVDq/2NRmp5kI5+5h5TX0asIS9A==", + "dev": true, + "optional": true + }, + "@swc/core-win32-ia32-msvc": { + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.11.8.tgz", + "integrity": "sha512-Z+FF5kgLHfQWIZ1KPdeInToXLzbY0sMAashjd/igKeP1Lz0qKXVAK+rpn6ASJi85Fn8wTftCGCyQUkRVn0bTDg==", + "dev": true, + "optional": true + }, + "@swc/core-win32-x64-msvc": { + "version": "1.11.8", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.11.8.tgz", + "integrity": "sha512-j6B6N0hChCeAISS6xp/hh6zR5CSCr037BAjCxNLsT8TGe5D+gYZ57heswUWXRH8eMKiRDGiLCYpPB2pkTqxCSw==", + "dev": true, + "optional": true + }, + "@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "dev": true + }, + "@swc/jest": { + "version": "0.2.37", + "resolved": "https://registry.npmjs.org/@swc/jest/-/jest-0.2.37.tgz", + "integrity": "sha512-CR2BHhmXKGxTiFr21DYPRHQunLkX3mNIFGFkxBGji6r9uyIR5zftTOVYj1e0sFNMV2H7mf/+vpaglqaryBtqfQ==", + "dev": true, + "requires": { + "@jest/create-cache-key-function": "^29.7.0", + "@swc/counter": "^0.1.3", + "jsonc-parser": "^3.2.0" + } + }, + "@swc/types": { + "version": "0.1.19", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.19.tgz", + "integrity": "sha512-WkAZaAfj44kh/UFdAQcrMP1I0nwRqpt27u+08LMBYMqmQfwwMofYoMh/48NGkMMRfC4ynpfwRbJuu8ErfNloeA==", + "dev": true, + "requires": { + "@swc/counter": "^0.1.3" + } + }, "@types/babel__core": { "version": "7.1.16", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.1.16.tgz", @@ -11599,15 +11908,6 @@ "picocolors": "^1.0.0" } }, - "bs-logger": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", - "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", - "dev": true, - "requires": { - "fast-json-stable-stringify": "2.x" - } - }, "bser": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", @@ -14320,6 +14620,12 @@ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "dev": true }, + "jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true + }, "jsprim": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", @@ -14412,12 +14718,6 @@ "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=", "dev": true }, - "lodash.memoize": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", - "integrity": "sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=", - "dev": true - }, "lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -14464,12 +14764,6 @@ "semver": "^5.6.0" } }, - "make-error": { - "version": "1.3.6", - "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", - "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", - "dev": true - }, "makeerror": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", @@ -15544,33 +15838,6 @@ "is-number": "^7.0.0" } }, - "ts-jest": { - "version": "29.1.1", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.1.1.tgz", - "integrity": "sha512-D6xjnnbP17cC85nliwGiL+tpoKN0StpgE0TeOjXQTU6MVCfsB4v7aW05CgQ/1OywGb0x/oy9hHFnN+sczTiRaA==", - "dev": true, - "requires": { - "bs-logger": "0.x", - "fast-json-stable-stringify": "2.x", - "jest-util": "^29.0.0", - "json5": "^2.2.3", - "lodash.memoize": "4.x", - "make-error": "1.x", - "semver": "^7.5.3", - "yargs-parser": "^21.0.1" - }, - "dependencies": { - "semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dev": true, - "requires": { - "lru-cache": "^6.0.0" - } - } - } - }, "tsconfig-paths": { "version": "3.9.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.9.0.tgz", diff --git a/package.json b/package.json index 3cdf63f..1e647c2 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,8 @@ "@babel/cli": "^7.8.4", "@babel/core": "^7.9.0", "@babel/preset-env": "^7.9.0", + "@swc/core": "^1.11.8", + "@swc/jest": "^0.2.37", "@types/jest": "^29.5.8", "@types/node": "^18.18.9", "@typescript-eslint/eslint-plugin": "^4.25.0", @@ -47,7 +49,6 @@ "jest": "^29.7.0", "jest-mock-axios": "^4.7.3", "nock": "^14.0.1", - "ts-jest": "^29.1.1", "typescript": "^4.9.5" } } From 705251e02902e85989e3e01d941bc52790806c34 Mon Sep 17 00:00:00 2001 From: littlemight Date: Wed, 12 Mar 2025 03:02:13 +0800 Subject: [PATCH 38/38] chore(ci): don't be greedy --- .github/workflows/ci.yml | 2 -- jest.config.js | 1 + package.json | 6 +++--- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 69b93b2..3ddc191 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,8 +45,6 @@ jobs: ${{ runner.OS }}- - run: npm ci - run: npm run test-ci - env: - NODE_OPTIONS: '--max-old-space-size=8192' - name: Submit test coverage to Coveralls uses: coverallsapp/github-action@v1.1.2 with: diff --git a/jest.config.js b/jest.config.js index c4107de..70ccd34 100644 --- a/jest.config.js +++ b/jest.config.js @@ -10,4 +10,5 @@ module.exports = { functions: 80, }, }, + workerIdleMemoryLimit: '512MB', } diff --git a/package.json b/package.json index 1e647c2..1a351ba 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,9 @@ "main": "./dist/index.js", "types": "./dist/index.d.ts", "scripts": { - "test": "NODE_OPTIONS=\"--max-old-space-size=8192\" jest", - "test-ci": "jest --coverage", - "test-watch": "jest --watch", + "test": "jest --logHeapUsage", + "test-ci": "jest --coverage --logHeapUsage", + "test-watch": "jest --watch --logHeapUsage", "build": "tsc", "prepare": "npm run build", "version": "auto-changelog -p && git add CHANGELOG.md"