diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f2bf79..6100723 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,13 @@ ## [Unreleased] +## [1.0.0] - 2023-12-28 + +- `jwk_loader/test` for convenience for testing without external dependencies. [#6](https://github.com/anakinj/jwk-loader/pull/6) ([@anakinj](https://github.com/anakinj)) +- Serialize the cached key sets into `JWT::JWK:Set` to avoid generating OpenSSL PKeys for each time the keys are used. [#6](https://github.com/anakinj/jwk-loader/pull/6) ([@anakinj](https://github.com/anakinj)) + ## [0.1.1] - 2022-08-26 -- make sure 'net/http' is required [#1](https://github.com/anakinj/jwk-loader/pull/2) ([@lukad](https://github.com/lukad)). +- make sure 'net/http' is required [#2](https://github.com/anakinj/jwk-loader/pull/2) ([@lukad](https://github.com/lukad)). ## [0.1.0] - 2022-07-06 diff --git a/Gemfile b/Gemfile index d23a666..d72a67d 100644 --- a/Gemfile +++ b/Gemfile @@ -11,5 +11,3 @@ gem "rubocop", "~> 1.32.0" gem "simplecov" gem "vcr" gem "webmock" - -gem "jwt" diff --git a/README.md b/README.md index 159a73e..af887cf 100644 --- a/README.md +++ b/README.md @@ -16,13 +16,34 @@ If bundler is not being used to manage dependencies, install the gem by executin ### Using as a jwks loader when decoding JWT tokens -``` +```ruby require "jwt" require "jwk-loader" JWT.decode(token, nil, true, algorithm: "RS512", jwks: JwkLoader.for_uri(uri: "https://url/to/public/jwks") ) ``` +### Testing endpoints protected by JWT tokens + +When testing HTTP endpoints protected by asymmetric JWT keys the mechanism in `jwk_loader/test` can be used to simplify the process. + +```ruby +require 'jwk_loader/test' + +RSpec.describe 'GET /protected' do + include JwkLoader::Test + + context 'when called with a valid token' do + let(:token) { sign_test_token(token_payload: { user_id: 'user' }, jwk_endpoint: 'https://url/to/public/jwks') } + subject(:response) { get('/protected', { 'HTTP_AUTHORIZATION' => "Bearer #{token}" }) } + + it 'is a success' do + expect(response.status).to eq(200) + end + end +end +``` + ### Configuring the gem ```ruby @@ -45,6 +66,7 @@ JwkLoader.configure do |config| end ``` + ## Development After checking out the repo, run `bundle install` to install dependencies. Then, run `bundle exec rspec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. diff --git a/jwk-loader.gemspec b/jwk-loader.gemspec index 79bd535..0338dcf 100644 --- a/jwk-loader.gemspec +++ b/jwk-loader.gemspec @@ -32,4 +32,5 @@ Gem::Specification.new do |spec| spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] spec.add_dependency "concurrent-ruby" + spec.add_dependency "jwt", "~> 2.6" end diff --git a/lib/jwk-loader.rb b/lib/jwk-loader.rb index d63df67..f59dd93 100644 --- a/lib/jwk-loader.rb +++ b/lib/jwk-loader.rb @@ -15,17 +15,27 @@ def for_uri(**options) JwksUriProvider.new(**options) end + def cache + config[:cache] + end + def configure yield config end - private - def config @config ||= JwkLoader::Config.new.tap do |cfg| cfg[:cache] = MemoryCache.new cfg[:cache_grace_period] = 900 end end + + def reset! + @config = nil + end + + def memory_store + @memory_store ||= MemoryCache.new + end end end diff --git a/lib/jwk_loader/jwks.rb b/lib/jwk_loader/jwks.rb index d406dd3..dd778af 100644 --- a/lib/jwk_loader/jwks.rb +++ b/lib/jwk_loader/jwks.rb @@ -14,6 +14,10 @@ def from_uri(uri) from_json(response.body) end + def from_memory(uri) + JwkLoader.memory_store.fetch(uri) + end + def from_json(jwks_json) JSON.parse(jwks_json, symbolize_names: true) end diff --git a/lib/jwk_loader/jwks_uri_provider.rb b/lib/jwk_loader/jwks_uri_provider.rb index b51d48a..00e2229 100644 --- a/lib/jwk_loader/jwks_uri_provider.rb +++ b/lib/jwk_loader/jwks_uri_provider.rb @@ -1,10 +1,6 @@ # frozen_string_literal: true module JwkLoader - def self.cache - @cache ||= MemoryCache.new - end - class JwksUriProvider attr_reader :uri, :cache, :cache_grace_period @@ -22,7 +18,7 @@ def call(options) private def jwks - from_cache || from_uri + from_cache || from_memory || from_uri end def invalidate_cache! @@ -35,12 +31,17 @@ def from_cache cache_entry&.fetch(:jwks) end + def from_memory + JwkLoader::Jwks.from_memory(uri) + end + def cache_entry cache.fetch(uri) end def from_uri - JwkLoader::Jwks.from_uri(uri).tap do |jwks| + data = JwkLoader::Jwks.from_uri(uri) + JWT::JWK::Set.new(data).tap do |jwks| cache.store(uri, jwks: jwks, fetched_at: Time.now) end end diff --git a/lib/jwk_loader/test.rb b/lib/jwk_loader/test.rb new file mode 100644 index 0000000..a5934a7 --- /dev/null +++ b/lib/jwk_loader/test.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module JwkLoader + module Test + def generate_signing_key(algorithm:) + case algorithm + when "RS256", "RS384", "RS512" + OpenSSL::PKey::RSA.new(2048) + when "ES256" + OpenSSL::PKey::EC.generate("prime256v1") + else + raise "Unsupported algorithm: #{algorithm}" + end + end + + def test_signing_key_for(jwk_endpoint:, algorithm: "RS512") + key_set = JwkLoader.memory_store.fetch(jwk_endpoint) + + if key_set.nil? + key_set = JWT::JWK::Set.new([generate_signing_key(algorithm: algorithm)]) + JwkLoader.memory_store.store(jwk_endpoint, key_set) + end + + key_set.first + end + + def sign_test_token(token_payload:, jwk_endpoint:, algorithm: "RS512") + key = test_signing_key_for(jwk_endpoint: jwk_endpoint, algorithm: algorithm) + JWT.encode(token_payload, key.signing_key, algorithm, kid: key[:kid]) + end + end +end diff --git a/lib/jwk_loader/version.rb b/lib/jwk_loader/version.rb index 1cb6562..01786a9 100644 --- a/lib/jwk_loader/version.rb +++ b/lib/jwk_loader/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module JwkLoader - VERSION = "0.1.1" + VERSION = "1.0.0" end diff --git a/spec/jwk_loader/test_spec.rb b/spec/jwk_loader/test_spec.rb new file mode 100644 index 0000000..453e239 --- /dev/null +++ b/spec/jwk_loader/test_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "jwk_loader/test" + +RSpec.describe JwkLoader::Test do + subject(:test_context) do + Class.new do + include JwkLoader::Test + end.new + end + + describe "#sign_test_token" do + context "when asked to sign a payload" do + it "returns a signed token that can be verified" do + token = test_context.sign_test_token(token_payload: {}, + jwk_endpoint: "https://example.com/.well-known/jwks.json", + algorithm: "RS512") + expect(token).to be_a(String) + decoded_token = JWT.decode(token, nil, true, algorithm: "RS512", jwks: JwkLoader.for_uri(uri: "https://example.com/.well-known/jwks.json")) + expect(decoded_token.first).to eq({}) + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 5786213..5827f77 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -20,8 +20,8 @@ c.syntax = :expect end - config.before(:each) do - ::JwkLoader.cache.clear + config.after(:each) do + ::JwkLoader.reset! end end