Skip to content

Commit f2f06e1

Browse files
authored
Add caching to remote JWKS fetch (#342)
* feat: add in-memory cache module for storing jwk set * add tests for cache implementation * add test to confirm jwks is cached * add doc comments to cache.rb * specify the time increment (seconds)
1 parent 7b22b63 commit f2f06e1

File tree

5 files changed

+238
-1
lines changed

5 files changed

+238
-1
lines changed

lib/workos.rb

+1
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ def self.key
4545
autoload :AuthenticationFactorAndChallenge, 'workos/authentication_factor_and_challenge'
4646
autoload :AuthenticationResponse, 'workos/authentication_response'
4747
autoload :AuditLogs, 'workos/audit_logs'
48+
autoload :Cache, 'workos/cache'
4849
autoload :Challenge, 'workos/challenge'
4950
autoload :Client, 'workos/client'
5051
autoload :Connection, 'workos/connection'

lib/workos/cache.rb

+94
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# frozen_string_literal: true
2+
3+
module WorkOS
4+
# The Cache module provides a simple in-memory cache for storing values
5+
# This module is not meant to be instantiated in a user space, and is used internally by the SDK
6+
module Cache
7+
# The Entry class represents a cache entry with a value and an expiration time
8+
class Entry
9+
attr_reader :value, :expires_at
10+
11+
# Initializes a new cache entry
12+
# @param value [Object] The value to store in the cache
13+
# @param expires_in_seconds [Integer, nil] The expiration time for the value in seconds, or nil for no expiration
14+
def initialize(value, expires_in_seconds)
15+
@value = value
16+
@expires_at = expires_in_seconds ? Time.now + expires_in_seconds : nil
17+
end
18+
19+
# Checks if the entry has expired
20+
# @return [Boolean] True if the entry has expired, false otherwise
21+
def expired?
22+
return false if expires_at.nil?
23+
24+
Time.now > @expires_at
25+
end
26+
end
27+
28+
class << self
29+
# Fetches a value from the cache, or calls the block to fetch the value if it is not present
30+
# @param key [String] The key to fetch the value for
31+
# @param expires_in [Integer] The expiration time for the value in seconds
32+
# @param force [Boolean] If true, the value will be fetched from the block even if it is present in the cache
33+
# @param block [Proc] The block to call to fetch the value if it is not present in the cache
34+
# @return [Object] The value fetched from the cache or the block
35+
def fetch(key, expires_in: nil, force: false, &block)
36+
entry = store[key]
37+
38+
if force || entry.nil? || entry.expired?
39+
value = block.call
40+
store[key] = Entry.new(value, expires_in)
41+
return value
42+
end
43+
44+
entry.value
45+
end
46+
47+
# Reads a value from the cache
48+
# @param key [String] The key to read the value for
49+
# @return [Object] The value read from the cache, or nil if the value is not present or has expired
50+
def read(key)
51+
entry = store[key]
52+
return nil if entry.nil? || entry.expired?
53+
54+
entry.value
55+
end
56+
57+
# Writes a value to the cache
58+
# @param key [String] The key to write the value for
59+
# @param value [Object] The value to write to the cache
60+
# @param expires_in [Integer] The expiration time for the value in seconds
61+
# @return [Object] The value written to the cache
62+
def write(key, value, expires_in: nil)
63+
store[key] = Entry.new(value, expires_in)
64+
value
65+
end
66+
67+
# Deletes a value from the cache
68+
# @param key [String] The key to delete the value for
69+
def delete(key)
70+
store.delete(key)
71+
end
72+
73+
# Clears all values from the cache
74+
def clear
75+
store.clear
76+
end
77+
78+
# Checks if a value exists in the cache
79+
# @param key [String] The key to check for
80+
# @return [Boolean] True if the value exists and has not expired, false otherwise
81+
def exist?(key)
82+
entry = store[key]
83+
!(entry.nil? || entry.expired?)
84+
end
85+
86+
private
87+
88+
# The in-memory store for the cache
89+
def store
90+
@store ||= {}
91+
end
92+
end
93+
end
94+
end

lib/workos/session.rb

+3-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@ def initialize(user_management:, client_id:, session_data:, cookie_password:)
2323
@session_data = session_data
2424
@client_id = client_id
2525

26-
@jwks = create_remote_jwk_set(URI(@user_management.get_jwks_url(client_id)))
26+
@jwks = Cache.fetch("jwks_#{client_id}", expires_in: 5 * 60) do
27+
create_remote_jwk_set(URI(@user_management.get_jwks_url(client_id)))
28+
end
2729
@jwks_algorithms = @jwks.map { |key| key[:alg] }.compact.uniq
2830
end
2931

spec/lib/workos/cache_spec.rb

+94
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# frozen_string_literal: true
2+
3+
describe WorkOS::Cache do
4+
before { described_class.clear }
5+
6+
describe '.write and .read' do
7+
it 'stores and retrieves data' do
8+
described_class.write('key', 'value')
9+
expect(described_class.read('key')).to eq('value')
10+
end
11+
12+
it 'returns nil if key does not exist' do
13+
expect(described_class.read('missing')).to be_nil
14+
end
15+
end
16+
17+
describe '.fetch' do
18+
it 'returns cached value when present and not expired' do
19+
described_class.write('key', 'value')
20+
fetch_value = described_class.fetch('key') { 'new_value' }
21+
expect(fetch_value).to eq('value')
22+
end
23+
24+
it 'executes block and caches value when not present' do
25+
fetch_value = described_class.fetch('key') { 'new_value' }
26+
expect(fetch_value).to eq('new_value')
27+
end
28+
29+
it 'executes block and caches value when force is true' do
30+
described_class.write('key', 'value')
31+
fetch_value = described_class.fetch('key', force: true) { 'new_value' }
32+
expect(fetch_value).to eq('new_value')
33+
end
34+
end
35+
36+
describe 'expiration' do
37+
it 'expires values after specified time' do
38+
described_class.write('key', 'value', expires_in: 0.1)
39+
expect(described_class.read('key')).to eq('value')
40+
sleep 0.2
41+
expect(described_class.read('key')).to be_nil
42+
end
43+
44+
it 'executes block and caches new value when expired' do
45+
described_class.write('key', 'old_value', expires_in: 0.1)
46+
sleep 0.2
47+
fetch_value = described_class.fetch('key') { 'new_value' }
48+
expect(fetch_value).to eq('new_value')
49+
end
50+
51+
it 'does not expire values when expires_in is nil' do
52+
described_class.write('key', 'value', expires_in: nil)
53+
sleep 0.2
54+
expect(described_class.read('key')).to eq('value')
55+
end
56+
end
57+
58+
describe '.exist?' do
59+
it 'returns true if key exists' do
60+
described_class.write('key', 'value')
61+
expect(described_class.exist?('key')).to be true
62+
end
63+
64+
it 'returns false if expired' do
65+
described_class.write('key', 'value', expires_in: 0.1)
66+
sleep 0.2
67+
expect(described_class.exist?('key')).to be false
68+
end
69+
70+
it 'returns false if key does not exist' do
71+
expect(described_class.exist?('missing')).to be false
72+
end
73+
end
74+
75+
describe '.delete' do
76+
it 'deletes key' do
77+
described_class.write('key', 'value')
78+
described_class.delete('key')
79+
expect(described_class.read('key')).to be_nil
80+
end
81+
end
82+
83+
describe '.clear' do
84+
it 'removes all keys from the cache' do
85+
described_class.write('key1', 'value1')
86+
described_class.write('key2', 'value2')
87+
88+
described_class.clear
89+
90+
expect(described_class.read('key1')).to be_nil
91+
expect(described_class.read('key2')).to be_nil
92+
end
93+
end
94+
end

spec/lib/workos/session_spec.rb

+46
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,52 @@
1919
allow(user_management).to receive(:get_jwks_url).with(client_id).and_return(jwks_url)
2020
end
2121

22+
describe 'JWKS caching' do
23+
before do
24+
WorkOS::Cache.clear
25+
end
26+
27+
it 'caches and returns JWKS' do
28+
expect(Net::HTTP).to receive(:get).once
29+
session1 = WorkOS::Session.new(
30+
user_management: user_management,
31+
client_id: client_id,
32+
session_data: session_data,
33+
cookie_password: cookie_password,
34+
)
35+
36+
session2 = WorkOS::Session.new(
37+
user_management: user_management,
38+
client_id: client_id,
39+
session_data: session_data,
40+
cookie_password: cookie_password,
41+
)
42+
43+
expect(session1.jwks.map(&:export)).to eq(session2.jwks.map(&:export))
44+
end
45+
46+
it 'fetches JWKS from remote when cache is expired' do
47+
expect(Net::HTTP).to receive(:get).twice
48+
session1 = WorkOS::Session.new(
49+
user_management: user_management,
50+
client_id: client_id,
51+
session_data: session_data,
52+
cookie_password: cookie_password,
53+
)
54+
55+
allow(Time).to receive(:now).and_return(Time.now + 301)
56+
57+
session2 = WorkOS::Session.new(
58+
user_management: user_management,
59+
client_id: client_id,
60+
session_data: session_data,
61+
cookie_password: cookie_password,
62+
)
63+
64+
expect(session1.jwks.map(&:export)).to eq(session2.jwks.map(&:export))
65+
end
66+
end
67+
2268
it 'raises an error if cookie_password is nil or empty' do
2369
expect do
2470
WorkOS::Session.new(

0 commit comments

Comments
 (0)