diff --git a/app/services/inherited_proofing/va/service.rb b/app/services/inherited_proofing/va/service.rb new file mode 100644 index 00000000000..87a7c047190 --- /dev/null +++ b/app/services/inherited_proofing/va/service.rb @@ -0,0 +1,86 @@ +module InheritedProofing + module Va + # Encapsulates request, response, error handling, validation, etc. for calling + # the VA service to gain PII for a particular user that will be subsequently + # used to proof the user using inherited proofing. + class Service + BASE_URI = IdentityConfig.store.inherited_proofing_va_base_url + + attr_reader :auth_code + + def initialize(auth_code) + @auth_code = auth_code + end + + # Calls the endpoint and returns the decrypted response. + def execute + raise 'The provided auth_code is blank?' if auth_code.blank? + + response = request + payload_to_hash decrypt_payload(response) + end + + private + + def request + connection.get(request_uri) { |req| req.headers = request_headers } + end + + def connection + Faraday.new do |conn| + conn.options.timeout = request_timeout + conn.options.read_timeout = request_timeout + conn.options.open_timeout = request_timeout + conn.options.write_timeout = request_timeout + conn.request :instrumentation, name: 'inherited_proofing.va' + + # raises errors on 4XX or 5XX responses + conn.response :raise_error + end + end + + def request_timeout + @request_timeout ||= IdentityConfig.store.doc_auth_s3_request_timeout + end + + def request_uri + @request_uri ||= "#{ URI(BASE_URI) }/inherited_proofing/user_attributes" + end + + def request_headers + { Authorization: "Bearer #{jwt_token}" } + end + + def jwt_token + JWT.encode(jwt_payload, private_key, jwt_encryption) + end + + def jwt_payload + { inherited_proofing_auth: auth_code, exp: jwt_expires } + end + + def private_key + @private_key ||= AppArtifacts.store.oidc_private_key + end + + def jwt_encryption + 'RS256' + end + + def jwt_expires + 1.day.from_now.to_i + end + + def decrypt_payload(response) + payload = JSON.parse(response.body)['data'] + JWE.decrypt(payload, private_key) if payload + end + + def payload_to_hash(decrypted_payload, default: nil) + return default unless decrypted_payload.present? + + JSON.parse(decrypted_payload, symbolize_names: true) + end + end + end +end diff --git a/config/application.yml.default b/config/application.yml.default index c5fa94eb4e5..d75e9a96089 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -283,6 +283,7 @@ voice_otp_speech_rate: 'slow' voip_check: true voip_block: true voip_allowed_phones: '[]' +inherited_proofing_va_base_url: 'https://staging-api.va.gov' development: aamva_private_key: 123abc diff --git a/lib/identity_config.rb b/lib/identity_config.rb index 558c077b7c0..bbeeed761d2 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -375,6 +375,7 @@ def self.build_store(config_map) config.add(:voip_allowed_phones, type: :json) config.add(:voip_block, type: :boolean) config.add(:voip_check, type: :boolean) + config.add(:inherited_proofing_va_base_url, type: :string) @store = RedactedStruct.new('IdentityConfig', *config.written_env.keys, keyword_init: true). new(**config.written_env) diff --git a/spec/services/inherited_proofing/va/service_spec.rb b/spec/services/inherited_proofing/va/service_spec.rb new file mode 100644 index 00000000000..a44c8eec4b6 --- /dev/null +++ b/spec/services/inherited_proofing/va/service_spec.rb @@ -0,0 +1,60 @@ +require 'rails_helper' + +RSpec.shared_examples 'an invalid auth code error is raised' do + it 'raises an error' do + expect { subject.execute }.to raise_error 'The provided auth_code is blank?' + end +end + +RSpec.describe InheritedProofing::Va::Service do + subject(:service) { described_class.new auth_code } + + before do + allow(service).to receive(:private_key).and_return(private_key) + end + + let(:auth_code) {} + let(:private_key) { private_key_from_store_or(file_name: 'va_ip.key') } + let(:payload) { { inherited_proofing_auth: auth_code, exp: 1.day.from_now.to_i } } + let(:jwt_token) { JWT.encode(payload, private_key, 'RS256') } + let(:request_uri) { + "#{InheritedProofing::Va::Service::BASE_URI}/inherited_proofing/user_attributes" + } + let(:request_headers) { { Authorization: "Bearer #{jwt_token}" } } + + it { respond_to :execute } + + it do + expect(service.send(:private_key)).to eq private_key + end + + describe '#execute' do + context 'when the auth code is valid' do + let(:auth_code) { 'mocked-auth-code-for-testing' } + + it 'makes an authenticated request' do + stub = stub_request(:get, request_uri). + with(headers: request_headers). + to_return(status: 200, body: '{}', headers: {}) + + service.execute + + expect(stub).to have_been_requested.once + end + end + + context 'when the auth code is invalid' do + context 'when an empty? string' do + let(:auth_code) { '' } + + it_behaves_like 'an invalid auth code error is raised' + end + + context 'when an nil?' do + let(:auth_code) { nil } + + it_behaves_like 'an invalid auth code error is raised' + end + end + end +end diff --git a/spec/support/private_key_file_helper.rb b/spec/support/private_key_file_helper.rb new file mode 100644 index 00000000000..94a4aaaaf5e --- /dev/null +++ b/spec/support/private_key_file_helper.rb @@ -0,0 +1,27 @@ +module PrivateKeyFileHelper + # Returns the private key in AppArtifacts.store.oidc_private_key if + # Identity::Hostdata.in_datacenter? or if the private key file does + # not exist; otherwise, the private key from the file is returned. + def private_key_from_store_or(file_name:) + file_name = force_tmp_private_key_file_name file_name: file_name + + if Rails.env.test? && !File.exist?(file_name) + puts "WARNING: Private key file '#{file_name}' not found!" + end + + if File.exist?(file_name) + OpenSSL::PKey::RSA.new(File.read(file_name)) + else + return AppArtifacts.store.oidc_private_key + end + end + + # Always ensure we're referencing files in the /tmp/ folder! + def force_tmp_private_key_file_name(file_name:) + "#{Rails.root}/tmp/#{File.basename(file_name)}" + end +end + +RSpec.configure do |config| + config.include PrivateKeyFileHelper +end