Skip to content

Commit fbe6c02

Browse files
committed
perf: cache imported issuer verification keys by algorithm
1 parent e64849e commit fbe6c02

2 files changed

Lines changed: 157 additions & 1 deletion

File tree

‎src/index.ts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3208,6 +3208,7 @@ interface PendingJWKSRequest {
32083208
}
32093209

32103210
let jwksRequests: WeakMap<AuthorizationServer, Set<PendingJWKSRequest>>
3211+
let jwksKeys: WeakMap<JWK, Map<string, Promise<CryptoKey>>>
32113212

32123213
/**
32133214
* A JSON Web Key Set cache value suitable for external persistence.
@@ -3379,7 +3380,22 @@ async function getPublicSigKeyFromIssuerJwksUri(
33793380
)
33803381
}
33813382

3382-
return importJwk(alg, jwk)
3383+
jwksKeys ||= new WeakMap()
3384+
let keys = jwksKeys.get(jwk)
3385+
if (!keys) {
3386+
keys = new Map()
3387+
jwksKeys.set(jwk, keys)
3388+
}
3389+
3390+
let key = keys.get(alg)
3391+
if (!key) {
3392+
key = importJwk(alg, jwk).catch((cause: unknown) => {
3393+
keys.delete(alg)
3394+
throw cause
3395+
})
3396+
keys.set(alg, key)
3397+
}
3398+
return key
33833399
}
33843400

33853401
/**

‎test/jwks_key_cache.test.ts‎

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import anyTest, { type TestFn } from 'ava'
2+
import * as jose from 'jose'
3+
import timekeeper from 'timekeeper'
4+
import setup, { client, endpoint, getResponse, issuer, teardown, type Context } from './_setup.js'
5+
import * as lib from './_lib.js'
6+
import { mangleJwtSignature } from './_tools.js'
7+
8+
const test = anyTest as TestFn<Context>
9+
10+
test.before(setup())
11+
test.after(teardown)
12+
13+
async function fixture(alg = 'ES256') {
14+
const keyPair = await lib.generateKeyPair(alg, { extractable: true })
15+
const as: lib.AuthorizationServer = {
16+
...issuer,
17+
jwks_uri: endpoint('jwks-key-cache'),
18+
authorization_signing_alg_values_supported: [alg, 'PS256'],
19+
}
20+
const jwks = { keys: [await jose.exportJWK(keyPair.publicKey)] }
21+
let requests = 0
22+
const options: lib.ValidateSignatureOptions = {
23+
[lib.customFetch]: async () => {
24+
requests++
25+
return getResponse(JSON.stringify(jwks))
26+
},
27+
}
28+
return {
29+
keyPair,
30+
jwks,
31+
requests: () => requests,
32+
sign: (privateKey = keyPair.privateKey, algorithm = alg) =>
33+
new jose.SignJWT({ code: 'code' })
34+
.setProtectedHeader({ alg: algorithm })
35+
.setAudience(client.client_id)
36+
.setIssuer(as.issuer)
37+
.setExpirationTime('10m')
38+
.sign(privateKey),
39+
validate: (jwt: string, cache?: lib.JWKSCacheInput) =>
40+
lib.validateJwtAuthResponse(as, client, new URLSearchParams({ response: jwt }), undefined, {
41+
...options,
42+
[lib.jwksCache]: cache,
43+
}),
44+
}
45+
}
46+
47+
async function verificationKey(verify: () => Promise<unknown>): Promise<lib.CryptoKey> {
48+
try {
49+
await verify()
50+
} catch (error) {
51+
if (
52+
error instanceof lib.OperationProcessingError &&
53+
error.message === 'JWT signature verification failed'
54+
) {
55+
return (error.cause as { key: lib.CryptoKey }).key
56+
}
57+
throw error
58+
}
59+
throw new Error('expected signature verification to fail')
60+
}
61+
62+
test('issuer verification keys are shared across concurrent and repeated validations', async (testContext) => {
63+
const context = await fixture()
64+
const token = await context.sign()
65+
const invalid = mangleJwtSignature(token)
66+
const cache: lib.JWKSCacheInput = {}
67+
const keys = await Promise.all(
68+
Array.from({ length: 8 }, () => verificationKey(() => context.validate(invalid, cache))),
69+
)
70+
71+
testContext.is(context.requests(), 1)
72+
testContext.is(keys[0].type, 'public')
73+
for (const key of keys) {
74+
testContext.is(key, keys[0])
75+
}
76+
testContext.is(await verificationKey(() => context.validate(invalid)), keys[0])
77+
testContext.is((await context.validate(token)).get('code'), 'code')
78+
testContext.deepEqual(cache.jwks, context.jwks)
79+
testContext.deepEqual(JSON.parse(JSON.stringify(cache)), cache)
80+
})
81+
82+
test('the same RSA JWK is cached separately for each signing algorithm', async (testContext) => {
83+
const context = await fixture('RS256')
84+
const privateJwk = await jose.exportJWK(context.keyPair.privateKey)
85+
const pssKey = await jose.importJWK(privateJwk, 'PS256')
86+
const rsaToken = await context.sign()
87+
const pssToken = await context.sign(pssKey as CryptoKey, 'PS256')
88+
const rsaInvalid = mangleJwtSignature(rsaToken)
89+
const pssInvalid = mangleJwtSignature(pssToken)
90+
const rsaPublic = await verificationKey(() => context.validate(rsaInvalid))
91+
const pssPublic = await verificationKey(() => context.validate(pssInvalid))
92+
93+
testContext.not(rsaPublic, pssPublic)
94+
testContext.is(rsaPublic.algorithm.name, 'RSASSA-PKCS1-v1_5')
95+
testContext.is(pssPublic.algorithm.name, 'RSA-PSS')
96+
testContext.is(await verificationKey(() => context.validate(rsaInvalid)), rsaPublic)
97+
testContext.is(await verificationKey(() => context.validate(pssInvalid)), pssPublic)
98+
await testContext.notThrowsAsync(context.validate(rsaToken))
99+
await testContext.notThrowsAsync(context.validate(pssToken))
100+
testContext.is(context.requests(), 1)
101+
})
102+
103+
test('failed JWK imports are not cached', async (testContext) => {
104+
const context = await fixture()
105+
const token = await context.sign()
106+
const jwk = { ...context.jwks.keys[0], x: 'invalid' }
107+
const cache: lib.ExportedJWKSCache = {
108+
uat: Math.floor(Date.now() / 1000),
109+
jwks: { keys: [jwk] },
110+
}
111+
112+
await testContext.throwsAsync(context.validate(token, cache))
113+
jwk.x = context.jwks.keys[0].x!
114+
await testContext.notThrowsAsync(context.validate(token, cache))
115+
testContext.is(context.requests(), 0)
116+
})
117+
118+
test.serial('refreshing JWKS replaces cached verification keys', async (testContext) => {
119+
const now = Date.now()
120+
timekeeper.freeze(now)
121+
testContext.teardown(() => timekeeper.reset())
122+
const context = await fixture()
123+
const originalToken = await context.sign()
124+
const originalKey = await verificationKey(() =>
125+
context.validate(mangleJwtSignature(originalToken)),
126+
)
127+
128+
const rotated = await lib.generateKeyPair('ES256')
129+
context.jwks.keys = [await jose.exportJWK(rotated.publicKey)]
130+
timekeeper.travel(now + 300_000)
131+
const rotatedToken = await context.sign(rotated.privateKey)
132+
const rotatedKey = await verificationKey(() => context.validate(mangleJwtSignature(rotatedToken)))
133+
134+
testContext.not(rotatedKey, originalKey)
135+
testContext.is(context.requests(), 2)
136+
await testContext.notThrowsAsync(context.validate(rotatedToken))
137+
await testContext.throwsAsync(context.validate(originalToken), {
138+
message: 'JWT signature verification failed',
139+
})
140+
})

0 commit comments

Comments
 (0)