Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/cuddly-tires-tan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@fingerprint/aws-cloudfront-proxy': minor
---

Introduce TTL for secret caching
3 changes: 2 additions & 1 deletion proxy/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { CloudFrontRequest } from 'aws-lambda/common/cloudfront'
import { createIngressHandler } from './handlers/handleIngress'
import { handleStatus } from './handlers/handleStatus'
import { V4_INGRESS_PATH } from './utils/paths'
import { getSecretCacheTtlMs } from './utils/headers'

export type Route = {
pathPattern: RegExp
Expand Down Expand Up @@ -57,7 +58,7 @@ export const handler = async (event: CloudFrontRequestEvent): Promise<CloudFront
setLogLevel(request)

const customerVariables = new CustomerVariables([
new SecretsManagerVariables(request),
new SecretsManagerVariables(request, getSecretCacheTtlMs(request)),
new HeaderCustomerVariables(request),
])

Expand Down
118 changes: 118 additions & 0 deletions proxy/test/utils/cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { TTLCache } from '../../utils/cache'

describe('TTLCache', () => {
beforeEach(() => {
jest.useFakeTimers()
})

afterEach(() => {
jest.useRealTimers()
})

test('should store and retrieve values', () => {
const cache = new TTLCache<string, string>(1000)
cache.set('key1', 'value1')

expect(cache.get('key1')).toBe('value1')
expect(cache.has('key1')).toBe(true)
})

test('should return undefined for missing keys', () => {
const cache = new TTLCache<string, string>(1000)

expect(cache.get('missingKey')).toBeUndefined()
expect(cache.has('missingKey')).toBe(false)
})

test('should expire items after default TTL', () => {
const cache = new TTLCache<string, string>(1000)
cache.set('key1', 'value1')

expect(cache.get('key1')).toBe('value1')

// Advance time past TTL
jest.advanceTimersByTime(1001)

expect(cache.get('key1')).toBeUndefined()
expect(cache.has('key1')).toBe(false)
})

test('should expire items after default TTL for null values', () => {
const cache = new TTLCache<string, string | null>(1000)
cache.set('key1', null)

expect(cache.get('key1')).toBeNull()
expect(cache.has('key1')).toBe(true)

// Advance time past TTL
jest.advanceTimersByTime(1001)

expect(cache.get('key1')).toBeUndefined()
expect(cache.has('key1')).toBe(false)
})

test('should respect custom TTL per item', () => {
const cache = new TTLCache<string, string>(1000)

// Item with shorter TTL
cache.set('key1', 'value1', 500)
// Item with longer TTL
cache.set('key2', 'value2', 2000)

// Advance past first item's TTL but before second item's TTL
jest.advanceTimersByTime(1000)

expect(cache.get('key1')).toBeUndefined()
expect(cache.get('key2')).toBe('value2')

// Advance past second item's TTL
jest.advanceTimersByTime(1001)

expect(cache.get('key2')).toBeUndefined()
})

test('should fallback to default TTL if passed TTL is NaN', () => {
const cache = new TTLCache<string, string>(1000)

cache.set('key1', 'value1', NaN)
expect(cache.get('key1')).toBe('value1')

jest.advanceTimersByTime(1001)

expect(cache.get('key1')).toBeUndefined()
})

test('should delete items', () => {
const cache = new TTLCache<string, string>(1000)
cache.set('key1', 'value1')

expect(cache.get('key1')).toBe('value1')

cache.delete('key1')

expect(cache.get('key1')).toBeUndefined()
expect(cache.has('key1')).toBe(false)
})

test('should clear all items', () => {
const cache = new TTLCache<string, string>(1000)
cache.set('key1', 'value1')
cache.set('key2', 'value2')

cache.clear()

expect(cache.get('key1')).toBeUndefined()
expect(cache.get('key2')).toBeUndefined()
})

test('has() should trigger eviction if item is expired', () => {
const cache = new TTLCache<string, string>(1000)
cache.set('key1', 'value1')

jest.advanceTimersByTime(1001)

expect(cache.has('key1')).toBe(false)
// Underlying map should have been cleaned up by has() which calls get()
expect(cache.get('key1')).toBeUndefined()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,16 @@ const client = new SecretsManagerClient({})

describe('retrieve secret', () => {
beforeEach(() => {
jest.useFakeTimers()
clearSecretsCache()

mock.reset()
})

afterEach(() => {
jest.useRealTimers()
})

it('caches result even if it is null', async () => {
mock
.on(GetSecretValueCommand, {
Expand All @@ -27,6 +32,26 @@ describe('retrieve secret', () => {
expect(mock).toHaveReceivedCommandTimes(GetSecretValueCommand, 1)
})

it('refetches secret after cache expires', async () => {
mock
.on(GetSecretValueCommand, {
SecretId: secretName,
})
.resolves({})

await retrieveSecret(client, secretName)
await retrieveSecret(client, secretName)

expect(mock).toHaveReceivedCommandTimes(GetSecretValueCommand, 1)

jest.advanceTimersByTime(500_001)

await retrieveSecret(client, secretName)
await retrieveSecret(client, secretName)

expect(mock).toHaveReceivedCommandTimes(GetSecretValueCommand, 2)
})

it('caches result even if it secrets manager throws', async () => {
mock
.on(GetSecretValueCommand, {
Expand Down
56 changes: 56 additions & 0 deletions proxy/utils/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
interface CacheItem<T> {
value: T
expiresAt: number
}

export class TTLCache<K, V> {
private cache: Map<K, CacheItem<V>>
private readonly ttlMs: number

// Default TTL is 5 minutes
private static readonly DEFAULT_TTL_MS = 300_000

constructor(ttlMs: number) {
this.cache = new Map()
this.ttlMs = TTLCache.isValidTTL(ttlMs) ? ttlMs : TTLCache.DEFAULT_TTL_MS

Check warning on line 15 in proxy/utils/cache.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch
}

get(key: K): V | undefined {
const item = this.cache.get(key)
if (item === undefined) {
return undefined
}

if (Date.now() >= item.expiresAt) {
this.cache.delete(key)
return undefined
}

return item.value
}

set(key: K, value: V, customTtlMs?: number): void {
const ttlMsToUse = TTLCache.isValidTTL(customTtlMs) ? customTtlMs : this.ttlMs

this.cache.set(key, {
value,
expiresAt: Date.now() + ttlMsToUse,
})
Comment thread
TheUnderScorer marked this conversation as resolved.
}

has(key: K): boolean {
return this.get(key) !== undefined
}

delete(key: K): void {
this.cache.delete(key)
}

clear(): void {
this.cache.clear()
}

static isValidTTL(value?: number): value is number {
return typeof value === 'number' && !Number.isNaN(value) && Number.isFinite(value) && value >= 0
}
}
Original file line number Diff line number Diff line change
@@ -1,37 +1,32 @@
import { CustomerVariablesRecord } from '../types'
import {
SecretsManagerClient,
GetSecretValueCommand,
GetSecretValueCommandOutput,
SecretsManagerClient,
} from '@aws-sdk/client-secrets-manager'
import { arrayBufferToString } from '../../buffer'
import { validateSecret } from './validate-secret'
import { normalizeSecret } from './normalize-secret'

interface CacheEntry {
value: CustomerVariablesRecord | null
}
import { TTLCache } from '../../cache'

/**
* Global cache for customer variables fetched from Secrets Manager.
* By default, the cache is set to expire after 5 minutes.
* */
const cache = new Map<string, CacheEntry>()
const cache = new TTLCache<string, CustomerVariablesRecord | null>(300_000)

/**
* Retrieves a secret from Secrets Manager and caches it or returns it from cache if it's still valid.
* */
export async function retrieveSecret(secretsManager: SecretsManagerClient, key: string) {
if (cache.has(key)) {
const entry = cache.get(key)!

return entry.value
export async function retrieveSecret(secretsManager: SecretsManagerClient, key: string, cacheTtlMs?: number) {
const cached = cache.get(key)
if (cached !== undefined) {
return cached
}
Comment thread
TheUnderScorer marked this conversation as resolved.

const result = await fetchSecret(secretsManager, key)

cache.set(key, {
value: result,
})
cache.set(key, result, cacheTtlMs)

return result
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ export class SecretsManagerVariables implements CustomerVariableProvider {

private readonly secretsManager?: SecretsManagerClient

constructor(private readonly request: CloudFrontRequest) {
constructor(
private readonly request: CloudFrontRequest,
private readonly cacheTtlMs?: number
) {
this.readSecretsInfoFromHeaders()

if (SecretsManagerVariables.isValidSecretInfo(this.secretsInfo)) {
Expand All @@ -45,7 +48,7 @@ export class SecretsManagerVariables implements CustomerVariableProvider {
}

try {
return await retrieveSecret(this.secretsManager, this.secretsInfo!.secretName!)
return await retrieveSecret(this.secretsManager, this.secretsInfo!.secretName!, this.cacheTtlMs)
} catch (error) {
console.error('Error retrieving secret from secrets manager', {
error,
Expand Down
20 changes: 20 additions & 0 deletions proxy/utils/headers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { updateCacheControlHeader } from './cache-control'
import { CustomerVariables } from './customer-variables/customer-variables'
import { getPreSharedSecret } from './customer-variables/selectors'
import { TTLCache } from './cache'

export const BLACKLISTED_HEADERS = new Set([
'age',
Expand Down Expand Up @@ -225,3 +226,22 @@
}
return headers[name][0].value
}

/**
* Retrieves the secret cache time-to-live (TTL) value in milliseconds from the request headers.
*
* @param {CloudFrontRequest} request - The CloudFront request object containing headers.
* @return {number|undefined} The parsed TTL value in milliseconds if present and valid; otherwise, undefined.
Comment thread
TheUnderScorer marked this conversation as resolved.
*/
export function getSecretCacheTtlMs(request: CloudFrontRequest): number | undefined {
const value = getHeaderValue(request, 'fpjs_proxy_secret_cache_ttl_ms')

if (value) {
const parsedValue = parseInt(value, 10)
if (TTLCache.isValidTTL(parsedValue)) {
return parsedValue
}

Check warning on line 243 in proxy/utils/headers.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

Check warning on line 243 in proxy/utils/headers.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch
}

Check warning on line 244 in proxy/utils/headers.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch

return undefined
}
Loading