Skip to content

Commit 9cd588d

Browse files
authored
fix(backend): Remove cache timer to fix empty cache bug (#3332)
1 parent ed1f233 commit 9cd588d

File tree

3 files changed

+56
-45
lines changed

3 files changed

+56
-45
lines changed

.changeset/cyan-gifts-cheer.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clerk/backend': patch
3+
---
4+
5+
Fix bug in JWKS cache logic that caused a race condition resulting in no JWK being available.

packages/backend/src/tokens/keys.test.ts

Lines changed: 33 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -188,8 +188,10 @@ export default (QUnit: QUnit) => {
188188
}
189189
});
190190

191-
test('throws an error when JWKS can not be fetched from Backend or Frontend API and cache updated less than 5 minutes ago', async assert => {
191+
test('throws an error when no JWK matches the provided kid', async assert => {
192+
fakeFetch.onCall(0).returns(jsonOk(mockJwks));
192193
const kid = 'ins_whatever';
194+
193195
try {
194196
await loadClerkJWKFromRemote({
195197
apiKey: 'deadbeef',
@@ -203,7 +205,7 @@ export default (QUnit: QUnit) => {
203205
action: 'Contact [email protected]',
204206
});
205207
assert.propContains(err, {
206-
message: `Unable to find a signing key in JWKS that matches the kid='${kid}' of the provided session token. Please make sure that the __session cookie or the HTTP authorization header contain a Clerk-generated session JWT. The following kid are available: local, ${mockRsaJwkKid}`,
208+
message: `Unable to find a signing key in JWKS that matches the kid='${kid}' of the provided session token. Please make sure that the __session cookie or the HTTP authorization header contain a Clerk-generated session JWT. The following kid are available: ${mockRsaJwkKid}`,
207209
});
208210
} else {
209211
// This should never be reached. If it does, the suite should fail
@@ -212,30 +214,37 @@ export default (QUnit: QUnit) => {
212214
}
213215
});
214216

215-
test('throws an error when no JWK matches the provided kid', async assert => {
217+
test('cache TTLs do not conflict', async assert => {
218+
fakeClock.runAll();
219+
216220
fakeFetch.onCall(0).returns(jsonOk(mockJwks));
217-
const kid = 'ins_whatever';
221+
let jwk = await loadClerkJWKFromRemote({
222+
secretKey: 'deadbeef',
223+
kid: mockRsaJwkKid,
224+
skipJwksCache: true,
225+
});
226+
assert.propEqual(jwk, mockRsaJwk);
218227

219-
try {
220-
await loadClerkJWKFromRemote({
221-
apiKey: 'deadbeef',
222-
kid,
223-
});
224-
assert.false(true);
225-
} catch (err) {
226-
if (err instanceof Error) {
227-
assert.propEqual(err, {
228-
reason: 'jwk-remote-missing',
229-
action: 'Contact [email protected]',
230-
});
231-
assert.propContains(err, {
232-
message: `Unable to find a signing key in JWKS that matches the kid='${kid}' of the provided session token. Please make sure that the __session cookie or the HTTP authorization header contain a Clerk-generated session JWT. The following kid are available: local, ${mockRsaJwkKid}`,
233-
});
234-
} else {
235-
// This should never be reached. If it does, the suite should fail
236-
assert.false(true);
237-
}
238-
}
228+
// just less than an hour, the cache TTL
229+
fakeClock.tick(60 * 60 * 1000 - 5);
230+
231+
// re-fetch, 5m cache is expired
232+
fakeFetch.onCall(1).returns(jsonOk(mockJwks));
233+
jwk = await loadClerkJWKFromRemote({
234+
secretKey: 'deadbeef',
235+
kid: mockRsaJwkKid,
236+
});
237+
assert.propEqual(jwk, mockRsaJwk);
238+
239+
// cache should be cleared, but 5m ttl is still valid
240+
fakeClock.next();
241+
242+
// re-fetch, 5m cache is expired
243+
jwk = await loadClerkJWKFromRemote({
244+
secretKey: 'deadbeef',
245+
kid: mockRsaJwkKid,
246+
});
247+
assert.propEqual(jwk, mockRsaJwk);
239248
});
240249
});
241250
};

packages/backend/src/tokens/keys.ts

Lines changed: 18 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -27,22 +27,9 @@ function getCacheValues() {
2727
return Object.values(cache);
2828
}
2929

30-
function setInCache(
31-
jwk: JsonWebKeyWithKid,
32-
jwksCacheTtlInMs: number = 1000 * 60 * 60, // 1 hour
33-
) {
30+
function setInCache(jwk: JsonWebKeyWithKid, shouldExpire = true) {
3431
cache[jwk.kid] = jwk;
35-
lastUpdatedAt = Date.now();
36-
37-
if (jwksCacheTtlInMs >= 0) {
38-
setTimeout(() => {
39-
if (jwk) {
40-
delete cache[jwk.kid];
41-
} else {
42-
cache = {};
43-
}
44-
}, jwksCacheTtlInMs);
45-
}
32+
lastUpdatedAt = shouldExpire ? Date.now() : -1;
4633
}
4734

4835
const LocalJwkKid = 'local';
@@ -87,7 +74,7 @@ export function loadClerkJWKFromLocal(localKey?: string): JsonWebKey {
8774
n: modulus,
8875
e: 'AQAB',
8976
},
90-
-1, // local key never expires in cache
77+
false, // local key never expires in cache
9178
);
9279
}
9380

@@ -128,10 +115,12 @@ export async function loadClerkJWKFromRemote({
128115
apiVersion = API_VERSION,
129116
issuer,
130117
kid,
131-
jwksCacheTtlInMs = 1000 * 60 * 60, // 1 hour,
132118
skipJwksCache,
133119
}: LoadClerkJWKFromRemoteOptions): Promise<JsonWebKey> {
134-
const shouldRefreshCache = !getFromCache(kid) && reachedMaxCacheUpdatedAt();
120+
if (cacheHasExpired()) {
121+
cache = {};
122+
}
123+
const shouldRefreshCache = !getFromCache(kid);
135124
if (skipJwksCache || shouldRefreshCache) {
136125
let fetcher;
137126
const key = secretKey || apiKey;
@@ -158,7 +147,7 @@ export async function loadClerkJWKFromRemote({
158147
});
159148
}
160149

161-
keys.forEach(key => setInCache(key, jwksCacheTtlInMs));
150+
keys.forEach(key => setInCache(key));
162151
}
163152

164153
const jwk = getFromCache(kid);
@@ -240,6 +229,14 @@ async function fetchJWKSFromBAPI(apiUrl: string, key: string, apiVersion: string
240229
return response.json();
241230
}
242231

243-
function reachedMaxCacheUpdatedAt() {
244-
return Date.now() - lastUpdatedAt >= MAX_CACHE_LAST_UPDATED_AT_SECONDS * 1000;
232+
function cacheHasExpired() {
233+
// If lastUpdatedAt is -1, it means that we're using a local JWKS and it never expires
234+
if (lastUpdatedAt === -1) {
235+
return false;
236+
}
237+
238+
// If the cache has expired, clear the value so we don't attempt to make decisions based on stale data
239+
const isExpired = Date.now() - lastUpdatedAt >= MAX_CACHE_LAST_UPDATED_AT_SECONDS * 1000;
240+
241+
return isExpired;
245242
}

0 commit comments

Comments
 (0)