From b7fa5447e565d8460aed29023866fde2ed1870f2 Mon Sep 17 00:00:00 2001 From: David Zuckerman Date: Wed, 1 Apr 2026 11:55:00 -0700 Subject: [PATCH 1/7] redirecting if jwt is not see for /fees path --- app/controllers/fees_controller.rb | 9 +++++++-- spec/request/fees_request_spec.rb | 4 ++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/app/controllers/fees_controller.rb b/app/controllers/fees_controller.rb index d6951fcb..622d3106 100644 --- a/app/controllers/fees_controller.rb +++ b/app/controllers/fees_controller.rb @@ -1,6 +1,9 @@ require 'jwt' class FeesController < ApplicationController + + rescue_from ActionController::ParameterMissing, with: :missing_params + # This will be needed for transaction_complete since Paypal will hit that protect_from_forgery with: :null_session @@ -13,6 +16,10 @@ def index redirect_to(action: :transaction_error) end + def missing_params(_error) + redirect_to 'https://lib.berkeley.edu/find/borrow-renew?section=pay-fees', allow_other_host: true + end + def efee @jwt = params.require(:jwt) secret = EfeesInvoice.secret @@ -73,8 +80,6 @@ def transaction_complete render json: { status: 'silent post received' } end - private - def authorize! return if Rails.env.development? diff --git a/spec/request/fees_request_spec.rb b/spec/request/fees_request_spec.rb index 89043ccd..67dab6ec 100644 --- a/spec/request/fees_request_spec.rb +++ b/spec/request/fees_request_spec.rb @@ -12,9 +12,9 @@ def base_url_for(user_id = nil) allow(Rails.application.config).to receive(:alma_api_key).and_return(alma_api_key) end - it 'shows a Bad Request error if request has no jwt' do + it 'redirects to the fallback URL if there is no jwt' do get fees_path - expect(response).to have_http_status(:bad_request) + expect(response).to redirect_to('https://lib.berkeley.edu/find/borrow-renew?section=pay-fees') end it 'redirects to error page if request has a non-existant alma id' do From 2b70111224353f90943fe93a1f6b49e1ff739554 Mon Sep 17 00:00:00 2001 From: David Zuckerman Date: Wed, 1 Apr 2026 12:10:24 -0700 Subject: [PATCH 2/7] moved missing_params under private --- app/controllers/fees_controller.rb | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/app/controllers/fees_controller.rb b/app/controllers/fees_controller.rb index 622d3106..240cd907 100644 --- a/app/controllers/fees_controller.rb +++ b/app/controllers/fees_controller.rb @@ -16,10 +16,6 @@ def index redirect_to(action: :transaction_error) end - def missing_params(_error) - redirect_to 'https://lib.berkeley.edu/find/borrow-renew?section=pay-fees', allow_other_host: true - end - def efee @jwt = params.require(:jwt) secret = EfeesInvoice.secret @@ -80,6 +76,12 @@ def transaction_complete render json: { status: 'silent post received' } end + private + + def missing_params(_error) + redirect_to 'https://lib.berkeley.edu/find/borrow-renew?section=pay-fees', allow_other_host: true + end + def authorize! return if Rails.env.development? From 8d7a504ed254f51b81bf2ad90a2dd6e6e3fa9f92 Mon Sep 17 00:00:00 2001 From: David Zuckerman Date: Wed, 1 Apr 2026 12:20:11 -0700 Subject: [PATCH 3/7] removed empty space --- app/controllers/fees_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/fees_controller.rb b/app/controllers/fees_controller.rb index 240cd907..efbf3ea9 100644 --- a/app/controllers/fees_controller.rb +++ b/app/controllers/fees_controller.rb @@ -76,7 +76,7 @@ def transaction_complete render json: { status: 'silent post received' } end - private + private def missing_params(_error) redirect_to 'https://lib.berkeley.edu/find/borrow-renew?section=pay-fees', allow_other_host: true From ebc5e9f0c07c663f74af0d1cff8f1379fff72830 Mon Sep 17 00:00:00 2001 From: David Zuckerman Date: Wed, 1 Apr 2026 12:56:48 -0700 Subject: [PATCH 4/7] only redirecting if jwt is missing for the index action for fees --- app/controllers/fees_controller.rb | 20 ++++++++------------ spec/request/fees_request_spec.rb | 2 +- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/app/controllers/fees_controller.rb b/app/controllers/fees_controller.rb index efbf3ea9..943c98cd 100644 --- a/app/controllers/fees_controller.rb +++ b/app/controllers/fees_controller.rb @@ -2,18 +2,18 @@ class FeesController < ApplicationController - rescue_from ActionController::ParameterMissing, with: :missing_params - # This will be needed for transaction_complete since Paypal will hit that protect_from_forgery with: :null_session def index - @jwt = params.require(:jwt) - decoded_token = JWT.decode @jwt, nil, false - @alma_id = decoded_token.first['userName'] - @fees = FeesPayment.new(alma_id: @alma_id) - rescue JWT::DecodeError - redirect_to(action: :transaction_error) + @jwt = params.require(:jwt) + decoded_token = JWT.decode @jwt, nil, false + @alma_id = decoded_token.first['userName'] + @fees = FeesPayment.new(alma_id: @alma_id) + rescue ActionController::ParameterMissing + redirect_to 'https://www.lib.berkeley.edu/find/borrow-renew?section=pay-fees', allow_other_host: true + rescue JWT::DecodeError + redirect_to(action: :transaction_error) end def efee @@ -78,10 +78,6 @@ def transaction_complete private - def missing_params(_error) - redirect_to 'https://lib.berkeley.edu/find/borrow-renew?section=pay-fees', allow_other_host: true - end - def authorize! return if Rails.env.development? diff --git a/spec/request/fees_request_spec.rb b/spec/request/fees_request_spec.rb index 67dab6ec..b54f9339 100644 --- a/spec/request/fees_request_spec.rb +++ b/spec/request/fees_request_spec.rb @@ -14,7 +14,7 @@ def base_url_for(user_id = nil) it 'redirects to the fallback URL if there is no jwt' do get fees_path - expect(response).to redirect_to('https://lib.berkeley.edu/find/borrow-renew?section=pay-fees') + expect(response).to redirect_to('https://www.lib.berkeley.edu/find/borrow-renew?section=pay-fees') end it 'redirects to error page if request has a non-existant alma id' do From c8b368013ebaa22f480b8ca8f8fd3e9aea1f9187 Mon Sep 17 00:00:00 2001 From: David Zuckerman Date: Wed, 1 Apr 2026 13:08:53 -0700 Subject: [PATCH 5/7] fixed rubocop indentation error --- app/controllers/fees_controller.rb | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/app/controllers/fees_controller.rb b/app/controllers/fees_controller.rb index 943c98cd..6309ffa9 100644 --- a/app/controllers/fees_controller.rb +++ b/app/controllers/fees_controller.rb @@ -6,14 +6,14 @@ class FeesController < ApplicationController protect_from_forgery with: :null_session def index - @jwt = params.require(:jwt) - decoded_token = JWT.decode @jwt, nil, false - @alma_id = decoded_token.first['userName'] - @fees = FeesPayment.new(alma_id: @alma_id) - rescue ActionController::ParameterMissing - redirect_to 'https://www.lib.berkeley.edu/find/borrow-renew?section=pay-fees', allow_other_host: true - rescue JWT::DecodeError - redirect_to(action: :transaction_error) + @jwt = params.require(:jwt) + decoded_token = JWT.decode @jwt, nil, false + @alma_id = decoded_token.first['userName'] + @fees = FeesPayment.new(alma_id: @alma_id) + rescue ActionController::ParameterMissing + redirect_to 'https://www.lib.berkeley.edu/find/borrow-renew?section=pay-fees', allow_other_host: true + rescue JWT::DecodeError + redirect_to(action: :transaction_error) end def efee From 0422ec621638e497ddb34d55bc54657a1c5340d6 Mon Sep 17 00:00:00 2001 From: David Zuckerman Date: Fri, 3 Apr 2026 08:50:23 -0700 Subject: [PATCH 6/7] validate Alma JWT signature --- Gemfile | 2 +- Gemfile.lock | 5 +- .../concerns/alma_jwt_validator.rb | 49 +++++++++++++++++++ app/controllers/fees_controller.rb | 16 ++++-- spec/data/fees/alma-fees-jwt.txt | 2 +- spec/request/fees_request_spec.rb | 16 ++++-- 6 files changed, 78 insertions(+), 12 deletions(-) create mode 100644 app/controllers/concerns/alma_jwt_validator.rb diff --git a/Gemfile b/Gemfile index 8e1618e3..f52bd5bc 100644 --- a/Gemfile +++ b/Gemfile @@ -19,7 +19,7 @@ gem 'ipaddress' gem 'jaro_winkler', '~> 1.5.5' gem 'jquery-rails' gem 'jquery-ui-rails' -gem 'jwt', '~> 1.5', '>= 1.5.4' +gem 'jwt', '~> 2.5' gem 'lograge', '>=0.11.2' gem 'mutex_m' # Deprecation warning. gem 'netaddr', '~> 1.5', '>= 1.5.1' diff --git a/Gemfile.lock b/Gemfile.lock index d2086ede..4f0099b4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -213,7 +213,8 @@ GEM json (2.18.1) jsonpath (0.5.8) multi_json - jwt (1.5.6) + jwt (2.10.2) + base64 language_server-protocol (3.17.0.5) lint_roller (1.1.0) listen (3.10.0) @@ -542,7 +543,7 @@ DEPENDENCIES jaro_winkler (~> 1.5.5) jquery-rails jquery-ui-rails - jwt (~> 1.5, >= 1.5.4) + jwt (~> 2.5) listen (~> 3.2) lograge (>= 0.11.2) mutex_m diff --git a/app/controllers/concerns/alma_jwt_validator.rb b/app/controllers/concerns/alma_jwt_validator.rb new file mode 100644 index 00000000..efee3ba5 --- /dev/null +++ b/app/controllers/concerns/alma_jwt_validator.rb @@ -0,0 +1,49 @@ +require 'jwt' +require 'net/http' +require 'json' + +module AlmaJwtValidator + JWKS_URL = 'https://api-na.hosted.exlibrisgroup.com/auth/01UCS_BER/jwks.json'.freeze + EXPECTED_ISS = 'https://api-na.hosted.exlibrisgroup.com/auth/01UCS_BER'.freeze + + module_function + + def jwk_set + Rails.cache.fetch('jwks_set', expires_in: 4.hour) do + jwks_raw = Net::HTTP.get(URI(JWKS_URL)) + jwks_keys = JSON.parse(jwks_raw)['keys'] + JWT::JWK::Set.new(jwks_keys) + end + end + + # rubocop:disable Metrics/MethodLength + def decode_and_verify_jwt(token) + # Decode header to get the 'kid' + header = JWT.decode(token, nil, false).last + kid = header['kid'] + + # Find the key from the JWK set + jwk = jwk_set.keys.find { |key| key.kid == kid } + raise JWT::VerificationError, 'Key not found in JWKS' unless jwk + + public_key = jwk.public_key + + options = { + algorithm: 'RS256', + verify_expiration: true, + verify_aud: false, + verify_iss: true, + iss: EXPECTED_ISS + } + + # Returns [payload, header] array if valid + JWT.decode(token, public_key, true, options) + rescue JWT::ExpiredSignature + raise JWT::VerificationError, 'Token has expired' + rescue JWT::InvalidIssuerError + raise JWT::VerificationError, 'Token issuer mismatch' + rescue JWT::DecodeError => e + raise JWT::VerificationError, "Invalid JWT: #{e.message}" + end + # rubocop:enable Metrics/MethodLength +end diff --git a/app/controllers/fees_controller.rb b/app/controllers/fees_controller.rb index 6309ffa9..103c7d0f 100644 --- a/app/controllers/fees_controller.rb +++ b/app/controllers/fees_controller.rb @@ -5,16 +5,24 @@ class FeesController < ApplicationController # This will be needed for transaction_complete since Paypal will hit that protect_from_forgery with: :null_session + # rubocop:disable Metrics/MethodLength def index @jwt = params.require(:jwt) - decoded_token = JWT.decode @jwt, nil, false - @alma_id = decoded_token.first['userName'] - @fees = FeesPayment.new(alma_id: @alma_id) + payload = AlmaJwtValidator.decode_and_verify_jwt(@jwt) + @alma_id = payload.first['userName'] + begin + @fees = FeesPayment.new(alma_id: @alma_id) + rescue StandardError => e + Rails.logger.warn "FeesPayment failed: #{e.message}" + redirect_to(action: :transaction_error) and return + end rescue ActionController::ParameterMissing redirect_to 'https://www.lib.berkeley.edu/find/borrow-renew?section=pay-fees', allow_other_host: true - rescue JWT::DecodeError + rescue JWT::DecodeError, JWT::VerificationError => e + Rails.logger.warn "JWT verification failed: #{e.message}" redirect_to(action: :transaction_error) end + # rubocop:enable Metrics/MethodLength def efee @jwt = params.require(:jwt) diff --git a/spec/data/fees/alma-fees-jwt.txt b/spec/data/fees/alma-fees-jwt.txt index 2398e4cc..17fb65c5 100644 --- a/spec/data/fees/alma-fees-jwt.txt +++ b/spec/data/fees/alma-fees-jwt.txt @@ -1 +1 @@ -eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJQcmltYSIsImp0aSI6IkMyOTIyMEQ2RTFCQTk4NDMzOEMzQTRDMEFCOTUwOUY5LmFwZDAzLm5hMDcucHJvZC5hbG1hLmRjMDEuaG9zdGVkLmV4bGlicmlzZ3JvdXAuY29tOjE4MDEiLCJ1c2VyTmFtZSI6IjEwMzM1MDI2IiwiZGlzcGxheU5hbWUiOiJTdWxsaXZhbiwgU3RldmVuIiwidXNlciI6IjI2Mzc3MjgwNTAwMDY1MzIiLCJ1c2VyR3JvdXAiOiJMSUJTVEFGRiIsImluc3RpdHV0aW9uIjoiMDFVQ1NfQkVSIiwidXNlcklwIjoiNzMuNzEuMTM4LjE3IiwiYXV0aGVudGljYXRpb25Qcm9maWxlIjoiQ0FTIiwiYXV0aGVudGljYXRpb25TeXN0ZW0iOiJDQVMiLCJsYW5ndWFnZSI6ImVuIiwic2FtbFNlc3Npb25JbmRleCI6IiIsInNhbWxOYW1lSWQiOiIiLCJvbkNhbXB1cyI6ImZhbHNlIiwic2lnbmVkSW4iOiJ0cnVlIiwidmlld0lkIjoiMDFVQ1NfQkVSOlVDQiJ9.Xus3sbFX8IHLPrV5_5YY8gbtBXzC48xLOu3XsMtQaMw \ No newline at end of file +eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyTmFtZSI6IjEwMzM1MDI2In0.23uMX0G7rPdgXarjFtlNUhxJJKGXDnlVNJpS34E0Vfg diff --git a/spec/request/fees_request_spec.rb b/spec/request/fees_request_spec.rb index b54f9339..827f278c 100644 --- a/spec/request/fees_request_spec.rb +++ b/spec/request/fees_request_spec.rb @@ -9,7 +9,13 @@ def base_url_for(user_id = nil) let(:request_headers) { { 'Accept' => 'application/json', 'Authorization' => "apikey #{alma_api_key}" } } before do - allow(Rails.application.config).to receive(:alma_api_key).and_return(alma_api_key) + allow(AlmaJwtValidator).to receive(:decode_and_verify_jwt).and_return( + [{ 'userName' => '10335026' }] + ) + allow(Rails.application.config).to receive_messages( + alma_api_key: alma_api_key, + alma_jwt_secret: 'fake-jwt-secret' + ) end it 'redirects to the fallback URL if there is no jwt' do @@ -18,7 +24,8 @@ def base_url_for(user_id = nil) end it 'redirects to error page if request has a non-existant alma id' do - stub_request(:get, "#{base_url_for}fees") + user_id = '10335026' + stub_request(:get, "#{base_url_for(user_id)}/fees") .with(headers: request_headers) .to_return(status: 404, body: '') @@ -53,9 +60,10 @@ def base_url_for(user_id = nil) end it 'payments page redirects to index if no fee was selected for payment' do - post '/fees/payment', params: { jwt: File.read('spec/data/fees/alma-fees-jwt.txt') } + jwt = File.read('spec/data/fees/alma-fees-jwt.txt').strip + post '/fees/payment', params: { jwt: jwt } expect(response).to have_http_status(:found) - expect(response).to redirect_to("#{fees_path}?jwt=#{File.read('spec/data/fees/alma-fees-jwt.txt')}") + expect(response).to redirect_to("#{fees_path}?jwt=#{jwt}") end it 'successful transaction_complete returns status 200' do From 4b0f14ff7b82c30a6094cfad6df06b82937a0f51 Mon Sep 17 00:00:00 2001 From: David Zuckerman Date: Fri, 3 Apr 2026 09:14:25 -0700 Subject: [PATCH 7/7] fixed rubocop shadow exception error --- app/controllers/fees_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/fees_controller.rb b/app/controllers/fees_controller.rb index 103c7d0f..4eabf0bc 100644 --- a/app/controllers/fees_controller.rb +++ b/app/controllers/fees_controller.rb @@ -18,7 +18,7 @@ def index end rescue ActionController::ParameterMissing redirect_to 'https://www.lib.berkeley.edu/find/borrow-renew?section=pay-fees', allow_other_host: true - rescue JWT::DecodeError, JWT::VerificationError => e + rescue JWT::DecodeError => e Rails.logger.warn "JWT verification failed: #{e.message}" redirect_to(action: :transaction_error) end