From 9d6e983008c9e7eeca3a12ab63346b17e0ac6819 Mon Sep 17 00:00:00 2001 From: Jbur43 Date: Tue, 7 Oct 2025 18:07:04 -0400 Subject: [PATCH 1/6] add token validation for rails controller actions --- .DS_Store | Bin 0 -> 6148 bytes Gemfile.lock | 217 +++++++--- lib/.DS_Store | Bin 0 -> 6148 bytes lib/traitify.rb | 2 + lib/traitify/.DS_Store | Bin 0 -> 6148 bytes .../concerns/token_authenticatable.rb | 99 +++++ spec/.DS_Store | Bin 0 -> 6148 bytes spec/spec_helper.rb | 9 + spec/traitify/.DS_Store | Bin 0 -> 6148 bytes .../concerns/token_authenticatable_spec.rb | 402 ++++++++++++++++++ traitify.gemspec | 3 + 11 files changed, 670 insertions(+), 62 deletions(-) create mode 100644 .DS_Store create mode 100644 lib/.DS_Store create mode 100644 lib/traitify/.DS_Store create mode 100644 lib/traitify/concerns/token_authenticatable.rb create mode 100644 spec/.DS_Store create mode 100644 spec/traitify/.DS_Store create mode 100644 spec/traitify/concerns/token_authenticatable_spec.rb diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..472c0334448658248e3b3fd70bb331c149d4692a GIT binary patch literal 6148 zcmeHKy-EW?5S}$xL<15#8{wWn3}~gr8qUTxFQEA`5fUz_vA)(~b-sh|a+7w>LjKbKe~|w?t&>qjrs`NFEX4bmiO_R}VG<=(sf93M{(LwkFvec1Ef zjfzDmUaA(QbWI()pi@-P3G%P+JudR`>v;GW-Ar5^g}nypyq;x#L0uae*HxlE*k}gT zP-2@05^Ipp>~Ow_E@=oBT0utB+x2aJ&i#X@>5PAg z^|LOr2YE}D^QtHV%E13+06m+fTydzgGN24712YEr`w(L=TEWa={^`J!9|3?7)VW~I zzmyyk3tGX= 6.0) activesupport (>= 5.1, < 8.x) faraday (~> 2.5) faraday-net_http (~> 3.0) faraday-retry (~> 2.2) + jwt (~> 2.0) + railties (>= 7.0) GEM remote: https://rubygems.org/ specs: - activesupport (7.1.4) + actionpack (7.1.5.2) + actionview (= 7.1.5.2) + activesupport (= 7.1.5.2) + nokogiri (>= 1.8.5) + racc + rack (>= 2.2.4) + rack-session (>= 1.0.1) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + actionview (7.1.5.2) + activesupport (= 7.1.5.2) + builder (~> 3.1) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + activesupport (7.1.5.2) base64 + benchmark (>= 0.3) bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) connection_pool (>= 2.2.5) drb i18n (>= 1.6, < 2) + logger (>= 1.4.2) minitest (>= 5.1) mutex_m + securerandom (>= 0.3) tzinfo (~> 2.0) addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) - ast (2.4.2) - base64 (0.2.0) - bigdecimal (3.1.8) + ast (2.4.3) + base64 (0.3.0) + benchmark (0.4.1) + bigdecimal (3.3.0) binding_of_caller (1.0.1) debug_inspector (>= 1.2.0) + builder (3.3.0) + cgi (0.5.0) coderay (1.1.3) - concurrent-ruby (1.3.4) - connection_pool (2.4.1) + concurrent-ruby (1.3.5) + connection_pool (2.5.4) crack (1.0.0) bigdecimal rexml + crass (1.0.6) + date (3.4.1) debug_inspector (1.2.0) - diff-lcs (1.5.1) + diff-lcs (1.6.2) docile (1.4.1) - drb (2.2.1) + drb (2.2.3) + erb (4.0.4) + cgi (>= 0.3.3) + erubi (1.13.1) faraday (2.8.1) base64 faraday-net_http (>= 2.0, < 3.1) ruby2_keywords (>= 0.0.4) faraday-net_http (3.0.2) - faraday-retry (2.2.1) + faraday-retry (2.3.2) faraday (~> 2.0) - hashdiff (1.1.1) - i18n (1.14.6) + hashdiff (1.2.1) + i18n (1.14.7) concurrent-ruby (~> 1.0) - json (2.7.2) - language_server-protocol (3.17.0.3) + io-console (0.8.1) + irb (1.15.2) + pp (>= 0.6.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + json (2.15.1) + jwt (2.10.2) + base64 + language_server-protocol (3.17.0.5) + lint_roller (1.1.0) + logger (1.7.0) + loofah (2.24.1) + crass (~> 1.0.2) + nokogiri (>= 1.12.0) method_source (1.1.0) - minitest (5.25.1) - mutex_m (0.2.0) - parallel (1.26.3) - parser (3.3.5.0) + minitest (5.25.5) + mutex_m (0.3.0) + nokogiri (1.15.7-arm64-darwin) + racc (~> 1.4) + nokogiri (1.15.7-x86_64-linux) + racc (~> 1.4) + parallel (1.27.0) + parser (3.3.9.0) ast (~> 2.4.1) racc - pry (0.14.2) + pp (0.6.3) + prettyprint + prettyprint (0.2.0) + prism (1.5.1) + pry (0.15.2) coderay (~> 1.1) method_source (~> 1.0) + psych (5.2.6) + date + stringio public_suffix (5.1.1) racc (1.8.1) - rack (3.1.16) + rack (3.2.2) + rack-session (2.1.1) + base64 (>= 0.1.0) + rack (>= 3.0.0) + rack-test (2.2.0) + rack (>= 1.3) + rackup (2.2.1) + rack (>= 3) + rails-dom-testing (2.3.0) + activesupport (>= 5.0.0) + minitest + nokogiri (>= 1.6) + rails-html-sanitizer (1.6.2) + loofah (~> 2.21) + nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) + railties (7.1.5.2) + actionpack (= 7.1.5.2) + activesupport (= 7.1.5.2) + irb + rackup (>= 1.0.0) + rake (>= 12.2) + thor (~> 1.0, >= 1.2.2) + zeitwerk (~> 2.6) rainbow (3.1.1) - rake (13.2.1) - regexp_parser (2.9.2) - rexml (3.3.9) - rspec (3.13.0) + rake (13.3.0) + rdoc (6.15.0) + erb + psych (>= 4.0.0) + tsort + regexp_parser (2.11.3) + reline (0.6.2) + io-console (~> 0.5) + rexml (3.4.4) + rspec (3.13.1) rspec-core (~> 3.13.0) rspec-expectations (~> 3.13.0) rspec-mocks (~> 3.13.0) - rspec-core (3.13.1) + rspec-core (3.13.5) rspec-support (~> 3.13.0) - rspec-expectations (3.13.3) + rspec-expectations (3.13.5) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-mocks (3.13.2) + rspec-mocks (3.13.5) diff-lcs (>= 1.2.0, < 2.0) rspec-support (~> 3.13.0) - rspec-support (3.13.1) - rubocop (1.66.1) + rspec-support (3.13.6) + rubocop (1.81.1) json (~> 2.3) - language_server-protocol (>= 3.17.0) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) parallel (~> 1.10) parser (>= 3.3.0.2) rainbow (>= 2.2.2, < 4.0) - regexp_parser (>= 2.4, < 3.0) - rubocop-ast (>= 1.32.2, < 2.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.47.1, < 2.0) ruby-progressbar (~> 1.7) - unicode-display_width (>= 2.4.0, < 3.0) - rubocop-airbnb (7.0.0) - rubocop (~> 1.61) - rubocop-performance (~> 1.20) - rubocop-rails (~> 2.24) - rubocop-rspec (~> 2.26) - rubocop-ast (1.32.3) - parser (>= 3.3.1.0) - rubocop-capybara (2.21.0) - rubocop (~> 1.41) - rubocop-factory_bot (2.26.1) - rubocop (~> 1.61) - rubocop-performance (1.22.1) - rubocop (>= 1.48.1, < 2.0) - rubocop-ast (>= 1.31.1, < 2.0) - rubocop-rails (2.26.2) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-airbnb (8.0.0) + lint_roller (~> 1.1) + rubocop (~> 1.72) + rubocop-capybara (~> 2.22) + rubocop-factory_bot (~> 2.27) + rubocop-performance (~> 1.24) + rubocop-rails (~> 2.30) + rubocop-rspec (~> 3.5) + rubocop-ast (1.47.1) + parser (>= 3.3.7.2) + prism (~> 1.4) + rubocop-capybara (2.22.1) + lint_roller (~> 1.1) + rubocop (~> 1.72, >= 1.72.1) + rubocop-factory_bot (2.27.1) + lint_roller (~> 1.1) + rubocop (~> 1.72, >= 1.72.1) + rubocop-performance (1.26.0) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.44.0, < 2.0) + rubocop-rails (2.33.4) activesupport (>= 4.2.0) + lint_roller (~> 1.1) rack (>= 1.1) - rubocop (>= 1.52.0, < 2.0) - rubocop-ast (>= 1.31.1, < 2.0) - rubocop-rspec (2.31.0) - rubocop (~> 1.40) - rubocop-capybara (~> 2.17) - rubocop-factory_bot (~> 2.22) - rubocop-rspec_rails (~> 2.28) - rubocop-rspec_rails (2.29.1) - rubocop (~> 1.61) - rubocop-traitify (1.3.0.pre.alpha.0) - rubocop-airbnb (~> 7.0.0) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.44.0, < 2.0) + rubocop-rspec (3.7.0) + lint_roller (~> 1.1) + rubocop (~> 1.72, >= 1.72.1) + rubocop-traitify (1.3.0) + rubocop-airbnb (~> 8.0.0) ruby-progressbar (1.13.0) ruby2_keywords (0.0.5) + securerandom (0.3.2) simplecov (0.21.2) docile (~> 1.1) simplecov-html (~> 0.11) simplecov_json_formatter (~> 0.1) - simplecov-html (0.13.1) + simplecov-html (0.13.2) simplecov_json_formatter (0.1.4) + stringio (3.1.7) + thor (1.4.0) + tsort (0.2.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - unicode-display_width (2.6.0) - webmock (3.24.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.1.0) + webmock (3.25.1) addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) + zeitwerk (2.6.18) PLATFORMS arm64-darwin-20 @@ -150,4 +243,4 @@ DEPENDENCIES webmock (~> 3.18) BUNDLED WITH - 2.4.22 + 2.3.16 diff --git a/lib/.DS_Store b/lib/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..596a1e2e7cd9d1e37d98f33021be42310463a36d GIT binary patch literal 6148 zcmeHKJ5B>J5S@V(E77Eb(pT6Um}qH|axVZO;v*&62!(>q7s+jqI296aJ|I@Opa}vr zBiV24`PloUwLL_{v&U6SG$o=2O^`*Yh)h>aNA5fUvgX*)J>AgL`nK&WiGJgh?7g8K zJtQLjcK>?%JbyW_`>np^x4mook2S$&NH7o#1Ovf9 zFmNCS{FZ4>4wOIA1_Qys-(^6~hlD1W9gCqJ9q6S5T30?QY+Yt;BgCA|5nJ{ei?3e-J$7Nqi8`N(-Vd;K5rT zq9?!EU20+)>PbXq%Iud+W_Q1Q*=Djtqtr88V39?XfkOl)SHSD?T53-gY}VzKdGY^()+mKK zF@6b{-O&ly4a?kG2X%Qxe&O`+-j<+d2gc1K*h zJl|n%(RL?gX2v*fX61G$%FK?qHtD2%i_(e$qCi=J6?@v@`oCAd|1WovGf_Yk_*V+3 zW-thPxFuIxD>uirR>P0sY@C-{oOi*2DaFX;QoIE>hPdVfpzkoZhzLwR1dI&QhyuT= Fz$fakdB6Yw literal 0 HcmV?d00001 diff --git a/lib/traitify/concerns/token_authenticatable.rb b/lib/traitify/concerns/token_authenticatable.rb new file mode 100644 index 0000000..d95f4a7 --- /dev/null +++ b/lib/traitify/concerns/token_authenticatable.rb @@ -0,0 +1,99 @@ +require "jwt" +require "json" +require "openssl" + +module Traitify + module Concerns + module TokenAuthenticatable + extend ActiveSupport::Concern + + included do + before_action :authenticate_with_token + end + + private + + def authenticate_with_token + token = extract_token_from_header + + return render_unauthorized("Missing token") if token.nil? + render_unauthorized("Invalid token") unless valid_token?(token) + end + + def extract_token_from_header + auth_header = request.headers["Authorization"] + auth_header&.split(" ")&.last + end + + def render_unauthorized(message) + render json: {error: message}, status: :unauthorized + end + + def valid_token?(token) + algorithm = "RS256" + public_keys = load_public_keys + + public_keys.each do |public_key| + decoded_token = JWT.decode(token, public_key, true, { + algorithm: algorithm, + iss: "Traitify by Paradox", + verify_iss: true, + verify_iat: true, + verify_nbf: true, + verify_jti: true + }) + + payload = decoded_token[0] + validate_claims(payload) + return true + rescue JWT::ExpiredSignature, JWT::DecodeError, JWT::VerificationError => e + Rails.logger.warn("[JWT] #{e.class.name}: #{e.message}") + next + rescue => e + Rails.logger.error("[JWT] Unexpected error: #{e.class} - #{e.message}") + next + end + + false + end + + def validate_claims(payload) + current_time = Time.now.to_i + + iat_value = payload["iat"] || payload[:iat] + if iat_value && iat_value > current_time + raise JWT::InvalidIatError.new("Token issued in the future") + end + + nbf_value = payload["nbf"] || payload[:nbf] + if nbf_value && nbf_value > current_time + raise JWT::DecodeError.new("Token not yet valid") + end + + jti_value = payload["jti"] || payload[:jti] + if jti_value.nil? || jti_value.empty? + raise JWT::DecodeError.new("Missing JWT ID (jti)") + end + end + + def load_public_keys + keys = [] + + if ENV["JWT_PUBLIC_KEY"] && !ENV["JWT_PUBLIC_KEY"].empty? + keys << OpenSSL::PKey::RSA.new(ENV["JWT_PUBLIC_KEY"]) + end + + if ENV["JWT_PUBLIC_KEY_LEGACY"] && !ENV["JWT_PUBLIC_KEY_LEGACY"].empty? + keys << OpenSSL::PKey::RSA.new(ENV["JWT_PUBLIC_KEY_LEGACY"]) + end + + if keys.empty? + raise "No JWT public keys configured. Set JWT_PUBLIC_KEY or " \ + "JWT_PUBLIC_KEY_LEGACY environment variable" + end + + keys + end + end + end +end diff --git a/spec/.DS_Store b/spec/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..723f2f4a945771abff7017f2ddee057c16bcdec9 GIT binary patch literal 6148 zcmeHKK~BR!475unin#O$xBWtY5URj|Yd-*`0xC611nmJ%`v4AnfG6+@BoIeF!2yXs zFkYL|C;@RosIn{XBwl;hnXKZPh?zdyKp*lY8e{KBg`m(yaq zal|rMbB=R#OjF>LPU%Wd)MrL7H*G$vIlb;azds&6NluNmit;O~pZw~1-AZ0A&VV!E z46F+SsM#XDZABlQ0cXG&STZ2rhX57K45MQDbYMs=0I&;l5?o6!Au+))GmMIufv|=G zHI%KzU=4>om|tcX6*ZjLnh&;}**X+XyJP*3+=(+qADsbbpw7UC-u9*b?|7oUA`zMSHih}`I5HX!N~Q3hq~^f0UuUT00DqZVFtagQ6iFE0m^qORtN zw;BE;1N`o`si2as>4K8?ceeX=y{hwkTGmt8v&G5j>*IKFl={qm^_gF-o|89hfkReM zjT{POatpj`pQYjcI-k{i4i8@5pT^IePqJ54f9CX)UpudWtxSvoW55{LI0jI&S+X5L z8;t>Dz!+FEz~2WCWekdqVElAoh%ErH3v(3AxtHJ=uNV{?L99TWgaRehX^Y_`9CojM zL9r2(aB|vwIK8ve4#ma0V}Bpr$pt|hjR9kz&A^si_PPEaf8YPNgY3!}Fa|b?0oTnZ z`3R4swKaJ-uC*R|4rO7#MsOK|Ni4 ""}) + end + + it "renders unauthorized error" do + expect(controller).to receive(:render).with( + json: {error: "Missing token"}, + status: :unauthorized + ) + + controller.send(:authenticate_with_token) + end + end + + context "when valid token is present" do + let(:valid_token){ "valid.jwt.token" } + + before do + allow(controller.request).to receive(:headers) + .and_return({"Authorization" => "Bearer #{valid_token}"}) + allow(controller).to receive(:valid_token?).with(valid_token).and_return(true) + end + + it "does not render any error" do + expect(controller).not_to receive(:render) + + controller.send(:authenticate_with_token) + end + end + + context "when invalid token is present" do + let(:invalid_token){ "invalid.jwt.token" } + + before do + allow(controller.request).to receive(:headers) + .and_return({"Authorization" => "Bearer #{invalid_token}"}) + allow(controller).to receive(:valid_token?).with(invalid_token).and_return(false) + end + + it "renders unauthorized error" do + expect(controller).to receive(:render).with( + json: {error: "Invalid token"}, + status: :unauthorized + ) + + controller.send(:authenticate_with_token) + end + end + end + + describe "#valid_token?" do + let(:private_key){ OpenSSL::PKey::RSA.new(2048) } + let(:public_key){ private_key.public_key } + let(:valid_payload) do + { + iss: "Traitify by Paradox", + iat: Time.now.to_i, + nbf: Time.now.to_i, + jti: "unique-token-id" + } + end + + before do + ENV["JWT_PUBLIC_KEY"] = public_key.to_pem + end + + context "with valid token" do + let(:valid_token) do + JWT.encode(valid_payload, private_key, "RS256") + end + + it "returns true" do + expect(controller.send(:valid_token?, valid_token)).to be true + end + end + + context "with invalid signature" do + let(:invalid_token) do + other_private_key = OpenSSL::PKey::RSA.new(2048) + JWT.encode(valid_payload, other_private_key, "RS256") + end + + it "returns false" do + expect(controller.send(:valid_token?, invalid_token)).to be false + end + end + + context "with malformed token" do + let(:malformed_token){ "not.a.valid.jwt" } + + it "returns false" do + expect(controller.send(:valid_token?, malformed_token)).to be false + end + end + + context "with expired token" do + let(:expired_payload) do + valid_payload.merge(iat: 1.hour.ago.to_i, exp: 1.hour.ago.to_i) + end + let(:expired_token) do + JWT.encode(expired_payload, private_key, "RS256") + end + + it "returns false" do + expect(controller.send(:valid_token?, expired_token)).to be false + end + end + + context "with wrong issuer" do + let(:wrong_issuer_payload) do + valid_payload.merge(iss: "Wrong Issuer") + end + let(:wrong_issuer_token) do + JWT.encode(wrong_issuer_payload, private_key, "RS256") + end + + it "returns false" do + expect(controller.send(:valid_token?, wrong_issuer_token)).to be false + end + end + + context "with multiple public keys" do + let(:legacy_private_key){ OpenSSL::PKey::RSA.new(2048) } + let(:legacy_public_key){ legacy_private_key.public_key } + + before do + ENV["JWT_PUBLIC_KEY_LEGACY"] = legacy_public_key.to_pem + end + + context "when token is signed with current key" do + let(:current_token) do + JWT.encode(valid_payload, private_key, "RS256") + end + + it "returns true" do + expect(controller.send(:valid_token?, current_token)).to be true + end + end + + context "when token is signed with legacy key" do + let(:legacy_token) do + JWT.encode(valid_payload, legacy_private_key, "RS256") + end + + it "returns true" do + expect(controller.send(:valid_token?, legacy_token)).to be true + end + end + end + + context "when no public keys are configured" do + before do + ENV.delete("JWT_PUBLIC_KEY") + ENV.delete("JWT_PUBLIC_KEY_LEGACY") + end + + it "raises an error" do + expect{ + controller.send(:valid_token?, "any.token") + }.to raise_error(/No JWT public keys configured/) + end + end + end + + describe "#validate_claims" do + let(:current_time){ Time.now.to_i } + + context "with valid claims" do + let(:valid_payload) do + { + iat: current_time - 100, + nbf: current_time - 50, + jti: "unique-token-id" + } + end + + it "does not raise an error" do + expect{ controller.send(:validate_claims, valid_payload) }.not_to raise_error + end + end + + context "with future iat" do + let(:future_iat_payload) do + { + iat: current_time + 100, + nbf: current_time - 50, + jti: "unique-token-id" + } + end + + it "raises InvalidIatError" do + expect{ + controller.send(:validate_claims, + future_iat_payload) + }.to raise_error(JWT::InvalidIatError, "Token issued in the future") + end + end + + context "with future nbf" do + let(:future_nbf_payload) do + { + iat: current_time - 100, + nbf: current_time + 50, + jti: "unique-token-id" + } + end + + it "raises DecodeError" do + expect{ + controller.send(:validate_claims, + future_nbf_payload) + }.to raise_error(JWT::DecodeError, "Token not yet valid") + end + end + + context "with missing jti" do + let(:missing_jti_payload) do + { + iat: current_time - 100, + nbf: current_time - 50 + } + end + + it "raises DecodeError" do + expect{ + controller.send(:validate_claims, + missing_jti_payload) + }.to raise_error(JWT::DecodeError, "Missing JWT ID (jti)") + end + end + + context "with blank jti" do + let(:blank_jti_payload) do + { + iat: current_time - 100, + nbf: current_time - 50, + jti: "" + } + end + + it "raises DecodeError" do + expect{ + controller.send(:validate_claims, + blank_jti_payload) + }.to raise_error(JWT::DecodeError, "Missing JWT ID (jti)") + end + end + + context "with nil jti" do + let(:nil_jti_payload) do + { + iat: current_time - 100, + nbf: current_time - 50, + jti: nil + } + end + + it "raises DecodeError" do + expect{ + controller.send(:validate_claims, + nil_jti_payload) + }.to raise_error(JWT::DecodeError, "Missing JWT ID (jti)") + end + end + + context "with missing iat" do + let(:missing_iat_payload) do + { + nbf: current_time - 50, + jti: "unique-token-id" + } + end + + it "does not raise an error" do + expect{ controller.send(:validate_claims, missing_iat_payload) }.not_to raise_error + end + end + + context "with missing nbf" do + let(:missing_nbf_payload) do + { + iat: current_time - 100, + jti: "unique-token-id" + } + end + + it "does not raise an error" do + expect{ controller.send(:validate_claims, missing_nbf_payload) }.not_to raise_error + end + end + end + + describe "#load_public_keys" do + let(:private_key){ OpenSSL::PKey::RSA.new(2048) } + let(:public_key){ private_key.public_key } + + context "with JWT_PUBLIC_KEY set" do + before do + ENV["JWT_PUBLIC_KEY"] = public_key.to_pem + end + + it "loads the public key" do + keys = controller.send(:load_public_keys) + expect(keys.length).to eq(1) + expect(keys.first).to be_a(OpenSSL::PKey::RSA) + end + end + + context "with JWT_PUBLIC_KEY_LEGACY set" do + let(:legacy_private_key){ OpenSSL::PKey::RSA.new(2048) } + let(:legacy_public_key){ legacy_private_key.public_key } + + before do + ENV["JWT_PUBLIC_KEY_LEGACY"] = legacy_public_key.to_pem + end + + it "loads the legacy public key" do + keys = controller.send(:load_public_keys) + expect(keys.length).to eq(1) + expect(keys.first).to be_a(OpenSSL::PKey::RSA) + end + end + + context "with multiple key sources" do + let(:key1_private){ OpenSSL::PKey::RSA.new(2048) } + let(:key1_public){ key1_private.public_key } + let(:key2_private){ OpenSSL::PKey::RSA.new(2048) } + let(:key2_public){ key2_private.public_key } + + before do + ENV["JWT_PUBLIC_KEY"] = key1_public.to_pem + ENV["JWT_PUBLIC_KEY_LEGACY"] = key2_public.to_pem + end + + it "loads all public keys from all sources" do + keys = controller.send(:load_public_keys) + expect(keys.length).to eq(2) + expect(keys).to all(be_a(OpenSSL::PKey::RSA)) + end + end + + context "with no keys configured" do + before do + ENV.delete("JWT_PUBLIC_KEY") + ENV.delete("JWT_PUBLIC_KEY_LEGACY") + end + + it "raises an error" do + expect{ + controller.send(:load_public_keys) + }.to raise_error(/No JWT public keys configured/) + end + end + end +end diff --git a/traitify.gemspec b/traitify.gemspec index b96af29..13006c5 100644 --- a/traitify.gemspec +++ b/traitify.gemspec @@ -22,6 +22,9 @@ Gem::Specification.new do |spec| spec.add_runtime_dependency "faraday", "~> 2.5" spec.add_runtime_dependency "faraday-net_http", "~> 3.0" spec.add_runtime_dependency "faraday-retry", "~> 2.2" + spec.add_runtime_dependency "jwt", "~> 2.0" + spec.add_runtime_dependency "railties", ">= 7.0" + spec.add_runtime_dependency "actionpack", ">= 6.0" spec.add_development_dependency "binding_of_caller", "~> 1.0" spec.add_development_dependency "bundler", "~> 2.2" From 9bf58347a00be0a3971a39b1670b39199338fd0b Mon Sep 17 00:00:00 2001 From: Jbur43 Date: Tue, 7 Oct 2025 18:08:50 -0400 Subject: [PATCH 2/6] remove local ds_store --- spec/traitify/.DS_Store | Bin 6148 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 spec/traitify/.DS_Store diff --git a/spec/traitify/.DS_Store b/spec/traitify/.DS_Store deleted file mode 100644 index dc0212f0e26def868ded8cf234c2240e11f7311e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKy-veG4EB`@rGlX&<1z36l`63wRam-q0|}v~2#^v|@v|`H7oUA`zMSHih}`I5HX!N~Q3hq~^f0UuUT00DqZVFtagQ6iFE0m^qORtN zw;BE;1N`o`si2as>4K8?ceeX=y{hwkTGmt8v&G5j>*IKFl={qm^_gF-o|89hfkReM zjT{POatpj`pQYjcI-k{i4i8@5pT^IePqJ54f9CX)UpudWtxSvoW55{LI0jI&S+X5L z8;t>Dz!+FEz~2WCWekdqVElAoh%ErH3v(3AxtHJ=uNV{?L99TWgaRehX^Y_`9CojM zL9r2(aB|vwIK8ve4#ma0V}Bpr$pt|hjR9kz&A^si_PPEaf8YPNgY3!}Fa|b?0oTnZ z`3R4swKaJ-uC*R|4rO7#MsOK|Ni4 Date: Tue, 4 Nov 2025 17:20:43 -0500 Subject: [PATCH 3/6] port to traitify.rb. only validate token --- .DS_Store | Bin 6148 -> 0 bytes lib/traitify.rb | 51 +++++++++ .../concerns/token_authenticatable.rb | 99 ------------------ lib/traitify/configuration.rb | 11 +- 4 files changed, 57 insertions(+), 104 deletions(-) delete mode 100644 .DS_Store delete mode 100644 lib/traitify/concerns/token_authenticatable.rb diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 472c0334448658248e3b3fd70bb331c149d4692a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKy-EW?5S}$xL<15#8{wWn3}~gr8qUTxFQEA`5fUz_vA)(~b-sh|a+7w>LjKbKe~|w?t&>qjrs`NFEX4bmiO_R}VG<=(sf93M{(LwkFvec1Ef zjfzDmUaA(QbWI()pi@-P3G%P+JudR`>v;GW-Ar5^g}nypyq;x#L0uae*HxlE*k}gT zP-2@05^Ipp>~Ow_E@=oBT0utB+x2aJ&i#X@>5PAg z^|LOr2YE}D^QtHV%E13+06m+fTydzgGN24712YEr`w(L=TEWa={^`J!9|3?7)VW~I zzmyyk3tGX e + Traitify.logger.warn("[JWT] #{e.class.name}: #{e.message}") + next + rescue => e + Traitify.logger.error("[JWT] Unexpected error: #{e.class} - #{e.message}") + next + end + + false + end + + private + + def validate_claims(payload) + current_time = Time.now.to_i + + iat_value = payload["iat"] || payload[:iat] + if iat_value && iat_value > current_time + raise JWT::InvalidIatError.new("Token issued in the future") + end + + nbf_value = payload["nbf"] || payload[:nbf] + if nbf_value && nbf_value > current_time + raise JWT::DecodeError.new("Token not yet valid") + end + + jti_value = payload["jti"] || payload[:jti] + if jti_value.nil? || jti_value.empty? + raise JWT::DecodeError.new("Missing JWT ID (jti)") + end + end end end diff --git a/lib/traitify/concerns/token_authenticatable.rb b/lib/traitify/concerns/token_authenticatable.rb deleted file mode 100644 index d95f4a7..0000000 --- a/lib/traitify/concerns/token_authenticatable.rb +++ /dev/null @@ -1,99 +0,0 @@ -require "jwt" -require "json" -require "openssl" - -module Traitify - module Concerns - module TokenAuthenticatable - extend ActiveSupport::Concern - - included do - before_action :authenticate_with_token - end - - private - - def authenticate_with_token - token = extract_token_from_header - - return render_unauthorized("Missing token") if token.nil? - render_unauthorized("Invalid token") unless valid_token?(token) - end - - def extract_token_from_header - auth_header = request.headers["Authorization"] - auth_header&.split(" ")&.last - end - - def render_unauthorized(message) - render json: {error: message}, status: :unauthorized - end - - def valid_token?(token) - algorithm = "RS256" - public_keys = load_public_keys - - public_keys.each do |public_key| - decoded_token = JWT.decode(token, public_key, true, { - algorithm: algorithm, - iss: "Traitify by Paradox", - verify_iss: true, - verify_iat: true, - verify_nbf: true, - verify_jti: true - }) - - payload = decoded_token[0] - validate_claims(payload) - return true - rescue JWT::ExpiredSignature, JWT::DecodeError, JWT::VerificationError => e - Rails.logger.warn("[JWT] #{e.class.name}: #{e.message}") - next - rescue => e - Rails.logger.error("[JWT] Unexpected error: #{e.class} - #{e.message}") - next - end - - false - end - - def validate_claims(payload) - current_time = Time.now.to_i - - iat_value = payload["iat"] || payload[:iat] - if iat_value && iat_value > current_time - raise JWT::InvalidIatError.new("Token issued in the future") - end - - nbf_value = payload["nbf"] || payload[:nbf] - if nbf_value && nbf_value > current_time - raise JWT::DecodeError.new("Token not yet valid") - end - - jti_value = payload["jti"] || payload[:jti] - if jti_value.nil? || jti_value.empty? - raise JWT::DecodeError.new("Missing JWT ID (jti)") - end - end - - def load_public_keys - keys = [] - - if ENV["JWT_PUBLIC_KEY"] && !ENV["JWT_PUBLIC_KEY"].empty? - keys << OpenSSL::PKey::RSA.new(ENV["JWT_PUBLIC_KEY"]) - end - - if ENV["JWT_PUBLIC_KEY_LEGACY"] && !ENV["JWT_PUBLIC_KEY_LEGACY"].empty? - keys << OpenSSL::PKey::RSA.new(ENV["JWT_PUBLIC_KEY_LEGACY"]) - end - - if keys.empty? - raise "No JWT public keys configured. Set JWT_PUBLIC_KEY or " \ - "JWT_PUBLIC_KEY_LEGACY environment variable" - end - - keys - end - end - end -end diff --git a/lib/traitify/configuration.rb b/lib/traitify/configuration.rb index b3ea70c..529a3a8 100644 --- a/lib/traitify/configuration.rb +++ b/lib/traitify/configuration.rb @@ -1,15 +1,16 @@ module Traitify module Configuration VALID_OPTIONS_KEYS = [ - :host, - :public_key, - :secret_key, - :version, :auto_retry, :deck_id, + :host, :image_pack, + :jwt_public_keys, :locale_key, - :retry_options + :public_key, + :retry_options, + :secret_key, + :version ].freeze attr_accessor(*VALID_OPTIONS_KEYS) From 195688a97adbc85a772efa54791286aaaf39d9d7 Mon Sep 17 00:00:00 2001 From: Jbur43 Date: Tue, 4 Nov 2025 17:22:12 -0500 Subject: [PATCH 4/6] deps --- Gemfile.lock | 75 ------------------------------------------------ traitify.gemspec | 2 -- 2 files changed, 77 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 05ac6bd..97e5514 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,33 +2,15 @@ PATH remote: . specs: traitify (2.1.0) - actionpack (>= 6.0) activesupport (>= 5.1, < 8.x) faraday (~> 2.5) faraday-net_http (~> 3.0) faraday-retry (~> 2.2) jwt (~> 2.0) - railties (>= 7.0) GEM remote: https://rubygems.org/ specs: - actionpack (7.1.5.2) - actionview (= 7.1.5.2) - activesupport (= 7.1.5.2) - nokogiri (>= 1.8.5) - racc - rack (>= 2.2.4) - rack-session (>= 1.0.1) - rack-test (>= 0.6.3) - rails-dom-testing (~> 2.2) - rails-html-sanitizer (~> 1.6) - actionview (7.1.5.2) - activesupport (= 7.1.5.2) - builder (~> 3.1) - erubi (~> 1.11) - rails-dom-testing (~> 2.2) - rails-html-sanitizer (~> 1.6) activesupport (7.1.5.2) base64 benchmark (>= 0.3) @@ -50,23 +32,16 @@ GEM bigdecimal (3.3.0) binding_of_caller (1.0.1) debug_inspector (>= 1.2.0) - builder (3.3.0) - cgi (0.5.0) coderay (1.1.3) concurrent-ruby (1.3.5) connection_pool (2.5.4) crack (1.0.0) bigdecimal rexml - crass (1.0.6) - date (3.4.1) debug_inspector (1.2.0) diff-lcs (1.6.2) docile (1.4.1) drb (2.2.3) - erb (4.0.4) - cgi (>= 0.3.3) - erubi (1.13.1) faraday (2.8.1) base64 faraday-net_http (>= 2.0, < 3.1) @@ -77,75 +52,29 @@ GEM hashdiff (1.2.1) i18n (1.14.7) concurrent-ruby (~> 1.0) - io-console (0.8.1) - irb (1.15.2) - pp (>= 0.6.0) - rdoc (>= 4.0.0) - reline (>= 0.4.2) json (2.15.1) jwt (2.10.2) base64 language_server-protocol (3.17.0.5) lint_roller (1.1.0) logger (1.7.0) - loofah (2.24.1) - crass (~> 1.0.2) - nokogiri (>= 1.12.0) method_source (1.1.0) minitest (5.25.5) mutex_m (0.3.0) - nokogiri (1.15.7-arm64-darwin) - racc (~> 1.4) - nokogiri (1.15.7-x86_64-linux) - racc (~> 1.4) parallel (1.27.0) parser (3.3.9.0) ast (~> 2.4.1) racc - pp (0.6.3) - prettyprint - prettyprint (0.2.0) prism (1.5.1) pry (0.15.2) coderay (~> 1.1) method_source (~> 1.0) - psych (5.2.6) - date - stringio public_suffix (5.1.1) racc (1.8.1) rack (3.2.2) - rack-session (2.1.1) - base64 (>= 0.1.0) - rack (>= 3.0.0) - rack-test (2.2.0) - rack (>= 1.3) - rackup (2.2.1) - rack (>= 3) - rails-dom-testing (2.3.0) - activesupport (>= 5.0.0) - minitest - nokogiri (>= 1.6) - rails-html-sanitizer (1.6.2) - loofah (~> 2.21) - nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) - railties (7.1.5.2) - actionpack (= 7.1.5.2) - activesupport (= 7.1.5.2) - irb - rackup (>= 1.0.0) - rake (>= 12.2) - thor (~> 1.0, >= 1.2.2) - zeitwerk (~> 2.6) rainbow (3.1.1) rake (13.3.0) - rdoc (6.15.0) - erb - psych (>= 4.0.0) - tsort regexp_parser (2.11.3) - reline (0.6.2) - io-console (~> 0.5) rexml (3.4.4) rspec (3.13.1) rspec-core (~> 3.13.0) @@ -212,9 +141,6 @@ GEM simplecov_json_formatter (~> 0.1) simplecov-html (0.13.2) simplecov_json_formatter (0.1.4) - stringio (3.1.7) - thor (1.4.0) - tsort (0.2.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) unicode-display_width (3.2.0) @@ -224,7 +150,6 @@ GEM addressable (>= 2.8.0) crack (>= 0.3.2) hashdiff (>= 0.4.0, < 2.0.0) - zeitwerk (2.6.18) PLATFORMS arm64-darwin-20 diff --git a/traitify.gemspec b/traitify.gemspec index 13006c5..9aceb54 100644 --- a/traitify.gemspec +++ b/traitify.gemspec @@ -23,8 +23,6 @@ Gem::Specification.new do |spec| spec.add_runtime_dependency "faraday-net_http", "~> 3.0" spec.add_runtime_dependency "faraday-retry", "~> 2.2" spec.add_runtime_dependency "jwt", "~> 2.0" - spec.add_runtime_dependency "railties", ">= 7.0" - spec.add_runtime_dependency "actionpack", ">= 6.0" spec.add_development_dependency "binding_of_caller", "~> 1.0" spec.add_development_dependency "bundler", "~> 2.2" From 64ba13f19c2ecfd3536c7e8ffaafc5395ef8a56f Mon Sep 17 00:00:00 2001 From: Jbur43 Date: Tue, 4 Nov 2025 17:27:09 -0500 Subject: [PATCH 5/6] tests --- lib/traitify.rb | 5 +- spec/traitify_spec.rb | 242 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 245 insertions(+), 2 deletions(-) create mode 100644 spec/traitify_spec.rb diff --git a/lib/traitify.rb b/lib/traitify.rb index 8a379d8..42c3862 100644 --- a/lib/traitify.rb +++ b/lib/traitify.rb @@ -5,7 +5,6 @@ require "openssl" require "traitify/configuration" require "traitify/client" -require "traitify/concerns/token_authenticatable" require "traitify/data" require "traitify/error" require "traitify/response" @@ -44,7 +43,9 @@ def log(level, message) def valid_jwt_token?(token) algorithm = "RS256" - public_keys = jwt_public_keys.each { |key| OpenSSL::PKey::RSA.new(key) } + return false unless jwt_public_keys && jwt_public_keys.any? + + public_keys = jwt_public_keys.map { |key| OpenSSL::PKey::RSA.new(key) } public_keys.each do |public_key| decoded_token = JWT.decode(token, public_key, true, { diff --git a/spec/traitify_spec.rb b/spec/traitify_spec.rb new file mode 100644 index 0000000..8c90acc --- /dev/null +++ b/spec/traitify_spec.rb @@ -0,0 +1,242 @@ +require "spec_helper" + +describe Traitify do + describe ".valid_jwt_token?" do + let(:private_key){ OpenSSL::PKey::RSA.new(2048) } + let(:public_key){ private_key.public_key } + let(:valid_payload) do + { + iss: "Traitify by Paradox", + iat: Time.now.to_i, + nbf: Time.now.to_i, + jti: "unique-token-id" + } + end + + before do + Traitify.jwt_public_keys = [public_key.to_pem] + end + + after do + Traitify.jwt_public_keys = nil + end + + context "with valid token" do + let(:valid_token) do + JWT.encode(valid_payload, private_key, "RS256") + end + + it "returns true" do + expect(Traitify.valid_jwt_token?(valid_token)).to be true + end + end + + context "with invalid signature" do + let(:invalid_token) do + other_private_key = OpenSSL::PKey::RSA.new(2048) + JWT.encode(valid_payload, other_private_key, "RS256") + end + + it "returns false" do + expect(Traitify.valid_jwt_token?(invalid_token)).to be false + end + end + + context "with malformed token" do + let(:malformed_token){ "not.a.valid.jwt" } + + it "returns false" do + expect(Traitify.valid_jwt_token?(malformed_token)).to be false + end + end + + context "with expired token" do + let(:expired_payload) do + valid_payload.merge(iat: 1.hour.ago.to_i, exp: 1.hour.ago.to_i) + end + let(:expired_token) do + JWT.encode(expired_payload, private_key, "RS256") + end + + it "returns false" do + expect(Traitify.valid_jwt_token?(expired_token)).to be false + end + end + + context "with wrong issuer" do + let(:wrong_issuer_payload) do + valid_payload.merge(iss: "Wrong Issuer") + end + let(:wrong_issuer_token) do + JWT.encode(wrong_issuer_payload, private_key, "RS256") + end + + it "returns false" do + expect(Traitify.valid_jwt_token?(wrong_issuer_token)).to be false + end + end + + context "with multiple public keys" do + let(:legacy_private_key){ OpenSSL::PKey::RSA.new(2048) } + let(:legacy_public_key){ legacy_private_key.public_key } + + before do + Traitify.jwt_public_keys = [public_key.to_pem, legacy_public_key.to_pem] + end + + context "when token is signed with current key" do + let(:current_token) do + JWT.encode(valid_payload, private_key, "RS256") + end + + it "returns true" do + expect(Traitify.valid_jwt_token?(current_token)).to be true + end + end + + context "when token is signed with legacy key" do + let(:legacy_token) do + JWT.encode(valid_payload, legacy_private_key, "RS256") + end + + it "returns true" do + expect(Traitify.valid_jwt_token?(legacy_token)).to be true + end + end + end + + context "with future iat" do + let(:future_iat_payload) do + { + iss: "Traitify by Paradox", + iat: Time.now.to_i + 100, + nbf: Time.now.to_i - 50, + jti: "unique-token-id" + } + end + let(:future_iat_token) do + JWT.encode(future_iat_payload, private_key, "RS256") + end + + it "returns false" do + expect(Traitify.valid_jwt_token?(future_iat_token)).to be false + end + end + + context "with future nbf" do + let(:future_nbf_payload) do + { + iss: "Traitify by Paradox", + iat: Time.now.to_i - 100, + nbf: Time.now.to_i + 50, + jti: "unique-token-id" + } + end + let(:future_nbf_token) do + JWT.encode(future_nbf_payload, private_key, "RS256") + end + + it "returns false" do + expect(Traitify.valid_jwt_token?(future_nbf_token)).to be false + end + end + + context "with missing jti" do + let(:missing_jti_payload) do + { + iss: "Traitify by Paradox", + iat: Time.now.to_i - 100, + nbf: Time.now.to_i - 50 + } + end + let(:missing_jti_token) do + JWT.encode(missing_jti_payload, private_key, "RS256") + end + + it "returns false" do + expect(Traitify.valid_jwt_token?(missing_jti_token)).to be false + end + end + + context "with blank jti" do + let(:blank_jti_payload) do + { + iss: "Traitify by Paradox", + iat: Time.now.to_i - 100, + nbf: Time.now.to_i - 50, + jti: "" + } + end + let(:blank_jti_token) do + JWT.encode(blank_jti_payload, private_key, "RS256") + end + + it "returns false" do + expect(Traitify.valid_jwt_token?(blank_jti_token)).to be false + end + end + + context "with nil jti" do + let(:nil_jti_payload) do + { + iss: "Traitify by Paradox", + iat: Time.now.to_i - 100, + nbf: Time.now.to_i - 50, + jti: nil + } + end + let(:nil_jti_token) do + JWT.encode(nil_jti_payload, private_key, "RS256") + end + + it "returns false" do + expect(Traitify.valid_jwt_token?(nil_jti_token)).to be false + end + end + + context "with missing iat" do + let(:missing_iat_payload) do + { + iss: "Traitify by Paradox", + nbf: Time.now.to_i - 50, + jti: "unique-token-id" + } + end + let(:missing_iat_token) do + JWT.encode(missing_iat_payload, private_key, "RS256") + end + + it "returns true" do + expect(Traitify.valid_jwt_token?(missing_iat_token)).to be true + end + end + + context "with missing nbf" do + let(:missing_nbf_payload) do + { + iss: "Traitify by Paradox", + iat: Time.now.to_i - 100, + jti: "unique-token-id" + } + end + let(:missing_nbf_token) do + JWT.encode(missing_nbf_payload, private_key, "RS256") + end + + it "returns true" do + expect(Traitify.valid_jwt_token?(missing_nbf_token)).to be true + end + end + + context "when no public keys are configured" do + before do + Traitify.jwt_public_keys = nil + end + + it "returns false" do + expect(Traitify.valid_jwt_token?("any.token")).to be false + end + end + end +end + From bed6f179b11a3444e534ae8859774ea603057fad Mon Sep 17 00:00:00 2001 From: Jbur43 Date: Tue, 4 Nov 2025 17:30:00 -0500 Subject: [PATCH 6/6] bundle update --- Gemfile.lock | 1 - traitify.gemspec | 1 - 2 files changed, 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 97e5514..09b98e2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,7 +2,6 @@ PATH remote: . specs: traitify (2.1.0) - activesupport (>= 5.1, < 8.x) faraday (~> 2.5) faraday-net_http (~> 3.0) faraday-retry (~> 2.2) diff --git a/traitify.gemspec b/traitify.gemspec index 9aceb54..69f9aa0 100644 --- a/traitify.gemspec +++ b/traitify.gemspec @@ -18,7 +18,6 @@ Gem::Specification.new do |spec| spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) spec.require_paths = ["lib"] - spec.add_runtime_dependency "activesupport", ">= 5.1", "< 8.x" spec.add_runtime_dependency "faraday", "~> 2.5" spec.add_runtime_dependency "faraday-net_http", "~> 3.0" spec.add_runtime_dependency "faraday-retry", "~> 2.2"