Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
5 changes: 3 additions & 2 deletions Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions app/controllers/concerns/alma_jwt_validator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
require 'jwt'
require 'net/http'
require 'json'

module AlmaJwtValidator
JWKS_URL = 'https://api-na.hosted.exlibrisgroup.com/auth/01UCS_BER/jwks.json'.freeze
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the sandbox publish a different URL? If so we'd need to be able to inject this (e.g. via ENV).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As we noticed when pairing, the actual issuer is Prima. From our testing:

# Get a Ruby shell with jwt installed...
$ docker run --rm -it ruby:3 bash -c 'gem install pry; gem install jwt; exec pry'

# Login to Alma, then visit the fees link from your account page.
# Extract the jwt from the URL in the Framework redirect and decode it.
require 'jwt'
jwt = "{copy from URL}"
JWT.decode(jwt, nil, false)

EXPECTED_ISS = 'Prima'.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

def decode_and_verify_jwt(token)
options = {
algorithm: 'RS256',
verify_expiration: true,
verify_aud: false,
Comment thread
davezuckerman marked this conversation as resolved.
verify_iss: true,
iss: EXPECTED_ISS,
jwks: jwk_set
}

JWT.decode(token, nil, true, options)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose this wasn't clear from my earlier comment, but I'm curious about why we're using JWT.decode instead of instantiating JWT::EncodedToken and using its methods.

end
Comment thread
anarchivist marked this conversation as resolved.
end
16 changes: 12 additions & 4 deletions app/controllers/fees_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 => 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)
Expand Down
92 changes: 92 additions & 0 deletions spec/controllers/concerns/alma_jwt_validator_spec.rb
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly, there's a lot in this file that seems to duplicate what's in JWT::JWK's methods here.

Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
require 'rails_helper'
require 'jwt'
require 'json'
require 'openssl'

describe AlmaJwtValidator do
let(:alma_institution_code) { '01UCS_BER' }
let(:jwks_url) { "https://api-na.hosted.exlibrisgroup.com/auth/#{alma_institution_code}/jwks.json" }
let(:expected_iss) { 'Prima' }

# Generate an RSA key pair for testing
let(:rsa_key) { OpenSSL::PKey::RSA.new(2048) }
let(:kid) { 'test-key-id' }
let(:test_payload) { { 'userName' => '10335026', 'iss' => expected_iss } }

# Helper to create JWK hash from RSA key using JWT::JWK
def create_jwk_hash(key, kid)
jwk = JWT::JWK.new(key, kid: kid)
jwk.export
end

# Helper to generate a valid JWT
def generate_jwt(payload, key, kid, algorithm = 'RS256')
header = { 'kid' => kid, 'alg' => algorithm }
JWT.encode(payload, key, algorithm, header)
end

before do
jwk = create_jwk_hash(rsa_key, kid)

stub_request(:get, jwks_url)
.to_return(
status: 200,
body: { 'keys' => [jwk] }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end

describe '.decode_and_verify_jwt' do
context 'with a valid JWT' do
it 'returns the decoded payload' do
token = generate_jwt(test_payload, rsa_key, kid)
result = AlmaJwtValidator.decode_and_verify_jwt(token)

expect(result).to be_an(Array)
expect(result[0]['userName']).to eq('10335026')
expect(result[1]['kid']).to eq(kid)
end
end

context 'with an invalid signature' do
it 'raises JWT::DecodeError' do
# Generate a token with a different key
different_key = OpenSSL::PKey::RSA.new(2048)
token = generate_jwt(test_payload, different_key, kid)

expect do
AlmaJwtValidator.decode_and_verify_jwt(token)
end.to raise_error(JWT::DecodeError)
end
end

context 'with an unknown key id' do
it 'raises JWT::DecodeError' do
token = generate_jwt(test_payload, rsa_key, 'unknown-kid')

expect do
AlmaJwtValidator.decode_and_verify_jwt(token)
end.to raise_error(JWT::DecodeError)
end
end

context 'with a malformed JWT' do
it 'raises JWT::DecodeError' do
expect do
AlmaJwtValidator.decode_and_verify_jwt('not.a.jwt')
end.to raise_error(JWT::DecodeError)
end
end

context 'when JWKS endpoint is unreachable' do
it 'raises an error' do
stub_request(:get, jwks_url).to_return(status: 500)
token = generate_jwt(test_payload, rsa_key, kid)

expect do
AlmaJwtValidator.decode_and_verify_jwt(token)
end.to raise_error(StandardError)
end
end
end
end
16 changes: 12 additions & 4 deletions spec/request/fees_request_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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: '')

Expand Down Expand Up @@ -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
Expand Down
Loading