Skip to content

Commit c35ac6f

Browse files
committed
validating jwt signature from alma
1 parent 9e3de46 commit c35ac6f

6 files changed

Lines changed: 177 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: 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 => 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: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
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) { "https://api-na.hosted.exlibrisgroup.com/auth/#{alma_institution_code}" }
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 from RSA public key
17+
def create_jwk(public_key, kid)
18+
n = Base64.urlsafe_encode64(public_key.n.to_s(2)).gsub(/=+$/, '')
19+
e = Base64.urlsafe_encode64(public_key.e.to_s(2)).gsub(/=+$/, '')
20+
{
21+
'kty' => 'RSA',
22+
'kid' => kid,
23+
'use' => 'sig',
24+
'alg' => 'RS256',
25+
'n' => n,
26+
'e' => e
27+
}
28+
end
29+
30+
# Helper to generate a valid JWT
31+
def generate_jwt(payload, key, kid, algorithm = 'RS256')
32+
header = { 'kid' => kid, 'alg' => algorithm }
33+
JWT.encode(payload, key, algorithm, header)
34+
end
35+
36+
before do
37+
jwk = create_jwk(rsa_key.public_key, kid)
38+
39+
stub_request(:get, jwks_url)
40+
.to_return(
41+
status: 200,
42+
body: { 'keys' => [jwk] }.to_json,
43+
headers: { 'Content-Type' => 'application/json' }
44+
)
45+
end
46+
47+
describe '.decode_and_verify_jwt' do
48+
context 'with a valid JWT' do
49+
it 'returns the decoded payload' do
50+
token = generate_jwt(test_payload, rsa_key, kid)
51+
result = AlmaJwtValidator.decode_and_verify_jwt(token)
52+
53+
expect(result).to be_an(Array)
54+
expect(result[0]['userName']).to eq('10335026')
55+
expect(result[1]['kid']).to eq(kid)
56+
end
57+
end
58+
59+
context 'with an invalid signature' do
60+
it 'raises JWT::VerificationError' do
61+
# Generate a token with a different key
62+
different_key = OpenSSL::PKey::RSA.new(2048)
63+
token = generate_jwt(test_payload, different_key, kid)
64+
65+
expect do
66+
AlmaJwtValidator.decode_and_verify_jwt(token)
67+
end.to raise_error(JWT::VerificationError)
68+
end
69+
end
70+
71+
context 'with an unknown key id' do
72+
it 'raises JWT::VerificationError' do
73+
token = generate_jwt(test_payload, rsa_key, 'unknown-kid')
74+
75+
expect do
76+
AlmaJwtValidator.decode_and_verify_jwt(token)
77+
end.to raise_error(JWT::VerificationError)
78+
end
79+
end
80+
81+
context 'with a malformed JWT' do
82+
it 'raises JWT::DecodeError' do
83+
expect do
84+
AlmaJwtValidator.decode_and_verify_jwt('not.a.jwt')
85+
end.to raise_error(JWT::DecodeError)
86+
end
87+
end
88+
89+
context 'when JWKS endpoint is unreachable' do
90+
it 'raises an error' do
91+
stub_request(:get, jwks_url).to_return(status: 500)
92+
token = generate_jwt(test_payload, rsa_key, kid)
93+
94+
expect do
95+
AlmaJwtValidator.decode_and_verify_jwt(token)
96+
end.to raise_error(StandardError)
97+
end
98+
end
99+
end
100+
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)