diff --git a/app/controllers/api/irs_attempts_api_controller.rb b/app/controllers/api/irs_attempts_api_controller.rb new file mode 100644 index 00000000000..c02b060ce96 --- /dev/null +++ b/app/controllers/api/irs_attempts_api_controller.rb @@ -0,0 +1,76 @@ +## +# This controller implements the Poll-based delivery method for Security Event +# Tokens as described RFC 8936 +# +# ref: https://datatracker.ietf.org/doc/html/rfc8936 +# +module Api + class IrsAttemptsApiController < ApplicationController + before_action :render_404_if_disabled + before_action :authenticate_client + + respond_to :json + + def create + acknowledge_acked_events + render json: { sets: security_event_tokens } + analytics.irs_attempts_api_events(**analytics_properties) + end + + private + + def render_404_if_disabled + render_not_found unless IdentityConfig.store.irs_attempt_api_enabled + end + + def authenticate_client + bearer, token = request.authorization.split(' ', 2) + if bearer != 'Bearer' || !valid_auth_tokens.include?(token) + render json: { status: 401, description: 'Unauthorized' }, status: :unauthorized + end + end + + def acknowledge_acked_events + redis_client.delete_events(ack_event_ids) + end + + def ack_event_ids + params['ack'] || [] + end + + def requested_event_count + count = if params['maxEvents'] + params['maxEvents'].to_i + else + IdentityConfig.store.irs_attempt_api_event_count_default + end + + [count, IdentityConfig.store.irs_attempt_api_event_count_max].min + end + + def security_event_tokens + @security_event_tokens ||= redis_client.read_events(requested_event_count) + end + + def security_event_token_errors + return unless params['setErrs'].present? + params['setErrs'].to_json + end + + def redis_client + @redis_client ||= IrsAttemptsApi::RedisClient.new + end + + def valid_auth_tokens + IdentityConfig.store.irs_attempt_api_auth_tokens + end + + def analytics_properties + { + acknowledged_event_count: ack_event_ids.count, + rendered_event_count: security_event_tokens.keys.count, + set_errors: security_event_token_errors, + } + end + end +end diff --git a/app/services/analytics_events.rb b/app/services/analytics_events.rb index d3982602e01..789f771be5d 100644 --- a/app/services/analytics_events.rb +++ b/app/services/analytics_events.rb @@ -921,6 +921,26 @@ def idv_start_over( ) end + # @param [Integer] acknowledged_event_count number of acknowledged events in the API call + # @param [Integer] rendered_event_count how many events were rendered in the API response + # @param [String] set_errors JSON encoded representation of SET errors from the client + # An IRS Attempt API client has acknowledged receipt of security event tokens and polled for a + # new set of events + def irs_attempts_api_events( + acknowledged_event_count:, + rendered_event_count:, + set_errors:, + **extra + ) + track_event( + 'IRS Attempt API: Events submitted', + acknowledged_event_count: acknowledged_event_count, + rendered_event_count: rendered_event_count, + set_errors: set_errors, + **extra, + ) + end + # @param ["authentication","reauthentication","confirmation"] context user session context # User visited the page to enter a backup code as their MFA def multi_factor_auth_enter_backup_code_visit(context:, **extra) diff --git a/app/services/irs_attempts_api/redis_client.rb b/app/services/irs_attempts_api/redis_client.rb index 3cc60306dc7..dc50721fffc 100644 --- a/app/services/irs_attempts_api/redis_client.rb +++ b/app/services/irs_attempts_api/redis_client.rb @@ -23,7 +23,8 @@ def delete_events(event_ids) def read_events(count = 1000) redis_pool.with do |client| - keys = client.scan(0, count: count).last + keys = client.scan(0, count: count).last.first(count) + next {} if keys.empty? client.mapped_mget(*keys) end end diff --git a/app/services/irs_attempts_api/tracker.rb b/app/services/irs_attempts_api/tracker.rb index 42c3c709fb0..4e1982cc3c8 100644 --- a/app/services/irs_attempts_api/tracker.rb +++ b/app/services/irs_attempts_api/tracker.rb @@ -16,6 +16,7 @@ def track_event(event_type, metadata = {}) event_metadata: metadata, ).build_event_token redis_client.write_event(jti: jti, jwe: jwe) + jti end private diff --git a/config/application.yml.default b/config/application.yml.default index c9d11bc5665..a6dbcb36149 100644 --- a/config/application.yml.default +++ b/config/application.yml.default @@ -104,8 +104,11 @@ idv_send_link_max_attempts: 5 in_person_proofing_enabled: true include_slo_in_saml_metadata: false irs_attempt_api_audience: 'https://irs.gov' +irs_attempt_api_auth_tokens: '' irs_attempt_api_enabled: false irs_attempt_api_event_ttl_seconds: 86400 +irs_attempt_api_event_count_default: 1000 +irs_attempt_api_event_count_max: 10000 liveness_checking_enabled: false logins_per_ip_track_only_mode: false # LexisNexis ##################################################### @@ -431,6 +434,7 @@ test: hmac_fingerprinter_key: a2c813d4dca919340866ba58063e4072adc459b767a74cf2666d5c1eef3861db26708e7437abde1755eb24f4034386b0fea1850a1cb7e56bff8fae3cc6ade96c hmac_fingerprinter_key_queue: '["old-key-one", "old-key-two"]' identity_pki_disabled: true + irs_attempt_api_auth_tokens: 'test-token-1,test-token-2' irs_attempt_api_public_key: MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAyut9Uio5XxsIUVrXARqCoHvcMVYT0p6WyU1BnbhxLRW4Q60p+4Bn32vVOt9nzeih7qvauYM5M0PZdKEmwOHflqPP+ABfKhL+6jxBhykN5P5UY375wTFBJZ20Fx8jOJbRhJD02oUQ49YKlDu3MG5Y0ApyD4ER4WKgxuB2OdyQKd9vg2ZZa+P2pw1HkFPEin0h8KBUFBeLGDZni8PIJdHBP6dA+xbayGBxSM/8xQC0JIg6KlGTcLql37QJIhP2oSv0nAJNb6idFPAz0uMCQDQWKKWV5FUDCsFVH7VuQz8xUCwnPn/SdaratB+29bwUpVhgHXrHdJ0i8vjBEX7smD7pI8CcFHuVgACt86NMlBnNCVkwumQgZNAAxe2mJoYcotEWOnhCuMc6MwSj985bj8XEdFlbf4ny9QO9rETd5aYcwXBiV/T6vd637uvHb0KenghNmlb1Tv9LMj2b9ZwNc9C6oeCnbN2YAfxSDrb8Ik+yq4hRewOvIK7f0CcpZYDXK25aHXnHm306Uu53KIwMGf1mha5T5LWTNaYy5XFoMWHJ9E+AnU/MUJSrwCAITH/S0JFcna5Oatn70aTE9pISATsqB5Iz1c46MvdrxD8hPoDjT7x6/EO316DZrxQfJhjbWsCB+R0QxYLkXPHczhB2Z0HPna9xB6RbJHzph7ifDizhZoMCAwEAAQ== lexisnexis_trueid_account_id: 'test_account' lockout_period_in_minutes: 5 diff --git a/config/routes.rb b/config/routes.rb index fc51e551b15..c6ff6d99b46 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -11,6 +11,7 @@ match '/api/openid_connect/token' => 'openid_connect/token#options', via: :options get '/api/openid_connect/userinfo' => 'openid_connect/user_info#show' post '/api/risc/security_events' => 'risc/security_events#create' + post '/api/irs_attempts_api/security_events' => 'api/irs_attempts_api#create' # SAML secret rotation paths SamlEndpoint.suffixes.each do |suffix| diff --git a/lib/identity_config.rb b/lib/identity_config.rb index 25d505f2c89..79182e67fbf 100644 --- a/lib/identity_config.rb +++ b/lib/identity_config.rb @@ -178,8 +178,11 @@ def self.build_store(config_map) config.add(:in_person_proofing_enabled, type: :boolean) config.add(:include_slo_in_saml_metadata, type: :boolean) config.add(:irs_attempt_api_audience) + config.add(:irs_attempt_api_auth_tokens, type: :comma_separated_string_list) config.add(:irs_attempt_api_enabled, type: :boolean) config.add(:irs_attempt_api_event_ttl_seconds, type: :integer) + config.add(:irs_attempt_api_event_count_default, type: :integer) + config.add(:irs_attempt_api_event_count_max, type: :integer) config.add(:irs_attempt_api_public_key) config.add(:lexisnexis_base_url, type: :string) config.add(:lexisnexis_request_mode, type: :string) diff --git a/spec/controllers/api/irs_attempts_api_controller_spec.rb b/spec/controllers/api/irs_attempts_api_controller_spec.rb new file mode 100644 index 00000000000..e30d65dffef --- /dev/null +++ b/spec/controllers/api/irs_attempts_api_controller_spec.rb @@ -0,0 +1,120 @@ +require 'rails_helper' + +RSpec.describe Api::IrsAttemptsApiController do + before do + stub_analytics + + allow(IdentityConfig.store).to receive(:irs_attempt_api_enabled).and_return(true) + + IrsAttemptsApi::RedisClient.clear_attempts! + existing_events + + request.headers['Authorization'] = "Bearer #{auth_token}" + end + + let(:auth_token) do + IdentityConfig.store.irs_attempt_api_auth_tokens.first + end + let(:existing_events) do + 3.times.map do + jti, jwe = IrsAttemptsApi::EncryptedEventTokenBuilder.new( + event_type: :test_event, + session_id: 'test-session-id', + occurred_at: Time.zone.now, + event_metadata: {}, + ).build_event_token + IrsAttemptsApi::RedisClient.new.write_event(jti: jti, jwe: jwe) + [jti, jwe] + end + end + let(:existing_event_jtis) { existing_events.map(&:first) } + + describe '#create' do + it 'renders a 404 if disabled' do + allow(IdentityConfig.store).to receive(:irs_attempt_api_enabled).and_return(false) + + post :create, params: {} + + expect(response.status).to eq(404) + end + + it 'authenticates the client' do + request.headers['Authorization'] = auth_token # Missing Bearer prefix + + post :create, params: {} + + expect(response.status).to eq(401) + + request.headers['Authorization'] = 'garbage-fake-token-nobody-likes' + + post :create, params: {} + + expect(response.status).to eq(401) + end + + it 'allows events to be acknowledged' do + post :create, params: { ack: existing_event_jtis } + + expect(response).to be_ok + + expected_response = { + 'sets' => {}, + } + expect(JSON.parse(response.body)).to eq(expected_response) + expect(IrsAttemptsApi::RedisClient.new.read_events).to be_empty + expect(@analytics).to have_logged_event( + 'IRS Attempt API: Events submitted', + acknowledged_event_count: 3, + rendered_event_count: 0, + set_errors: nil, + ) + end + + it 'renders new events' do + post :create, params: {} + + expect(response).to be_ok + + expected_response = { + 'sets' => existing_events.to_h, + } + expect(JSON.parse(response.body)).to eq(expected_response) + expect(@analytics).to have_logged_event( + 'IRS Attempt API: Events submitted', + acknowledged_event_count: 0, + rendered_event_count: 3, + set_errors: nil, + ) + end + + it 'logs errors from the client' do + set_errors = { abc123: { description: 'it is b0rken' } } + + post :create, params: { setErrs: set_errors } + + expect(response).to be_ok + expect(@analytics).to have_logged_event( + 'IRS Attempt API: Events submitted', + acknowledged_event_count: 0, + rendered_event_count: 3, + set_errors: set_errors.to_json, + ) + end + + it 'respects the maxEvents param' do + post :create, params: { maxEvents: 2 } + + expect(response).to be_ok + expect(JSON.parse(response.body)['sets'].keys.length).to eq(2) + end + + it 'does not render more than the configured maximum event allowance' do + allow(IdentityConfig.store).to receive(:irs_attempt_api_event_count_max).and_return(2) + + post :create, params: { maxEvents: 5 } + + expect(response).to be_ok + expect(JSON.parse(response.body)['sets'].keys.length).to eq(2) + end + end +end diff --git a/spec/requests/irs_attempts_api_spec.rb b/spec/requests/irs_attempts_api_spec.rb new file mode 100644 index 00000000000..ac751a819be --- /dev/null +++ b/spec/requests/irs_attempts_api_spec.rb @@ -0,0 +1,54 @@ +require 'rails_helper' + +RSpec.describe 'IRS attempts API' do + before do + allow(IdentityConfig.store).to receive(:irs_attempt_api_enabled).and_return(true) + IrsAttemptsApi::RedisClient.clear_attempts! + events_to_acknowledge + events_to_render + end + + let(:events_to_acknowledge) do + 3.times.map do + jti, jwe = IrsAttemptsApi::EncryptedEventTokenBuilder.new( + event_type: :test_event, + session_id: 'test-session-id', + occurred_at: Time.zone.now, + event_metadata: {}, + ).build_event_token + IrsAttemptsApi::RedisClient.new.write_event(jti: jti, jwe: jwe) + [jti, jwe] + end + end + let(:events_to_render) do + 3.times.map do + jti, jwe = IrsAttemptsApi::EncryptedEventTokenBuilder.new( + event_type: :test_event, + session_id: 'test-session-id', + occurred_at: Time.zone.now, + event_metadata: {}, + ).build_event_token + IrsAttemptsApi::RedisClient.new.write_event(jti: jti, jwe: jwe) + [jti, jwe] + end + end + + it 'allows events to be acknowledged and renders new events' do + auth_token = IdentityConfig.store.irs_attempt_api_auth_tokens.first + request_body = { + ack: events_to_acknowledge.map(&:first), + } + request_headers = { + Authorization: "Bearer #{auth_token}", + } + + post '/api/irs_attempts_api/security_events', params: request_body, headers: request_headers + + expect(response.status).to eq(200) + + expected_response_body = { + 'sets' => events_to_render.to_h, + } + expect(JSON.parse(response.body)).to eq(expected_response_body) + end +end