Skip to content
Merged
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
76 changes: 76 additions & 0 deletions app/controllers/api/irs_attempts_api_controller.rb
Original file line number Diff line number Diff line change
@@ -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
20 changes: 20 additions & 0 deletions app/services/analytics_events.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

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

the logs are JSON, to save ourselves a JSON parse later, should this just be the JSON array or hash or whatever?

Also is this accepting data wholesale from partners into our logs? I'm a little wary of giving partners freeform access to write to our logs because PII may end up there

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hmm, yeah I hadn't considered PII but was nervous about just dropping stuff in here. Not super sure how to approach it cause I imagine we do want to be able to see these errors.

# 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)
Expand Down
3 changes: 2 additions & 1 deletion app/services/irs_attempts_api/redis_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions app/services/irs_attempts_api/tracker.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions config/application.yml.default
Original file line number Diff line number Diff line change
Expand Up @@ -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 #####################################################
Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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|
Expand Down
3 changes: 3 additions & 0 deletions lib/identity_config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
120 changes: 120 additions & 0 deletions spec/controllers/api/irs_attempts_api_controller_spec.rb
Original file line number Diff line number Diff line change
@@ -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
54 changes: 54 additions & 0 deletions spec/requests/irs_attempts_api_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Comment on lines 7 to 11
Copy link
Contributor

Choose a reason for hiding this comment

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

if we switch the let to let!( we can let Rspec eager load these and simplify the before block:

Suggested change
events_to_acknowledge
events_to_render
end
let(:events_to_acknowledge) do
end
let!(:events_to_acknowledge) do

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Okay, so this gets tricky because of the .clear_attempts! call in the before action. That runs after the let! block so the events get created and then immediately destroyed 😭

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