-
Notifications
You must be signed in to change notification settings - Fork 0
JWKS Cache Stores
RDCP's JWKS client supports a pluggable cache interface so deployments can choose the right backend for their platform: memory, filesystem, Redis, DynamoDB, Vercel KV, Kubernetes secrets, and more.
Interface
export interface JwksCacheStore {
get(url: string): Promise<{
jwks: { keys: JWK[] }
etag?: string
lastFetched: number
ttlMs?: number
} | undefined>
set(
url: string,
entry: { jwks: { keys: JWK[] }; etag?: string; lastFetched: number; ttlMs?: number }
): Promise<void>
del(url: string): Promise<void>
}Usage
import { createJwksFetcher } from '@rdcp.dev/server'
const fetcher = createJwksFetcher({
ttlMs: 60_000,
// Either provide a path for built-in file cacheβ¦
cachePath: '.rdcp-cache',
// β¦or a custom store via `cache`
// cache: new RedisJwksCache(redis),
refreshThresholdMs: 10_000,
})
const res = await fetcher.fetch('https://issuer.example.com')
// res: { jwks, etag?, fromCache }Production Examples
Redis (most requested)
import Redis from 'ioredis'
import type { JWK } from 'jose'
import type { JwksCacheStore } from '@rdcp.dev/server'
type Entry = { jwks: { keys: JWK[] }; etag?: string; lastFetched: number; ttlMs?: number }
class RedisJwksCache implements JwksCacheStore {
constructor(private redis: Redis) {}
private key(url: string) {
return `jwks:${url}`
}
async get(url: string): Promise<Entry | undefined> {
const data = await this.redis.get(this.key(url))
return data ? (JSON.parse(data) as Entry) : undefined
}
async set(url: string, entry: Entry): Promise<void> {
const ttlSec = entry.ttlMs ? Math.ceil(entry.ttlMs / 1000) : 3600
await this.redis.set(this.key(url), JSON.stringify(entry), 'EX', ttlSec)
}
async del(url: string): Promise<void> {
await this.redis.del(this.key(url))
}
}Encrypted file cache (encryption at rest)
import { promises as fs } from 'node:fs'
import { join } from 'node:path'
import { createHash, randomBytes, createCipheriv, createDecipheriv } from 'node:crypto'
import type { JWK } from 'jose'
import type { JwksCacheStore } from '@rdcp.dev/server'
type Entry = { jwks: { keys: JWK[] }; etag?: string; lastFetched: number; ttlMs?: number }
function hash(s: string) {
return createHash('sha256').update(s).digest('hex')
}
class EncryptedFileCache implements JwksCacheStore {
constructor(private key: Buffer, private baseDir: string) {}
private path(url: string) {
return join(this.baseDir, `${hash(url)}.bin`)
}
private encrypt(plaintext: string): Buffer {
const iv = randomBytes(12)
const cipher = createCipheriv('aes-256-gcm', this.key, iv)
const enc = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()])
const tag = cipher.getAuthTag()
return Buffer.concat([iv, tag, enc])
}
private decrypt(buf: Buffer): string {
const iv = buf.subarray(0, 12)
const tag = buf.subarray(12, 28)
const enc = buf.subarray(28)
const decipher = createDecipheriv('aes-256-gcm', this.key, iv)
decipher.setAuthTag(tag)
const dec = Buffer.concat([decipher.update(enc), decipher.final()])
return dec.toString('utf8')
}
async get(url: string): Promise<Entry | undefined> {
try {
const p = this.path(url)
const raw = await fs.readFile(p)
return JSON.parse(this.decrypt(raw)) as Entry
} catch {
return undefined
}
}
async set(url: string, entry: Entry): Promise<void> {
const p = this.path(url)
await fs.mkdir(this.baseDir, { recursive: true })
const tmp = `${p}.tmp-${Date.now()}`
const payload = this.encrypt(JSON.stringify(entry))
await fs.writeFile(tmp, payload)
await fs.rename(tmp, p)
}
async del(url: string): Promise<void> {
try {
await fs.unlink(this.path(url))
} catch {}
}
}AWS Lambda + DynamoDB
import { DynamoDBClient, GetItemCommand, PutItemCommand, DeleteItemCommand } from '@aws-sdk/client-dynamodb'
import type { JWK } from 'jose'
import type { JwksCacheStore } from '@rdcp.dev/server'
type Entry = { jwks: { keys: JWK[] }; etag?: string; lastFetched: number; ttlMs?: number }
class DynamoJwksCache implements JwksCacheStore {
constructor(private client: DynamoDBClient, private tableName = 'rdcp-jwks-cache') {}
async get(url: string): Promise<Entry | undefined> {
const r = await this.client.send(
new GetItemCommand({ TableName: this.tableName, Key: { url: { S: url } } })
)
const item = r.Item
if (!item || !item.data?.S) return undefined
return JSON.parse(item.data.S) as Entry
}
async set(url: string, entry: Entry): Promise<void> {
const ttlSec = entry.ttlMs ? Math.ceil(entry.ttlMs / 1000) : 3600
const expires = Math.floor(Date.now() / 1000) + ttlSec
await this.client.send(
new PutItemCommand({
TableName: this.tableName,
Item: {
url: { S: url },
data: { S: JSON.stringify(entry) },
ttl: { N: String(expires) },
},
})
)
}
async del(url: string): Promise<void> {
await this.client.send(
new DeleteItemCommand({ TableName: this.tableName, Key: { url: { S: url } } })
)
}
}Vercel Edge Runtime (KV)
// Pseudo-interface for Vercel KV
declare const kv: {
get<T>(k: string): Promise<T | null>
setex(k: string, ttlSec: number, v: unknown): Promise<void>
del(k: string): Promise<void>
}
import type { JWK } from 'jose'
import type { JwksCacheStore } from '@rdcp.dev/server'
type Entry = { jwks: { keys: JWK[] }; etag?: string; lastFetched: number; ttlMs?: number }
class VercelKVCache implements JwksCacheStore {
private key(url: string) {
return `jwks:${url}`
}
async get(url: string): Promise<Entry | undefined> {
const v = await kv.get<Entry>(this.key(url))
return v ?? undefined
}
async set(url: string, entry: Entry): Promise<void> {
const ttlSec = entry.ttlMs ? Math.ceil(entry.ttlMs / 1000) : 3600
await kv.setex(this.key(url), ttlSec, entry)
}
async del(url: string): Promise<void> {
await kv.del(this.key(url))
}
}Kubernetes Secrets (air-gapped)
// Outline using @kubernetes/client-node (pseudo for brevity)
import type { CoreV1Api } from '@kubernetes/client-node'
import type { JWK } from 'jose'
import type { JwksCacheStore } from '@rdcp.dev/server'
type Entry = { jwks: { keys: JWK[] }; etag?: string; lastFetched: number; ttlMs?: number }
function nameFromUrl(url: string) {
return `jwks-${Buffer.from(url).toString('base64url').slice(0, 50)}`
}
class K8sSecretCache implements JwksCacheStore {
constructor(private k8s: CoreV1Api, private namespace: string) {}
async get(url: string): Promise<Entry | undefined> {
try {
const name = nameFromUrl(url)
const r = await this.k8s.readNamespacedSecret(name, this.namespace)
const b64 = r.body.data?.jwks
return b64 ? (JSON.parse(Buffer.from(b64, 'base64').toString('utf8')) as Entry) : undefined
} catch {
return undefined
}
}
async set(url: string, entry: Entry): Promise<void> {
const name = nameFromUrl(url)
const jwksB64 = Buffer.from(JSON.stringify(entry)).toString('base64')
await this.k8s.createNamespacedSecret(this.namespace, {
metadata: { name },
type: 'Opaque',
data: { jwks: jwksB64 },
} as unknown)
}
async del(url: string): Promise<void> {
const name = nameFromUrl(url)
await this.k8s.deleteNamespacedSecret(name, this.namespace)
}
}Metrics wrapper
// Wrap any cache with metrics
interface MetricsClient {
histogram(name: string, valueMs: number, labels?: Record<string, string>): void
counter(name: string, labels?: Record<string, string>): void
}
class MetricsJwksCache implements JwksCacheStore {
constructor(private inner: JwksCacheStore, private metrics: MetricsClient) {}
async get(url: string) {
const t = Date.now()
const res = await this.inner.get(url)
this.metrics.histogram('jwks_cache_get_ms', Date.now() - t, { hit: res ? 'true' : 'false' })
this.metrics.counter('jwks_cache_get_total', { hit: res ? 'true' : 'false' })
return res
}
async set(url: string, entry: Entry) {
const t = Date.now()
await this.inner.set(url, entry)
this.metrics.histogram('jwks_cache_set_ms', Date.now() - t)
this.metrics.counter('jwks_cache_set_total')
}
async del(url: string) {
await this.inner.del(url)
this.metrics.counter('jwks_cache_del_total')
}
}Multi-Region cache (primary/secondary with backfill)
class MultiRegionCache implements JwksCacheStore {
constructor(private primary: JwksCacheStore, private secondary: readonly JwksCacheStore[]) {}
async get(url: string) {
let res = await this.primary.get(url)
if (res) return res
for (const s of this.secondary) {
res = await s.get(url)
if (res) {
await this.primary.set(url, res)
return res
}
}
return undefined
}
async set(url: string, entry: Entry) {
await this.primary.set(url, entry)
for (const s of this.secondary) await s.set(url, entry)
}
async del(url: string) {
await this.primary.del(url)
for (const s of this.secondary) await s.del(url)
}
}Performance & Security Notes
- Always set ttlMs to a sensible value (e.g., 60β300s) and pair with ETag revalidation.
- Prefer shared caches (Redis/Dynamo/Vercel KV) in multi-instance deployments.
- Consider encryption at rest for file-based caches on shared disks.
- Export cache metrics (hit ratio, latencies) to validate effectiveness.
See also:
- README β JWKS Infrastructure
- Authentication & Key Lifecycle (JWT refresh planned in #26)
Getting Started: Installation β’ Basic Usage β’ Authentication
Migration: From Manual Implementation β’ Framework Examples β’ Publishing Guide
Protocol: RDCP v1.0 Specification β’ Implementation Guide β’ API Reference
π Home | π¦ NPM Package | π GitHub | π Issues
RDCP SDK v1.0.0 - Runtime Debug Control Protocol implementation for JavaScript/Node.js applications
- Application-Control-Plane-Concepts
- rdcp-technical-analysis
- Migration-Guide
- Multi-Tenancy
- Audit-Trail
- Performance Metrics
- Implementation-Status
- JavaScript-vs-TypeScript-Boundaries
- Core-Package-Boundaries
- Publishing-Setup
- Contributing
- API-Reference
- Client-Fetch-API-Examples
- Tracing-Library-Integration-Examples
- Integration-Scenarios
- Trace-Propagation-Demo
- RDCP-Demo-App
- Conformance-Kit
- Conformance-Setup
- Conformance-Tags
- Conformance-Reports
- Conformance-Requirements
- Conformance-CLI
- Protocol Specification
- Implementation Guide
- RDCP-Primitive-Types
- Protocol-Schemas
- Protocol-Error-Codes
- API-Reference
Version: 1.0.0
Protocol: RDCP v1.0
License: Apache-2.0