Skip to content

Commit 0422ec6

Browse files
committed
validate Alma JWT signature
1 parent c8b3680 commit 0422ec6

File tree

6 files changed

+78
-12
lines changed

6 files changed

+78
-12
lines changed

Gemfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ gem 'ipaddress'
1919
gem 'jaro_winkler', '~> 1.5.5'
2020
gem 'jquery-rails'
2121
gem 'jquery-ui-rails'
22-
gem 'jwt', '~> 1.5', '>= 1.5.4'
22+
gem 'jwt', '~> 2.5'
2323
gem 'lograge', '>=0.11.2'
2424
gem 'mutex_m' # Deprecation warning.
2525
gem 'netaddr', '~> 1.5', '>= 1.5.1'

Gemfile.lock

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,8 @@ GEM
213213
json (2.18.1)
214214
jsonpath (0.5.8)
215215
multi_json
216-
jwt (1.5.6)
216+
jwt (2.10.2)
217+
base64
217218
language_server-protocol (3.17.0.5)
218219
lint_roller (1.1.0)
219220
listen (3.10.0)
@@ -542,7 +543,7 @@ DEPENDENCIES
542543
jaro_winkler (~> 1.5.5)
543544
jquery-rails
544545
jquery-ui-rails
545-
jwt (~> 1.5, >= 1.5.4)
546+
jwt (~> 2.5)
546547
listen (~> 3.2)
547548
lograge (>= 0.11.2)
548549
mutex_m
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
require 'jwt'
2+
require 'net/http'
3+
require 'json'
4+
5+
module AlmaJwtValidator
6+
JWKS_URL = 'https://api-na.hosted.exlibrisgroup.com/auth/01UCS_BER/jwks.json'.freeze
7+
EXPECTED_ISS = 'https://api-na.hosted.exlibrisgroup.com/auth/01UCS_BER'.freeze
8+
9+
module_function
10+
11+
def jwk_set
12+
Rails.cache.fetch('jwks_set', expires_in: 4.hour) do
13+
jwks_raw = Net::HTTP.get(URI(JWKS_URL))
14+
jwks_keys = JSON.parse(jwks_raw)['keys']
15+
JWT::JWK::Set.new(jwks_keys)
16+
end
17+
end
18+
19+
# rubocop:disable Metrics/MethodLength
20+
def decode_and_verify_jwt(token)
21+
# Decode header to get the 'kid'
22+
header = JWT.decode(token, nil, false).last
23+
kid = header['kid']
24+
25+
# Find the key from the JWK set
26+
jwk = jwk_set.keys.find { |key| key.kid == kid }
27+
raise JWT::VerificationError, 'Key not found in JWKS' unless jwk
28+
29+
public_key = jwk.public_key
30+
31+
options = {
32+
algorithm: 'RS256',
33+
verify_expiration: true,
34+
verify_aud: false,
35+
verify_iss: true,
36+
iss: EXPECTED_ISS
37+
}
38+
39+
# Returns [payload, header] array if valid
40+
JWT.decode(token, public_key, true, options)
41+
rescue JWT::ExpiredSignature
42+
raise JWT::VerificationError, 'Token has expired'
43+
rescue JWT::InvalidIssuerError
44+
raise JWT::VerificationError, 'Token issuer mismatch'
45+
rescue JWT::DecodeError => e
46+
raise JWT::VerificationError, "Invalid JWT: #{e.message}"
47+
end
48+
# rubocop:enable Metrics/MethodLength
49+
end

app/controllers/fees_controller.rb

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,24 @@ class FeesController < ApplicationController
55
# This will be needed for transaction_complete since Paypal will hit that
66
protect_from_forgery with: :null_session
77

8+
# rubocop:disable Metrics/MethodLength
89
def index
910
@jwt = params.require(:jwt)
10-
decoded_token = JWT.decode @jwt, nil, false
11-
@alma_id = decoded_token.first['userName']
12-
@fees = FeesPayment.new(alma_id: @alma_id)
11+
payload = AlmaJwtValidator.decode_and_verify_jwt(@jwt)
12+
@alma_id = payload.first['userName']
13+
begin
14+
@fees = FeesPayment.new(alma_id: @alma_id)
15+
rescue StandardError => e
16+
Rails.logger.warn "FeesPayment failed: #{e.message}"
17+
redirect_to(action: :transaction_error) and return
18+
end
1319
rescue ActionController::ParameterMissing
1420
redirect_to 'https://www.lib.berkeley.edu/find/borrow-renew?section=pay-fees', allow_other_host: true
15-
rescue JWT::DecodeError
21+
rescue JWT::DecodeError, JWT::VerificationError => e
22+
Rails.logger.warn "JWT verification failed: #{e.message}"
1623
redirect_to(action: :transaction_error)
1724
end
25+
# rubocop:enable Metrics/MethodLength
1826

