diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3877f28 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,21 @@ +name: CI + +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + ruby-version: ['3.0', '3.1', '3.2', '3.3', ruby-head] + + steps: + - uses: actions/checkout@v4 + - name: Set up Ruby ${{ matrix.ruby-version }} + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby-version }} + bundler-cache: true # 'bundle install' and cache + - name: Run specs + run: | + bundle exec rspec \ No newline at end of file diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..94234ee --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,17 @@ +name: Lint + +on: [push, pull_request] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Ruby ${{ matrix.ruby-version }} + uses: ruby/setup-ruby@v1 + with: + ruby-version: 2.7 + bundler-cache: true # 'bundle install' and cache + - name: Run specs + run: | + bundle exec rubocop \ No newline at end of file diff --git a/lib/warden/jwt_auth.rb b/lib/warden/jwt_auth.rb index 4f11be3..ed5d6e2 100644 --- a/lib/warden/jwt_auth.rb +++ b/lib/warden/jwt_auth.rb @@ -19,7 +19,7 @@ module Warden module JWTAuth extend Dry::Configurable - @@jwks = nil + @jwks = nil def symbolize_keys(hash) hash.transform_keys(&:to_sym) @@ -39,14 +39,18 @@ def constantize_values(hash) end def init_jkws_loader(url) - if url - @@jwks = JWKS.new(url) - JWTAuth.config.algorithm = @@jwks.algo - end + return unless url + + @jwks = JWKS.new(url) + JWTAuth.config.algorithm = @jwks.algo end def self.jwks - @@jwks + @jwks + end + + def jwks + self.class.jwks end module_function :init_jkws_loader, :constantize_values, :symbolize_keys, :upcase_first_items diff --git a/lib/warden/jwt_auth/errors.rb b/lib/warden/jwt_auth/errors.rb index e350ba7..b7a633f 100644 --- a/lib/warden/jwt_auth/errors.rb +++ b/lib/warden/jwt_auth/errors.rb @@ -17,7 +17,7 @@ class NilUser < JWT::DecodeError class WrongScope < JWT::DecodeError end - # Error raised when trying to decode a token without scope claim and + # Error raised when trying to decode a token without scope claim and # no default_scope set class MissingScopeWithNoDefaultFallback < JWT::DecodeError end diff --git a/lib/warden/jwt_auth/jwks.rb b/lib/warden/jwt_auth/jwks.rb index 2e8c3ec..0b008e2 100644 --- a/lib/warden/jwt_auth/jwks.rb +++ b/lib/warden/jwt_auth/jwks.rb @@ -1,18 +1,22 @@ +# frozen_string_literal: true + module Warden module JWTAuth + # JWKS fetcher class. + # + # Uses a Rails cache key to store the payload class JWKS - - JWKS_CACHE_KEY = "auth/jwks-json".freeze + JWKS_CACHE_KEY = 'auth/jwks-json' def initialize(url) @jwks_url = url end - def loader(options={}) + def loader(options = {}) jwks(force: options[:invalidate]) || {} end - def algo(key_index=0) + def algo(key_index = 0) loader[:keys][key_index][:alg] end @@ -20,9 +24,7 @@ def algo(key_index=0) def fetch_jwks response = Faraday.get(@jwks_url) - if response.status == 200 - JSON.parse(response.body.to_s) - end + JSON.parse(response.body.to_s) if response.status == 200 end def jwks(force: false) @@ -32,4 +34,4 @@ def jwks(force: false) end end end -end \ No newline at end of file +end diff --git a/lib/warden/jwt_auth/strategy.rb b/lib/warden/jwt_auth/strategy.rb index 429212a..a3e9c65 100644 --- a/lib/warden/jwt_auth/strategy.rb +++ b/lib/warden/jwt_auth/strategy.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'warden' +require 'semantic_logger' module Warden module JWTAuth @@ -21,13 +22,13 @@ def authenticate! aud = EnvHelper.aud_header(env) user = UserDecoder.new.call(token, scope, aud) - logger.warn("JWT accepted", user: user.id) if user + logger.warn('JWT accepted', user: user.id) if user success!(user) rescue JWT::DecodeError => e - logger.error("JWT decoding failed", message: e.message) + logger.error('JWT decoding failed', message: e.message) - fail!(e.message) + fail!(e.message) end private diff --git a/lib/warden/jwt_auth/token_decoder.rb b/lib/warden/jwt_auth/token_decoder.rb index 52ae54f..c6407cb 100644 --- a/lib/warden/jwt_auth/token_decoder.rb +++ b/lib/warden/jwt_auth/token_decoder.rb @@ -26,18 +26,9 @@ def call(token) def decode(token, secret) if JWTAuth.config.jwks_url jwks = Warden::JWTAuth.jwks - JWT.decode(token, - secret, - true, - algorithm: jwks.algo, - verify_jti: true, - jwks: jwks.loader)[0] + JWT.decode(token, secret, true, algorithm: jwks.algo, verify_jti: true, jwks: jwks.loader)[0] else - JWT.decode(token, - secret, - true, - algorithm: algorithm, - verify_jti: true)[0] + JWT.decode(token, secret, true, algorithm: algorithm, verify_jti: true)[0] end end end diff --git a/lib/warden/jwt_auth/user_decoder.rb b/lib/warden/jwt_auth/user_decoder.rb index b5586ff..2b861b3 100644 --- a/lib/warden/jwt_auth/user_decoder.rb +++ b/lib/warden/jwt_auth/user_decoder.rb @@ -27,14 +27,17 @@ def initialize(**args) # encoded user # @raise [Errors::NilUser] when decoded user is nil # @raise [Errors::WrongScope] when encoded scope does not match with scope - # @raise [Errors::WrongAud] when encoded aud does not match with aud - # argument + # @raise [Errors::WrongAud] when encoded aud does not match with aud argument + # rubocop:disable Metrics/MethodLength def call(token, scope, aud) config = JWTAuth.config payload = TokenDecoder.new.call(token) if payload_has_no_scope?(payload) - raise Errors::MissingScopeWithNoDefaultFallback, 'payload has no scp claim and no default_scope is set' unless config.default_scope + unless config.default_scope + raise Errors::MissingScopeWithNoDefaultFallback, 'payload has no scp claim and no default_scope is set' + end + scope = payload['scp'] = config.default_scope end @@ -43,6 +46,7 @@ def call(token, scope, aud) check_valid_user(payload, user, scope) user end + # rubocop:enable Metrics/MethodLength private @@ -50,8 +54,7 @@ def check_valid_claims(payload, scope, aud) raise Errors::WrongScope, 'wrong scope' unless helper.scope_matches?(payload, scope) if aud.nil? && !payload['aud'].nil? - raise Errors::MissingAudHeaderWithNoFallback, 'aud_header is missing and valid_auds setting is unset' unless JWTAuth.config.valid_auds - raise Errors::WrongAud, 'aud_header missing and aud claim is not part of the valid_auds setting' unless helper.aud_matches_valid_ones?(payload) + check_empty_aud_header(payload) else raise Errors::WrongAud, 'wrong aud' unless helper.aud_matches?(payload, aud) end @@ -59,6 +62,18 @@ def check_valid_claims(payload, scope, aud) scope end + def check_empty_aud_header(payload) + unless JWTAuth.config.valid_auds + raise Errors::MissingAudHeaderWithNoFallback, 'aud_header is missing and valid_auds setting is unset' + end + + # rubocop:disable Style/GuardClause + unless helper.aud_matches_valid_ones?(payload) + raise Errors::WrongAud, 'aud_header missing and aud claim is not part of the valid_auds setting' + end + # rubocop:enable Style/GuardClause + end + def check_valid_user(payload, user, scope) raise Errors::NilUser, 'nil user' unless user diff --git a/spec/support/fixtures.rb b/spec/support/fixtures.rb index a37b9e2..fa9e0b7 100644 --- a/spec/support/fixtures.rb +++ b/spec/support/fixtures.rb @@ -7,6 +7,10 @@ module Fixtures class User include Singleton + def id + 1 + end + def jwt_subject '1' end diff --git a/spec/warden/jwt_auth/strategy_spec.rb b/spec/warden/jwt_auth/strategy_spec.rb index 531f1c1..47e7c4d 100644 --- a/spec/warden/jwt_auth/strategy_spec.rb +++ b/spec/warden/jwt_auth/strategy_spec.rb @@ -31,8 +31,9 @@ end end - context "when issuer is configured" do - let(:token) { Warden::JWTAuth::TokenEncoder.new.call({issuer: issuer}) } + # rubocop:disable RSpec/NestedGroups + context 'when issuer is configured' do + let(:token) { Warden::JWTAuth::TokenEncoder.new.call({ issuer: issuer }) } let(:env) { { 'HTTP_AUTHORIZATION' => "Bearer #{token}" } } let(:issuer) { 'http://example.com' } let(:strategy) { described_class.new(env, :user) } @@ -43,20 +44,21 @@ end end - context "when the issuer claim matches the configured issuer" do + context 'when the issuer claim matches the configured issuer' do it 'returns true' do expect(strategy).to be_valid end end - context "when the issuer claim does not match the configured issuer" do - let(:token) { Warden::JWTAuth::TokenEncoder.new.call({"iss" => 'http://example.org'}) } + context 'when the issuer claim does not match the configured issuer' do + let(:token) { Warden::JWTAuth::TokenEncoder.new.call({ 'iss' => 'http://example.org' }) } it 'returns false' do expect(strategy).not_to be_valid end end end + # rubocop:enable RSpec/NestedGroups end describe '#persist?' do diff --git a/spec/warden/jwt_auth/token_encoder_spec.rb b/spec/warden/jwt_auth/token_encoder_spec.rb index 7803dbb..3308606 100644 --- a/spec/warden/jwt_auth/token_encoder_spec.rb +++ b/spec/warden/jwt_auth/token_encoder_spec.rb @@ -68,6 +68,7 @@ context 'with issuer claim' do let(:issuer) { 'http://example.com' } + before do Warden::JWTAuth.configure do |config| config.issuer = issuer