Skip to content

Commit

Permalink
[VI-252] map sts validation (#20186)
Browse files Browse the repository at this point in the history
* adds map sts token duration validation

* updates

* updates & unit specs

* initial MAP JWT signature validation

* map jwks call

* updates map response to a VA.gov-owned keypair

* spec & mock updates

* updates map jwks handling, spec fixes

* spec updates

* updates

* imports SiS PublicJwks module
  • Loading branch information
bramleyjl authored Jan 17, 2025
1 parent 7189935 commit 9b91dfd
Show file tree
Hide file tree
Showing 13 changed files with 274 additions and 95 deletions.
2 changes: 1 addition & 1 deletion app/controllers/v0/map_services_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ def token
result = MAP::SecurityToken::Service.new.token(application: params[:application].to_sym, icn:, cache: false)

render json: result, status: :ok
rescue Common::Client::Errors::ClientError, Common::Exceptions::GatewayTimeout
rescue Common::Client::Errors::ClientError, Common::Exceptions::GatewayTimeout, JWT::DecodeError
render json: sts_client_error, status: :bad_gateway
rescue MAP::SecurityToken::Errors::ApplicationMismatchError
render json: application_mismatch_error, status: :bad_request
Expand Down
3 changes: 3 additions & 0 deletions config/betamocks/services_config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -908,6 +908,9 @@
- :method: :post
:path: "/sts/oauth/v1/token"
:file_path: "map/secure_token_service/token"
- :method: :get
:path: "/sts/oauth/v1/jwks"
:file_path: "map/secure_token_service/jwks"

# Sign Up Service Terms API
- :name: "MAP SUS"
Expand Down
14 changes: 13 additions & 1 deletion lib/map/security_token/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,18 @@ def client_cert_path
Settings.map_services.client_cert_path
end

def jwks_cache_key
'map_public_jwks'
end

def jwks_cache_expiration
30.minutes
end

def public_jwks_path
'/sts/oauth/v1/jwks'
end

def service_name
'map_security_token_service'
end
Expand Down Expand Up @@ -66,7 +78,7 @@ def client_assertion_patient_id_type
'icn'
end

def logging_prefix
def log_prefix
'[MAP][SecurityToken][Service]'
end

Expand Down
34 changes: 23 additions & 11 deletions lib/map/security_token/service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,35 @@

require 'map/security_token/configuration'
require 'map/security_token/errors'
require 'sign_in/public_jwks'

module MAP
module SecurityToken
class Service < Common::Client::Base
include SignIn::PublicJwks
configuration Configuration

def token(application:, icn:, cache: true)
cached_response = true
Rails.logger.info("#{config.logging_prefix} token request", { application:, icn: })
Rails.logger.info("#{config.log_prefix} token request", { application:, icn: })
token = Rails.cache.fetch("map_sts_token_#{application}_#{icn}", expires_in: 5.minutes, force: !cache) do
cached_response = false
request_token(application, icn)
end
Rails.logger.info("#{config.logging_prefix} token success", { application:, icn:, cached_response: })
Rails.logger.info("#{config.log_prefix} token success", { application:, icn:, cached_response: })
token
rescue Common::Client::Errors::ParsingError => e
Rails.logger.error("#{config.logging_prefix} token failed, parsing error", application:, icn:,
context: e.message)
Rails.logger.error("#{config.log_prefix} token failed, parsing error", application:, icn:,
context: e.message)
raise e
rescue JWT::DecodeError => e
Rails.logger.error("#{config.log_prefix} token failed, JWT decode error", application:, icn:,
context: e.message)
raise e
rescue Common::Client::Errors::ClientError => e
parse_and_raise_error(e, icn, application)
rescue Common::Exceptions::GatewayTimeout => e
Rails.logger.error("#{config.logging_prefix} token failed, gateway timeout", application:, icn:)
Rails.logger.error("#{config.log_prefix} token failed, gateway timeout", application:, icn:)
raise e
rescue Errors::ApplicationMismatchError => e
Rails.logger.error(e.message, application:, icn:)
Expand All @@ -49,25 +55,33 @@ def parse_and_raise_error(e, icn, application)
error_source = status >= 500 ? 'server' : 'client'
parse_body = e.body.presence || {}
context = { error: parse_body['error'] }
message = "#{config.logging_prefix} token failed, #{error_source} error"
message = "#{config.log_prefix} token failed, #{error_source} error"

Rails.logger.error(message, status:, application:, icn:, context:)
raise e, "#{message}, status: #{status}, application: #{application}, icn: #{icn}, context: #{context}"
end

def parse_response(response, application, icn)
response_body = response.body
validate_map_token(response_body['access_token'])

{
access_token: response_body['access_token'],
expiration: Time.zone.now + response_body['expires_in']
}
rescue JWT::DecodeError => e
raise e
rescue => e
message = "#{config.logging_prefix} token failed, response unknown"
message = "#{config.log_prefix} token failed, response unknown"
Rails.logger.error(message, application:, icn:)
raise e, "#{message}, application: #{application}, icn: #{icn}"
end

def validate_map_token(encoded_token)
public_keys = public_jwks.keys.map(&:public_key)
JWT.decode(encoded_token, public_keys, true, { algorithms: ['RS512'] })
end

def client_id_from_application(application)
case application
when :chatbot
Expand All @@ -79,14 +93,12 @@ def client_id_from_application(application)
when :appointments
config.appointments_client_id
else
raise Errors::ApplicationMismatchError, "#{config.logging_prefix} token failed, application mismatch detected"
raise Errors::ApplicationMismatchError, "#{config.log_prefix} token failed, application mismatch detected"
end
end

def token_params(application, icn)
unless icn
raise Errors::MissingICNError, "#{config.logging_prefix} token failed, ICN not present in access token"
end
raise Errors::MissingICNError, "#{config.log_prefix} token failed, ICN not present in access token" unless icn

client_id = client_id_from_application(application)
URI.encode_www_form({ grant_type: config.grant_type,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@
VCR.use_cassette 'check_in/clinics/get_clinics_200' do
VCR.use_cassette 'check_in/facilities/get_facilities_200' do
VCR.use_cassette 'check_in/appointments/get_appointments_200' do
VCR.use_cassette 'check_in/map/security_token_service_200' do
VCR.use_cassette 'map/security_token_service_200_response' do
get "/check_in/v2/sessions/#{id}/appointments", params: { start: start_date, end: end_date }
end
end
Expand Down Expand Up @@ -302,7 +302,7 @@
VCR.use_cassette 'check_in/clinics/get_clinics_200' do
VCR.use_cassette 'check_in/facilities/get_facilities_200' do
VCR.use_cassette 'check_in/appointments/get_appointments_without_location_200' do
VCR.use_cassette 'check_in/map/security_token_service_200' do
VCR.use_cassette 'map/security_token_service_200_response' do
get "/check_in/v2/sessions/#{id}/appointments", params: { start: start_date, end: end_date }
end
end
Expand Down Expand Up @@ -390,7 +390,7 @@
it 'returns appointments' do
VCR.use_cassette 'check_in/facilities/get_facilities_200' do
VCR.use_cassette 'check_in/appointments/get_appointments_without_clinic_200' do
VCR.use_cassette 'check_in/map/security_token_service_200' do
VCR.use_cassette 'map/security_token_service_200_response' do
get "/check_in/v2/sessions/#{id}/appointments", params: { start: start_date, end: end_date }
end
end
Expand All @@ -417,7 +417,7 @@

it 'returns error' do
VCR.use_cassette 'check_in/appointments/get_appointments_500' do
VCR.use_cassette 'check_in/map/security_token_service_200' do
VCR.use_cassette 'map/security_token_service_200_response' do
get "/check_in/v2/sessions/#{id}/appointments", params: { start: start_date, end: end_date }
end
end
Expand All @@ -444,7 +444,7 @@
it 'returns error' do
VCR.use_cassette 'check_in/facilities/get_facilities_500' do
VCR.use_cassette 'check_in/appointments/get_appointments_200' do
VCR.use_cassette 'check_in/map/security_token_service_200' do
VCR.use_cassette 'map/security_token_service_200_response' do
get "/check_in/v2/sessions/#{id}/appointments", params: { start: start_date, end: end_date }
end
end
Expand Down Expand Up @@ -473,7 +473,7 @@
VCR.use_cassette 'check_in/clinics/get_clinics_500' do
VCR.use_cassette 'check_in/facilities/get_facilities_200' do
VCR.use_cassette 'check_in/appointments/get_appointments_200' do
VCR.use_cassette 'check_in/map/security_token_service_200' do
VCR.use_cassette 'map/security_token_service_200_response' do
get "/check_in/v2/sessions/#{id}/appointments", params: { start: start_date, end: end_date }
end
end
Expand Down
12 changes: 12 additions & 0 deletions spec/fixtures/map/jwks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"keys": [
{
"kty": "RSA",
"e": "AQAB",
"use": "sig",
"kid": "c9233bd7a62406b325c3cbf9778ea1ec75fa6de587640ee6d522e1bda2251277",
"alg": "RS512",
"n": "rGRLjxGb2ygtXWEEC99h5Z__PrAAqf0_wXQcFDnbV1bCp2rfv1xprPS-Mhi_mLh4YVBVKfD5vz0X9eHq1ieua_prgIgloT4doOzphkPTVoALQcm7HmBEWOs3r_nOIZMyOomPb-i4EqNITqD-qEdxGce5-GuopT1BotKzwtX5m7YlqviLFvyCQIRxr1C8GspAjyrODZVTTiKN0nyQKO3EUQZ2ietC52sbnSlnFOHbIpRP1mBrRvkELYIk8gfInMNvSk98SeWd2dDGe48OKNAzR2zGWYwIvxChBAoxahQ1Rh89WF1zKWIRTxYTuBJP3owBJdcfDcvTxovW5y6ciL0KyJKkZgyRJuBvIt8P9tEus8ef9s_3dRnKJi46uRolre5snXWIAAf-fUZvHdnwPLfANgqpNauVPwjtC_MGvbXYALdyCpIUmuRhWX_OHWYl7PTjllMezjVNylSK-2QVc6M7U3OXt00Q8poRKEUquowNPgbNaQnBzOtOEnpqCNIl0_86Qf8QLoSbdF4B0Yr2LJPraPsFxT3xdbcg9bS8vvGWJ2a2KmLqDKmpm6e9Cr3QZ-2-rUBn4KURjnV2KWQM-6tYwUTV0o467OaCNwkUSOqaPUuMbRHR7L5YTuA8BkJsPv_6pZe3VD74N9kbSMlt7RC2Qo2FCyxRwUtdSzTjheJwrvs"
}
]
}
56 changes: 56 additions & 0 deletions spec/lib/map/security_token/service_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,21 @@
let(:log_prefix) { '[MAP][SecurityToken][Service]' }
let(:expected_request_message) { "#{log_prefix} token request" }
let(:expected_request_payload) { { application:, icn: } }
let(:jwks_cache_key) { 'map_public_jwks' }
let(:jwk_payload) { JSON.parse(File.read('spec/fixtures/map/jwks.json'))['keys'].first }
let(:map_jwks) { JWT::JWK::Set.new([jwk_payload]) }
let(:redis_store) { ActiveSupport::Cache::RedisCacheStore.new(redis: MockRedis.new) }

shared_examples 'STS token request' do
before do
allow(Rails).to receive(:cache).and_return(redis_store)
Rails.cache.write(jwks_cache_key, map_jwks)
end

after do
Rails.cache.clear
end

it 'logs the token request' do
VCR.use_cassette('map/security_token_service_200_response') do
expect(Rails.logger).to receive(:info).with(expected_request_message, expected_request_payload)
Expand Down Expand Up @@ -144,6 +157,49 @@
let(:expected_log_message) { "#{log_prefix} token success" }
let(:expected_log_payload) { { application:, icn:, cached_response: false } }

context 'when validating the response token' do
before do
described_class.configuration.instance_variable_set(:@public_jwks, nil)
allow(Rails.logger).to receive(:info)
end

context 'when obtaining the MAP STS JWKs' do
context 'and the MAP STS JWKs are not cached' do
before { Rails.cache.clear }

it 'makes a request to the MAP STS JWKs endpoint' do
VCR.use_cassette('map/security_token_service_200_response') do
expect(Rails.logger).to receive(:info).with("#{log_prefix} Get Public JWKs Success")
subject
end
end
end

context 'and the MAP STS JWKs are cached' do
it 'does not make a request to the MAP STS JWKs endpoint' do
VCR.use_cassette('map/security_token_service_200_response') do
expect(Rails.cache).not_to receive(:write).with(jwks_cache_key, anything)
expect(Rails.logger).not_to receive(:info).with("#{log_prefix} Get Public JWKs Success")
subject
end
end
end
end

context 'when response is an invalid token',
vcr: { cassette_name: 'map/security_token_service_200_invalid_token' } do
let(:expected_error) { JWT::DecodeError }
let(:expected_error_context) { 'Signature verification failed' }
let(:expected_logger_message) { "#{log_prefix} token failed, JWT decode error" }
let(:expected_log_values) { { application:, icn:, context: expected_error_context } }

it 'raises a JWT Decode error and creates a log' do
expect(Rails.logger).to receive(:error).with(expected_logger_message, expected_log_values)
expect { subject }.to raise_exception(expected_error, expected_error_context)
end
end
end

it 'logs a token success message',
vcr: { cassette_name: 'map/security_token_service_200_response' } do
expect(Rails.logger).to receive(:info).with(expected_request_message, { application:, icn: })
Expand Down
2 changes: 1 addition & 1 deletion spec/lib/map/sign_up/service_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@
let(:expected_log_message) { "#{log_prefix} agreements accept success, icn: #{icn}" }

before do
Timecop.freeze(Time.zone.local(2023, 1, 1, 12, 0, 0))
Timecop.freeze(Time.zone.local(2024, 9, 1, 12, 0, 0))
allow(Rails.logger).to receive(:info)
end

Expand Down
20 changes: 19 additions & 1 deletion spec/requests/v0/map_services_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,25 @@
end
end

context 'when MAP STS client returns an access token',
context 'when MAP STS client returns an invalid token',
vcr: { cassette_name: 'map/security_token_service_200_invalid_token' } do
it 'responds with error details in response body' do
call_endpoint
expect(JSON.parse(response.body)).to eq(
{
'error' => 'server_error',
'error_description' => 'STS failed to return a valid token.'
}
)
end

it 'returns HTTP status bad_gateway' do
call_endpoint
expect(response).to have_http_status(:bad_gateway)
end
end

context 'when MAP STS client returns a valid access token',
vcr: { cassette_name: 'map/security_token_service_200_response' } do
it 'responds with STS-issued token in response body' do
call_endpoint
Expand Down

This file was deleted.

Loading

0 comments on commit 9b91dfd

Please sign in to comment.