Skip to content

Commit

Permalink
chore(backend): Fix JWKS cache (#3321)
Browse files Browse the repository at this point in the history
* chore(backend): Fix JWKS cache

* chore(backend): Finish implementation

* chore(backend): Fix keys tests

* fix(backend): Add test to assert the cache doesn't get wiped out

* fix(backend): Simplify test

* chore(backend): Remove unnecessary comment

* chore(backend): Add @deprecated comment

* chore(backend): Remove unnecessary error

* fix(backend): Ensure fetch if kid isn't found in cache

* chore(backend): Re-add exported errors to avoid a breaking change

* Update integration/tests/handshake.test.ts
  • Loading branch information
BRKalow committed May 7, 2024
1 parent 7c6146e commit 4f4375e
Show file tree
Hide file tree
Showing 4 changed files with 67 additions and 27 deletions.
5 changes: 5 additions & 0 deletions .changeset/chatty-cooks-notice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': patch
---

Fix bug in JWKS cache logic that caused a race condition resulting in no JWK being available.
1 change: 1 addition & 0 deletions packages/backend/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export const TokenVerificationErrorReason = {
RemoteJWKInvalid: 'jwk-remote-invalid',
RemoteJWKMissing: 'jwk-remote-missing',
JWKFailedToResolve: 'jwk-failed-to-resolve',
JWKKidMismatch: 'jwk-kid-mismatch',
};

export type TokenVerificationErrorReason =
Expand Down
38 changes: 36 additions & 2 deletions packages/backend/src/tokens/__tests__/keys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,8 +172,9 @@ export default (QUnit: QUnit) => {
} catch (err) {
if (err instanceof Error) {
assert.propEqual(err, {
reason: 'jwk-remote-missing',
action: 'Contact support@clerk.com',
reason: 'jwk-kid-mismatch',
action:
'Go to your Dashboard and validate your secret and public keys are correct. Contact support@clerk.com if the issue persists.',
});
assert.propContains(err, {
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 is available: ${mockRsaJwkKid}, local`,
Expand All @@ -184,5 +185,38 @@ export default (QUnit: QUnit) => {
}
}
});

test('cache TTLs do not conflict', async assert => {
fakeClock.runAll();

fakeFetch.onCall(0).returns(jsonOk(mockJwks));
let jwk = await loadClerkJWKFromRemote({
secretKey: 'deadbeef',
kid: mockRsaJwkKid,
skipJwksCache: true,
});
assert.propEqual(jwk, mockRsaJwk);

// just less than an hour, the cache TTL
fakeClock.tick(60 * 60 * 1000 - 5);

// re-fetch, 5m cache is expired
fakeFetch.onCall(1).returns(jsonOk(mockJwks));
jwk = await loadClerkJWKFromRemote({
secretKey: 'deadbeef',
kid: mockRsaJwkKid,
});
assert.propEqual(jwk, mockRsaJwk);

// cache should be cleared, but 5m ttl is still valid
fakeClock.next();

// re-fetch, 5m cache is expired
jwk = await loadClerkJWKFromRemote({
secretKey: 'deadbeef',
kid: mockRsaJwkKid,
});
assert.propEqual(jwk, mockRsaJwk);
});
});
};
50 changes: 25 additions & 25 deletions packages/backend/src/tokens/keys.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { API_URL, API_VERSION, JWKS_CACHE_TTL_MS, MAX_CACHE_LAST_UPDATED_AT_SECONDS } from '../constants';
import { API_URL, API_VERSION, MAX_CACHE_LAST_UPDATED_AT_SECONDS } from '../constants';
import {
TokenVerificationError,
TokenVerificationErrorAction,
Expand Down Expand Up @@ -26,19 +26,9 @@ function getCacheValues() {
return Object.values(cache);
}

function setInCache(jwk: JsonWebKeyWithKid, jwksCacheTtlInMs: number) {
function setInCache(jwk: JsonWebKeyWithKid, shouldExpire = true) {
cache[jwk.kid] = jwk;
lastUpdatedAt = Date.now();

if (jwksCacheTtlInMs >= 0) {
setTimeout(() => {
if (jwk) {
delete cache[jwk.kid];
} else {
cache = {};
}
}, jwksCacheTtlInMs);
}
lastUpdatedAt = shouldExpire ? Date.now() : -1;
}

const LocalJwkKid = 'local';
Expand Down Expand Up @@ -83,7 +73,7 @@ export function loadClerkJWKFromLocal(localKey?: string): JsonWebKey {
n: modulus,
e: 'AQAB',
},
-1, // local key never expires in cache
false, // local key never expires in cache
);
}

Expand All @@ -92,6 +82,9 @@ export function loadClerkJWKFromLocal(localKey?: string): JsonWebKey {

export type LoadClerkJWKFromRemoteOptions = {
kid: string;
/**
* @deprecated This cache TTL is deprecated and will be removed in the next major version. Specifying a cache TTL is now a no-op.
*/
jwksCacheTtlInMs?: number;
skipJwksCache?: boolean;
secretKey?: string;
Expand All @@ -108,19 +101,16 @@ export type LoadClerkJWKFromRemoteOptions = {
* @param {Object} options
* @param {string} options.kid - The id of the key that the JWT was signed with
* @param {string} options.alg - The algorithm of the JWT
* @param {number} options.jwksCacheTtlInMs - The TTL of the jwks cache (defaults to 1 hour)
* @returns {JsonWebKey} key
*/
export async function loadClerkJWKFromRemote({
secretKey,
apiUrl = API_URL,
apiVersion = API_VERSION,
kid,
jwksCacheTtlInMs = JWKS_CACHE_TTL_MS,
skipJwksCache,
}: LoadClerkJWKFromRemoteOptions): Promise<JsonWebKey> {
const needsFetch = !getFromCache(kid) || cacheHasExpired();
if (skipJwksCache || needsFetch) {
if (skipJwksCache || cacheHasExpired() || !getFromCache(kid)) {
if (!secretKey) {
throw new TokenVerificationError({
action: TokenVerificationErrorAction.ContactSupport,
Expand All @@ -139,7 +129,7 @@ export async function loadClerkJWKFromRemote({
});
}

keys.forEach(key => setInCache(key, jwksCacheTtlInMs));
keys.forEach(key => setInCache(key));
}

const jwk = getFromCache(kid);
Expand All @@ -152,11 +142,9 @@ export async function loadClerkJWKFromRemote({
.join(', ');

throw new TokenVerificationError({
action: TokenVerificationErrorAction.ContactSupport,
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.${
jwkKeys ? ` The following kid is available: ${jwkKeys}` : ''
}`,
reason: TokenVerificationErrorReason.RemoteJWKMissing,
action: `Go to your Dashboard and validate your secret and public keys are correct. ${TokenVerificationErrorAction.ContactSupport} if the issue persists.`,
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 is available: ${jwkKeys}`,
reason: TokenVerificationErrorReason.JWKKidMismatch,
});
}

Expand Down Expand Up @@ -208,7 +196,19 @@ async function fetchJWKSFromBAPI(apiUrl: string, key: string, apiVersion: string
}

function cacheHasExpired() {
return Date.now() - lastUpdatedAt >= MAX_CACHE_LAST_UPDATED_AT_SECONDS * 1000;
// If lastUpdatedAt is -1, it means that we're using a local JWKS and it never expires
if (lastUpdatedAt === -1) {
return false;
}

// If the cache has expired, clear the value so we don't attempt to make decisions based on stale data
const isExpired = Date.now() - lastUpdatedAt >= MAX_CACHE_LAST_UPDATED_AT_SECONDS * 1000;

if (isExpired) {
cache = {};
}

return isExpired;
}

type ErrorFields = {
Expand Down

0 comments on commit 4f4375e

Please sign in to comment.