Skip to content

Commit 72220a1

Browse files
gjtorikianclaude
andcommitted
perf(session): cache JWKS per client with 5-minute TTL
Previously every authenticate() call issued a live HTTP GET to the JWKS endpoint, making each session check dependent on an external round-trip and inflating latency. Add an in-memory cache on SessionManager keyed by client ID with a 300-second TTL, plus a force-refresh path on `kid` miss so newly-rotated signing keys are still discovered without waiting for TTL expiry. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 4b84f49 commit 72220a1

2 files changed

Lines changed: 127 additions & 1 deletion

File tree

lib/SessionManager.php

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,23 @@
1313

1414
class SessionManager
1515
{
16+
/**
17+
* In-memory JWKS cache, keyed by client ID. Values are
18+
* `['keys' => array, 'fetched_at' => int]`. Cache lives for the
19+
* lifetime of the SessionManager instance and is bypassed when a
20+
* token's `kid` isn't found, so key rotation still resolves quickly.
21+
*
22+
* @var array<string, array{keys: array, fetched_at: int}>
23+
*/
24+
private array $jwksCache = [];
25+
26+
/**
27+
* JWKS cache TTL in seconds. WorkOS rotates signing keys on the order
28+
* of weeks, so a few minutes is plenty to absorb traffic spikes
29+
* without making session checks dependent on a live JWKS round-trip.
30+
*/
31+
private const JWKS_CACHE_TTL_SECONDS = 300;
32+
1633
public function __construct(
1734
private readonly HttpClient $client,
1835
) {
@@ -302,6 +319,33 @@ public function fetchJwks(string $clientId): array
302319
);
303320
}
304321

322+
/**
323+
* Return the JWKS for `$clientId`, served from an in-memory cache
324+
* with a {@see JWKS_CACHE_TTL_SECONDS}-second TTL. Set
325+
* `$forceRefresh` to bypass the cache after a `kid` miss, which
326+
* lets newly-rotated keys be discovered without waiting for TTL
327+
* expiry.
328+
*
329+
* @return array<string, mixed>
330+
*/
331+
private function getCachedJwks(string $clientId, bool $forceRefresh = false): array
332+
{
333+
$now = time();
334+
$entry = $this->jwksCache[$clientId] ?? null;
335+
if (
336+
!$forceRefresh
337+
&& $entry !== null
338+
&& ($now - $entry['fetched_at']) < self::JWKS_CACHE_TTL_SECONDS
339+
) {
340+
return $entry['keys'];
341+
}
342+
343+
$keys = $this->fetchJwks($clientId);
344+
$this->jwksCache[$clientId] = ['keys' => $keys, 'fetched_at' => $now];
345+
346+
return $keys;
347+
}
348+
305349
/**
306350
* Algorithms permitted on the JWS header. WorkOS access tokens are signed
307351
* with RS256; no other algorithm is accepted, in particular `none` is
@@ -370,8 +414,14 @@ private function decodeAccessToken(
370414
throw new \InvalidArgumentException('JWT header missing kid');
371415
}
372416

373-
$jwks = $this->fetchJwks($clientId);
417+
// Try the cached JWKS first; if the `kid` isn't present, force a
418+
// refresh once to handle key rotation, then fail if still unknown.
419+
$jwks = $this->getCachedJwks($clientId);
374420
$jwk = self::findJwkByKid($jwks, $kid);
421+
if ($jwk === null) {
422+
$jwks = $this->getCachedJwks($clientId, forceRefresh: true);
423+
$jwk = self::findJwkByKid($jwks, $kid);
424+
}
375425
if ($jwk === null) {
376426
throw new \InvalidArgumentException('No JWKS key matches JWT kid');
377427
}

tests/SessionManagerTest.php

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,4 +289,80 @@ public function testAuthenticateRejectsUnknownKid(): void
289289
$this->assertFalse($result['authenticated']);
290290
$this->assertSame('invalid_jwt', $result['reason']);
291291
}
292+
293+
public function testAuthenticateCachesJwksAcrossCalls(): void
294+
{
295+
[$jwks, $jwt] = $this->buildSignedJwt([
296+
'sid' => 'session_test',
297+
'exp' => time() + 3600,
298+
]);
299+
300+
$sealed = SessionManager::sealSessionFromAuthResponse(
301+
accessToken: $jwt,
302+
refreshToken: 'ref_test',
303+
cookiePassword: $this->cookiePassword,
304+
);
305+
306+
// Only ONE JWKS response is queued; a second fetch would make
307+
// the MockHandler throw, so successful back-to-back authenticate
308+
// calls prove the cache served the second one.
309+
$client = $this->createMockClient([['status' => 200, 'body' => $jwks]]);
310+
$sessionManager = $client->sessionManager();
311+
312+
$first = $sessionManager->authenticate(
313+
sessionData: $sealed,
314+
cookiePassword: $this->cookiePassword,
315+
clientId: 'client_123',
316+
);
317+
$second = $sessionManager->authenticate(
318+
sessionData: $sealed,
319+
cookiePassword: $this->cookiePassword,
320+
clientId: 'client_123',
321+
);
322+
323+
$this->assertTrue($first['authenticated']);
324+
$this->assertTrue($second['authenticated']);
325+
$this->assertCount(1, $this->requestHistory);
326+
}
327+
328+
public function testAuthenticateRefreshesJwksOnUnknownKid(): void
329+
{
330+
// Seed the cache with a JWKS that only knows `kid_old`, then
331+
// present a token signed with `kid_new`. The kid-miss path
332+
// should force a refresh and find `kid_new` in the second
333+
// JWKS response.
334+
[$oldJwks] = $this->buildSignedJwt(
335+
['sid' => 'session_old', 'exp' => time() + 3600],
336+
'RS256',
337+
'kid_old',
338+
);
339+
340+
[$newJwks, $newJwt] = $this->buildSignedJwt(
341+
['sid' => 'session_new', 'exp' => time() + 3600],
342+
'RS256',
343+
'kid_new',
344+
);
345+
346+
$sealedNew = SessionManager::sealSessionFromAuthResponse(
347+
accessToken: $newJwt,
348+
refreshToken: 'ref_test',
349+
cookiePassword: $this->cookiePassword,
350+
);
351+
352+
$client = $this->createMockClient([
353+
['status' => 200, 'body' => $oldJwks],
354+
['status' => 200, 'body' => $newJwks],
355+
]);
356+
$sessionManager = $client->sessionManager();
357+
358+
$result = $sessionManager->authenticate(
359+
sessionData: $sealedNew,
360+
cookiePassword: $this->cookiePassword,
361+
clientId: 'client_123',
362+
);
363+
364+
$this->assertTrue($result['authenticated']);
365+
$this->assertSame('session_new', $result['session_id']);
366+
$this->assertCount(2, $this->requestHistory);
367+
}
292368
}

0 commit comments

Comments
 (0)