Skip to content

Commit 88d0a49

Browse files
committed
validating jwt signature from alma
refactored jwt signature validation to use more built in jwt methods For JWT signature authorization. changed EXPECTED_ISS and removed rescue block
1 parent c7871d1 commit 88d0a49

6 files changed

Lines changed: 151 additions & 11 deletions

File tree

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: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
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 = 'Prima'.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+
def decode_and_verify_jwt(token)
20+
options = {
21+
algorithm: 'RS256',
22+
verify_expiration: true,
23+
verify_aud: false,
24+
verify_iss: true,
25+
iss: EXPECTED_ISS,
26+
jwks: jwk_set
27+
}
28+
29+
JWT.decode(token, nil, true, options)
30+
end
31+
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 => 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)
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
require 'rails_helper'
2+
require 'jwt'
3+
require 'json'
4+
require 'openssl'
5+
6+
describe AlmaJwtValidator do
7+
let(:alma_institution_code) { '01UCS_BER' }
8+
let(:jwks_url) { "https://api-na.hosted.exlibrisgroup.com/auth/#{alma_institution_code}/jwks.json" }
9+
let(:expected_iss) { 'Prima' }
10+
11+
# Generate an RSA key pair for testing
12+
let(:rsa_key) { OpenSSL::PKey::RSA.new(2048) }
13+
let(:kid) { 'test-key-id' }
14+
let(:test_payload) { { 'userName' => '10335026', 'iss' => expected_iss } }
15+
16+
# Helper to create JWK hash from RSA key using JWT::JWK
17+
def create_jwk_hash(key, kid)
18+
jwk = JWT::JWK.new(key, kid: kid)
19+
jwk.export
20+
end
21+
22+
# Helper to generate a valid JWT
23+
def generate_jwt(payload, key, kid, algorithm = 'RS256')
24+
header = { 'kid' => kid, 'alg' => algorithm }
25+
JWT.encode(payload, key, algorithm, header)
26+
end
27+
28+
before do
29+
jwk = create_jwk_hash(rsa_key, kid)
30+
31+
stub_request(:get, jwks_url)
32+
.to_return(
33+
status: 200,
34+
body: { 'keys' => [jwk] }.to_json,
35+
headers: { 'Content-Type' => 'application/json' }
36+
)
37+
end
38+
39+
describe '.decode_and_verify_jwt' do
40+
context 'with a valid JWT' do
41+
it 'returns the decoded payload' do
42+
token = generate_jwt(test_payload, rsa_key, kid)
43+
result = AlmaJwtValidator.decode_and_verify_jwt(token)
44+
45+
expect(result).to be_an(Array)
46+
expect(result[0]['userName']).to eq('10335026')
47+
expect(result[1]['kid']).to eq(kid)
48+
end
49+
end
50+
51+
context 'with an invalid signature' do
52+
it 'raises JWT::DecodeError' do
53+
# Generate a token with a different key
54+
different_key = OpenSSL::PKey::RSA.new(2048)
55+
token = generate_jwt(test_payload, different_key, kid)
56+
57+
expect do
58+
AlmaJwtValidator.decode_and_verify_jwt(token)
59+
end.to raise_error(JWT::DecodeError)
60+
end
61+
end
62+
63+
context 'with an unknown key id' do
64+
it 'raises JWT::DecodeError' do
65+
token = generate_jwt(test_payload, rsa_key, 'unknown-kid')
66+
67+
expect do
68+
AlmaJwtValidator.decode_and_verify_jwt(token)
69+
end.to raise_error(JWT::DecodeError)
70+
end
71+
end
72+
73+
context 'with a malformed JWT' do
74+
it 'raises JWT::DecodeError' do
75+
expect do
76+
AlmaJwtValidator.decode_and_verify_jwt('not.a.jwt')
77+
end.to raise_error(JWT::DecodeError)
78+
end
79+
end
80+
81+
context 'when JWKS endpoint is unreachable' do
82+
it 'raises an error' do
83+
stub_request(:get, jwks_url).to_return(status: 500)
84+
token = generate_jwt(test_payload, rsa_key, kid)
85+
86+
expect do
87+
AlmaJwtValidator.decode_and_verify_jwt(token)
88+
end.to raise_error(StandardError)
89+
end
90+
end
91+
end
92+
end

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)