diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 18be359..2006e2b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,7 +18,7 @@ jobs: strategy: fail-fast: false matrix: - RUBY_VERSION: ["2.5", "2.6", "3.1"] + RUBY_VERSION: ["3.1", "3.4"] steps: - name: Check out code @@ -30,8 +30,11 @@ jobs: ruby-version: ${{ matrix.RUBY_VERSION }} bundler-cache: true + - name: Lint + run: bundle exec rubocop + - name: Run Tests - run: bundle exec rake + run: bundle exec rspec results: if: ${{ always() }} diff --git a/.gitignore b/.gitignore index d87d4be..5be7ee7 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ spec/reports test/tmp test/version_tmp tmp +.rubocop-https* \ No newline at end of file diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..f508c47 --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,2 @@ +inherit_from: + - https://raw.githubusercontent.com/aptible/dryer-lint/main/rspec_3_plus/.rubocop.base.yml diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7efe67b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM ruby:3.4 + +WORKDIR /app + +COPY Gemfile /app/Gemfile +COPY fridge.gemspec /app/fridge.gemspec +COPY lib/ /app/lib + +RUN bundle install + +COPY spec/ /app/spec +COPY .rspec /app/.rspec +COPY Rakefile /app/Rakefile + +CMD ["true"] diff --git a/Gemfile b/Gemfile index 7ad6fc5..6c7d002 100644 --- a/Gemfile +++ b/Gemfile @@ -1,3 +1,5 @@ +# frozen_string_literal: true + source 'https://rubygems.org' # Specify your gem's dependencies in fridge.gemspec diff --git a/Rakefile b/Rakefile index 1a4d040..362be1e 100644 --- a/Rakefile +++ b/Rakefile @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'bundler/gem_tasks' require 'aptible/tasks' diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..42f231e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,6 @@ +services: + fridge: + build: + context: . + volumes: + - .:/app diff --git a/fridge.gemspec b/fridge.gemspec index 39e5595..5391c31 100644 --- a/fridge.gemspec +++ b/fridge.gemspec @@ -1,6 +1,6 @@ -# encoding: utf-8 +# frozen_string_literal: true -lib = File.expand_path('../lib', __FILE__) +lib = File.expand_path('lib', __dir__) $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) require 'English' @@ -17,16 +17,23 @@ Gem::Specification.new do |spec| spec.license = 'MIT' spec.files = `git ls-files`.split($RS) - spec.test_files = spec.files.grep(%r{^spec/}) spec.require_paths = ['lib'] + spec.required_ruby_version = '>= 3.0' spec.add_dependency 'gem_config' spec.add_dependency 'jwt', '~> 2.3.0' - spec.add_development_dependency 'aptible-tasks' spec.add_development_dependency 'pry' spec.add_development_dependency 'rails' spec.add_development_dependency 'rake' - spec.add_development_dependency 'rspec', '~> 3.0' + spec.add_development_dependency 'rspec' spec.add_development_dependency 'rspec-rails' + spec.add_development_dependency 'rubocop' + spec.add_development_dependency 'rubocop-capybara' + spec.add_development_dependency 'rubocop-factory_bot' + spec.add_development_dependency 'rubocop-rails' + spec.add_development_dependency 'rubocop-rake' + spec.add_development_dependency 'rubocop-rspec' + spec.add_development_dependency 'rubocop-rspec_rails' + spec.metadata['rubygems_mfa_required'] = 'true' end diff --git a/justfile b/justfile new file mode 100644 index 0000000..7659e4c --- /dev/null +++ b/justfile @@ -0,0 +1,15 @@ +init: + rm Gemfile.lock || true + docker compose build + +test: + docker compose run fridge bundle exec rspec + +lint: + docker compose run fridge bundle exec rubocop + +pretty: + docker compose run fridge bundle exec rubocop -a + +shell: + docker compose run fridge bash diff --git a/lib/fridge.rb b/lib/fridge.rb index d6096ac..451515c 100644 --- a/lib/fridge.rb +++ b/lib/fridge.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'gem_config' require 'fridge/version' @@ -8,6 +10,7 @@ require 'fridge/railtie' if defined?(Rails) +# Fridge provides JWT token validation for distributed resource servers. module Fridge include GemConfig::Base @@ -15,10 +18,7 @@ module Fridge has :private_key, classes: [String] has :public_key, classes: [String] - # rubocop:disable Style/PercentLiteralDelimiters has :signing_algorithm, values: %w[RS512 RS256], default: 'RS512' - # rubocop:enable Style/PercentLiteralDelimiters - # A validator must raise an exception or return a false value for an # invalid token has :validator, classes: [Proc], default: ->(token) { token.valid? } diff --git a/lib/fridge/access_token.rb b/lib/fridge/access_token.rb index 6faf211..feae87a 100644 --- a/lib/fridge/access_token.rb +++ b/lib/fridge/access_token.rb @@ -1,6 +1,9 @@ +# frozen_string_literal: true + require 'jwt' module Fridge + # Represents a JWT-based access token with encoding and decoding support. class AccessToken attr_accessor :id, :issuer, :subject, :scope, :expires_at, :actor, :jwt, :attributes @@ -15,7 +18,7 @@ def initialize(jwt_or_options = nil) else {} end - [:id, :issuer, :subject, :scope, :expires_at, :actor].each do |key| + %i[id issuer subject scope expires_at actor].each do |key| send "#{key}=", options.delete(key) end self.attributes = options @@ -35,7 +38,7 @@ def serialize def encode_and_sign h = {} - [:id, :issuer, :subject, :scope, :expires_at, :actor].each do |key| + %i[id issuer subject scope expires_at actor].each do |key| h[key] = send(key) end h.merge!(attributes) @@ -63,7 +66,7 @@ def valid? end def expired? - expires_at.nil? || expires_at < Time.now + expires_at.nil? || expires_at < Time.zone.now end def private_key @@ -107,7 +110,7 @@ def respond_to_missing?(method, include_private = false) end def validate_parameters! - [:subject, :expires_at].each do |attribute| + %i[subject expires_at].each do |attribute| next if send(attribute) raise SerializationError, "Missing attribute: #{attribute}" @@ -159,12 +162,12 @@ def decode_from_jwt(hash) scope: hash.delete('scope') }.delete_if { |_, v| v.nil? } - hash.delete('exp').tap { |e| out[:expires_at] = Time.at(e) if e } + hash.delete('exp').tap { |e| out[:expires_at] = Time.zone.at(e) if e } hash.delete('act').tap { |a| out[:actor] = decode_from_jwt(a) if a } # Extra attributes hash.delete_if { |_, v| v.nil? } - hash = Hash[hash.map { |k, v| [k.to_sym, v] }] + hash = hash.to_h { |k, v| [k.to_sym, v] } out.merge!(hash) out diff --git a/lib/fridge/expired_token.rb b/lib/fridge/expired_token.rb index 52ed2b4..9d420c1 100644 --- a/lib/fridge/expired_token.rb +++ b/lib/fridge/expired_token.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Fridge class ExpiredToken < InvalidToken end diff --git a/lib/fridge/invalid_token.rb b/lib/fridge/invalid_token.rb index 7ceb344..53b9a84 100644 --- a/lib/fridge/invalid_token.rb +++ b/lib/fridge/invalid_token.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Fridge class InvalidToken < StandardError end diff --git a/lib/fridge/rails_helpers.rb b/lib/fridge/rails_helpers.rb index 56d8473..7ae8ac3 100644 --- a/lib/fridge/rails_helpers.rb +++ b/lib/fridge/rails_helpers.rb @@ -1,4 +1,7 @@ +# frozen_string_literal: true + module Fridge + # Rails controller helpers for bearer token and session cookie management. module RailsHelpers extend ActiveSupport::Concern @@ -8,15 +11,15 @@ module RailsHelpers end def token_scope - current_token.scope if current_token + current_token&.scope end def token_subject - current_token.subject if current_token + current_token&.subject end def token_actor - current_token.actor if current_token + current_token&.actor end def current_token @@ -29,15 +32,15 @@ def current_token def bearer_token header = request.env['HTTP_AUTHORIZATION'] - header.gsub(/^Bearer /, '') unless header.nil? + header&.gsub(/^Bearer /, '') end def session_subject - session_token.subject if session_token + session_token&.subject end def session_actor - session_token.actor if session_token + session_token&.actor end def session_token diff --git a/lib/fridge/railtie.rb b/lib/fridge/railtie.rb index 862f82c..79b0475 100644 --- a/lib/fridge/railtie.rb +++ b/lib/fridge/railtie.rb @@ -1,9 +1,12 @@ +# frozen_string_literal: true + require 'fridge/rails_helpers' module Fridge + # Railtie that automatically includes RailsHelpers into ActionController::Base. class Railtie < Rails::Railtie initializer 'fridge.rails_helpers' do - ActionController::Base.send :include, RailsHelpers + ActionController::Base.include RailsHelpers end end end diff --git a/lib/fridge/serialization_error.rb b/lib/fridge/serialization_error.rb index d444e52..ab83cdb 100644 --- a/lib/fridge/serialization_error.rb +++ b/lib/fridge/serialization_error.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Fridge class SerializationError < StandardError end diff --git a/lib/fridge/version.rb b/lib/fridge/version.rb index 66dbe24..008a7f0 100644 --- a/lib/fridge/version.rb +++ b/lib/fridge/version.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module Fridge - VERSION = '1.0.1'.freeze + VERSION = '1.0.1' end diff --git a/spec/fixtures/app.rb b/spec/fixtures/app.rb index 3a7306d..7b956d8 100644 --- a/spec/fixtures/app.rb +++ b/spec/fixtures/app.rb @@ -1,3 +1,11 @@ +# frozen_string_literal: true + +module Aptible + module Auth + def self.configuration; end + end +end + module Rails class App def env_config diff --git a/spec/fridge/access_token_spec.rb b/spec/fridge/access_token_spec.rb index d4444dd..5693dcf 100644 --- a/spec/fridge/access_token_spec.rb +++ b/spec/fridge/access_token_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' require 'json' @@ -8,12 +10,12 @@ before { Fridge.configuration.public_key = public_key.to_s } - it 'should accept a hash' do + it 'accepts a hash' do access_token = described_class.new(id: 'foobar') expect(access_token.id).to eq 'foobar' end - it 'should accept a JWT' do + it 'accepts a JWT' do jwt = JWT.encode( { id: 'foobar', exp: Time.now.to_i + 10 }, private_key, 'RS512' @@ -22,17 +24,17 @@ expect(access_token.id).to eq 'foobar' end - it 'should raise an error on an invalid JWT' do + it 'raises an error on an invalid JWT' do expect { described_class.new('foobar') } .to raise_error Fridge::InvalidToken end - it 'should raise an error on an incorrectly signed JWT' do + it 'raises an error on an incorrectly signed JWT' do jwt = JWT.encode({ id: 'foobar' }, OpenSSL::PKey::RSA.new(1024), 'RS512') expect { described_class.new(jwt) }.to raise_error Fridge::InvalidToken end - it 'should raise an error on an expired JWT' do + it 'raises an error on an expired JWT' do jwt = JWT.encode( { id: 'foobar', exp: Time.now.to_i - 10 }, private_key, 'RS512' @@ -41,7 +43,7 @@ end # http://bit.ly/jwt-none-vulnerability - it 'should raise an error with { "alg": "none" }' do + it 'raises an error with { "alg": "none" }' do jwt = "#{Base64.encode64({ typ: 'JWT', alg: 'none' }.to_json).chomp}." \ "#{Base64.encode64({ id: 'foobar' }.to_json).chomp}" expect(JWT.decode(jwt, nil, false)[0]).to eq('id' => 'foobar') @@ -50,13 +52,15 @@ end describe '#serialize' do + subject { described_class.new(options) } + let(:options) do { id: SecureRandom.uuid, issuer: 'https://auth.aptible.com', subject: "https://auth.aptible.com/users/#{SecureRandom.uuid}", scope: 'read', - expires_at: Time.now + 3600 + expires_at: Time.zone.now + 3600 } end @@ -65,9 +69,7 @@ before { Fridge.configuration.private_key = private_key.to_s } - subject { described_class.new(options) } - - it 'should return a JWT comprised of token attributes' do + it 'returns a JWT comprised of token attributes' do hash = { id: subject.id, iss: subject.issuer, @@ -78,12 +80,12 @@ expect(subject.serialize).to eq JWT.encode(hash, private_key, 'RS512') end - it 'should be verifiable with the application public key' do + it 'is verifiable with the application public key' do expect { JWT.decode(subject.serialize, public_key, true, algorithm: 'RS512') } .not_to raise_error end - it 'should be tamper-resistant' do + it 'is tamper-resistant' do header, _, signature = subject.serialize.split('.') tampered_claim = JWT::Base64.url_encode({ foo: 'bar' }.to_json) tampered_token = [header, tampered_claim, signature].join('.') @@ -93,23 +95,24 @@ end.to raise_error JWT::DecodeError end - it 'should represent :exp in seconds since the epoch' do + it 'represents :exp in seconds since the epoch' do hash, = JWT.decode(subject.serialize, public_key, true, algorithm: 'RS512') expect(hash['exp']).to be_a Integer end - it 'should be deterministic' do - expect(subject.serialize).to eq subject.serialize + it 'is deterministic' do + first = subject.serialize + expect(subject.serialize).to eq first end - it 'should complement #initialize' do + it 'complements #initialize' do copy = described_class.new(subject.serialize) expect(copy.subject).to eq subject.subject expect(copy.expires_at.to_i).to eq subject.expires_at.to_i expect(copy.scope).to eq subject.scope end - it 'should include custom attributes' do + it 'includes custom attributes' do subject = described_class.new(options.merge(foo: 'bar')) copy = described_class.new(subject.serialize) @@ -119,12 +122,12 @@ expect(copy.respond_to?(:bar)).to be_falsey end - it 'should raise an error if required attributes are missing' do + it 'raises an error if required attributes are missing' do subject.subject = nil expect { subject.serialize }.to raise_error Fridge::SerializationError end - it 'should encode and decode :actor as :act' do + it 'encodes and decode :actor as :act' do # The `act` field can recursively encode additional # claims, so we check those too. actor = { subject: 'foo', username: 'test', actor: { subject: 'bar' } } @@ -142,31 +145,33 @@ expect(new.actor).to eq(actor) end - it 'should be idempotent' do + it 'is idempotent' do subject = described_class.new(options) - expect(subject.serialize).to eq(subject.serialize) + first = subject.serialize + expect(subject.serialize).to eq(first) end - it 'should be idempotent with an actor' do + it 'is idempotent with an actor' do actor = { subject: 'foo', username: 'test', actor: { subject: 'bar' } } subject = described_class.new(options.merge(actor: actor)) - expect(subject.serialize).to eq(subject.serialize) + first = subject.serialize + expect(subject.serialize).to eq(first) end end describe '#expired?' do - it 'should return true if the access token has expired' do - subject.stub(:expires_at) { Time.now - 3600 } + it 'returns true if the access token has expired' do + subject.stub(:expires_at) { Time.zone.now - 3600 } expect(subject).to be_expired end - it 'should return true if the access token has no expiration set' do + it 'returns true if the access token has no expiration set' do subject.stub(:expires_at) { nil } expect(subject).to be_expired end - it 'should return false otherwise' do - subject.stub(:expires_at) { Time.now + 3600 } + it 'returns false otherwise' do + subject.stub(:expires_at) { Time.zone.now + 3600 } expect(subject).not_to be_expired end end diff --git a/spec/fridge/rails_helpers_spec.rb b/spec/fridge/rails_helpers_spec.rb index 77c2c6d..676b7b5 100644 --- a/spec/fridge/rails_helpers_spec.rb +++ b/spec/fridge/rails_helpers_spec.rb @@ -1,10 +1,13 @@ +# frozen_string_literal: true + require 'spec_helper' require 'fixtures/app' describe Fridge::RailsHelpers do include RSpec::Rails::ControllerExampleGroup - controller(ActionController::Base) { include Fridge::RailsHelpers } + helpers = described_class + controller(ActionController::Base) { include helpers } let(:organization_url) do "https://auth.aptible.com/users/#{SecureRandom.uuid}" @@ -15,15 +18,17 @@ let(:options) do { subject: "https://auth.aptible.com/users/#{SecureRandom.uuid}", - expires_at: Time.now + 3600 + expires_at: Time.zone.now + 3600 } end let(:access_token) { Fridge::AccessToken.new(options) } let(:cookies) { controller.send(:cookies) } - before { Fridge.configuration.private_key = private_key.to_s } - before { Fridge.configuration.public_key = public_key.to_s } + before do + Fridge.configuration.private_key = private_key.to_s + Fridge.configuration.public_key = public_key.to_s + end describe '#bearer_token' do it 'returns the bearer token from the Authorization: header' do @@ -64,26 +69,26 @@ describe '#current_token' do before { controller.stub(:bearer_token) { access_token.serialize } } - it 'should raise an error if the token is not a valid JWT' do + it 'raises an error if the token is not a valid JWT' do controller.stub(:bearer_token) { 'foobar' } expect { controller.current_token }.to raise_error Fridge::InvalidToken end - it 'should raise an error if the token has expired' do - access_token.expires_at = Time.now - 3600 + it 'raises an error if the token has expired' do + access_token.expires_at = Time.zone.now - 3600 expect { controller.current_token }.to raise_error Fridge::InvalidToken end - it 'should raise an error if custom validation fails' do + it 'raises an error if custom validation fails' do Fridge.configuration.validator = ->(_) { false } expect { controller.current_token }.to raise_error Fridge::InvalidToken end - it 'should not raise an error if a valid token is passed' do + it 'does not raise an error if a valid token is passed' do expect { controller.current_token }.not_to raise_error end - it 'should return the token if a valid token is passed' do + it 'returns the token if a valid token is passed' do expect(controller.current_token.id).to eq access_token.id end end @@ -101,19 +106,19 @@ end describe '#session_token' do - it 'should delete all cookies on error' do + it 'deletes all cookies on error' do cookies[:fridge_session] = 'foobar' controller.session_token expect(cookies.deleted?(:fridge_session, domain: 'auth.aptible.com')) .to be true end - it 'should return nil on error' do + it 'returns nil on error' do cookies[:fridge_session] = 'foobar' expect(controller.session_token).to be_nil end - it 'should return the token stored in :fridge_session' do + it 'returns the token stored in :fridge_session' do cookies[:fridge_session] = access_token.serialize expect(controller.session_token.id).to eq access_token.id end @@ -121,12 +126,12 @@ context 'with a non-:read scope' do before { options.merge!(scope: 'manage') } - it 'should downgrade the token' do + it 'downgrades the token' do cookies[:fridge_session] = access_token.serialize expect(controller.session_token.scope).to eq 'read' end - it 'should not change the validity of a token' do + it 'does not change the validity of a token' do cookies[:fridge_session] = access_token.serialize expect(controller.session_token).to be_valid end @@ -134,38 +139,38 @@ end describe '#validate_token' do - it 'should return false if the token is invalid' do + it 'returns false if the token is invalid' do Fridge.configuration.validator = ->(_) { false } expect(controller.validate_token(access_token)).to be false end - it 'should return false if the token validator fails' do + it 'returns false if the token validator fails' do Fridge.configuration.validator = ->(_) { raise 'Foobar' } expect(controller.validate_token(access_token)).to be false end - it 'should return the token if valid' do + it 'returns the token if valid' do Fridge.configuration.validator = ->(_) { true } expect(controller.validate_token(access_token)).to eq access_token end end - describe '#validate_token' do - it 'should raise an exception if the token is invalid' do + describe '#validate_token!' do + it 'raises an exception if the token is invalid' do Fridge.configuration.validator = ->(_) { false } expect { controller.validate_token!(access_token) } .to raise_error Fridge::InvalidToken end - it 'should return the token if valid' do + it 'returns the token if valid' do Fridge.configuration.validator = ->(_) { true } expect(controller.validate_token!(access_token)).to eq access_token end end describe '#sessionize_token' do - it 'should set a session cookie' do - Rails.stub_chain(:env, :development?) { false } + it 'sets a session cookie' do + allow(Rails).to receive(:env).and_return(double(development?: false)) controller.sessionize_token(access_token) expect(cookies[:fridge_session]).to eq access_token.serialize end @@ -179,16 +184,16 @@ end describe '#write_shared_cookie' do - before { Rails.stub_chain(:env, :development?) { false } } + before { allow(Rails).to receive(:env).and_return(double(development?: false)) } - it 'should save cookie' do + it 'saves cookie' do controller.write_shared_cookie(:organization_url, organization_url) expect(cookies[:organization_url]).to eq organization_url end end describe '#read_shared_cookie' do - it 'should read cookie' do + it 'reads cookie' do cookies[:organization_url] = { value: organization_url } expect(controller.read_shared_cookie(:organization_url)).to( eq organization_url @@ -197,9 +202,9 @@ end describe '#delete_shared_cookie' do - before { Rails.stub_chain(:env, :development?) { false } } + before { allow(Rails).to receive(:env).and_return(double(development?: false)) } - it 'should delete cookie' do + it 'deletes cookie' do controller.write_shared_cookie(:organization_url, organization_url) controller.delete_shared_cookie(:organization_url) expect(cookies[:organization_url]).to be_nil @@ -207,30 +212,30 @@ end describe '#fridge_cookie_options' do - before { Rails.stub_chain(:env, :development?) { false } } + before { allow(Rails).to receive(:env).and_return(double(development?: false)) } it 'are configurable' do Fridge.configuration.cookie_options = { foobar: true } options = controller.fridge_cookie_options expect(options[:domain]).to eq 'auth.aptible.com' - expect(options[:foobar]).to eq true + expect(options[:foobar]).to be true end it 'restricts cookies to the specific subdomain' do - auth = class_double('Aptible::Auth').as_stubbed_const - allow(auth).to receive_message_chain(:configuration, :root_url) do - 'https://auth-bob.aptible-sandbox.com' - end + auth = class_double(Aptible::Auth).as_stubbed_const + allow(auth).to receive(:configuration).and_return( + double(root_url: 'https://auth-bob.aptible-sandbox.com') + ) options = controller.fridge_cookie_options expect(options[:domain]).to eq 'auth-bob.aptible-sandbox.com' end it 'handles local development using defaults' do - auth = class_double('Aptible::Auth').as_stubbed_const - allow(auth).to receive_message_chain(:configuration, :root_url) do - 'https://localhost:4000' - end + auth = class_double(Aptible::Auth).as_stubbed_const + allow(auth).to receive(:configuration).and_return( + double(root_url: 'https://localhost:4000') + ) options = controller.fridge_cookie_options expect(options[:domain]).to eq :all diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index e33a39c..fe5a56d 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) $LOAD_PATH.unshift(File.dirname(__FILE__)) @@ -19,5 +21,6 @@ end RSpec.configure do |config| + config.before { Time.zone = 'UTC' } config.before { Fridge.configuration.reset } end