1927
def efee
2028
@jwt = params.require(:jwt)

spec/data/fees/alma-fees-jwt.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJQcmltYSIsImp0aSI6IkMyOTIyMEQ2RTFCQTk4NDMzOEMzQTRDMEFCOTUwOUY5LmFwZDAzLm5hMDcucHJvZC5hbG1hLmRjMDEuaG9zdGVkLmV4bGlicmlzZ3JvdXAuY29tOjE4MDEiLCJ1c2VyTmFtZSI6IjEwMzM1MDI2IiwiZGlzcGxheU5hbWUiOiJTdWxsaXZhbiwgU3RldmVuIiwidXNlciI6IjI2Mzc3MjgwNTAwMDY1MzIiLCJ1c2VyR3JvdXAiOiJMSUJTVEFGRiIsImluc3RpdHV0aW9uIjoiMDFVQ1NfQkVSIiwidXNlcklwIjoiNzMuNzEuMTM4LjE3IiwiYXV0aGVudGljYXRpb25Qcm9maWxlIjoiQ0FTIiwiYXV0aGVudGljYXRpb25TeXN0ZW0iOiJDQVMiLCJsYW5ndWFnZSI6ImVuIiwic2FtbFNlc3Npb25JbmRleCI6IiIsInNhbWxOYW1lSWQiOiIiLCJvbkNhbXB1cyI6ImZhbHNlIiwic2lnbmVkSW4iOiJ0cnVlIiwidmlld0lkIjoiMDFVQ1NfQkVSOlVDQiJ9.Xus3sbFX8IHLPrV5_5YY8gbtBXzC48xLOu3XsMtQaMw
1+
eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyTmFtZSI6IjEwMzM1MDI2In0.23uMX0G7rPdgXarjFtlNUhxJJKGXDnlVNJpS34E0Vfg

spec/request/fees_request_spec.rb

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,13 @@ def base_url_for(user_id = nil)
99
let(:request_headers) { { 'Accept' => 'application/json', 'Authorization' => "apikey #{alma_api_key}" } }
1010

1111
before do
12-
allow(Rails.application.config).to receive(:alma_api_key).and_return(alma_api_key)
12+
allow(AlmaJwtValidator).to receive(:decode_and_verify_jwt).and_return(
13+
[{ 'userName' => '10335026' }]
14+
)
15+
allow(Rails.application.config).to receive_messages(
16+
alma_api_key: alma_api_key,
17+
alma_jwt_secret: 'fake-jwt-secret'
18+
)
1319
end
1420

1521
it 'redirects to the fallback URL if there is no jwt' do
@@ -18,7 +24,8 @@ def base_url_for(user_id = nil)
1824
end
1925

2026
it 'redirects to error page if request has a non-existant alma id' do
21-
stub_request(:get, "#{base_url_for}fees")
27+
user_id = '10335026'
28+
stub_request(:get, "#{base_url_for(user_id)}/fees")
2229
.with(headers: request_headers)
2330
.to_return(status: 404, body: '')
2431

@@ -53,9 +60,10 @@ def base_url_for(user_id = nil)
5360
end
5461

5562
it 'payments page redirects to index if no fee was selected for payment' do
56-
post '/fees/payment', params: { jwt: File.read('spec/data/fees/alma-fees-jwt.txt') }
63+
jwt = File.read('spec/data/fees/alma-fees-jwt.txt').strip
64+
post '/fees/payment', params: { jwt: jwt }
5765
expect(response).to have_http_status(:found)
58-
expect(response).to redirect_to("#{fees_path}?jwt=#{File.read('spec/data/fees/alma-fees-jwt.txt')}")
66+
expect(response).to redirect_to("#{fees_path}?jwt=#{jwt}")
5967
end
6068

6169
it 'successful transaction_complete returns status 200' do

0 commit comments

Comments
 (0)