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
15 changes: 15 additions & 0 deletions app/controllers/api/attempts/events_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ class EventsController < ApplicationController
prepend_before_action :skip_session_load
prepend_before_action :skip_session_expiration

skip_before_action :verify_authenticity_token
before_action :authenticate_client, only: :poll

def poll
head :method_not_allowed
end
Expand All @@ -19,6 +22,18 @@ def status
reason: :not_yet_implemented,
}
end

private

def authenticate_client
if AttemptsApi::RequestTokenValidator.new(request.authorization).invalid?
render json: { status: 401, description: 'Unauthorized' }, status: :unauthorized
end
end

def poll_params
params.permit(:maxEvents, acks: [])
end
end
end
end
82 changes: 82 additions & 0 deletions app/services/attempts_api/request_token_validator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# frozen_string_literal: true

module AttemptsApi
class RequestTokenValidator
include ActiveModel::Model

attr_reader :bearer, :issuer, :token

validates :token, :issuer, :bearer, presence: true
validates :bearer, comparison: { equal_to: 'Bearer' }
validate :config_data_exists
validate :service_provider_exists, if: :config_data_exists?
validate :valid_request_token?, if: :config_data_exists?

def initialize(auth_request_header)
@bearer, @issuer, @token = auth_request_header&.split(' ', 3)
end

private

def config_data_exists
return if config_data_exists?

errors.add(
:issuer,
:not_authorized,
message: 'Issuer is not authorized to use Attempts API',
)
end

def issuer_is_authorized
errors.add(
:issuer,
:not_authorized,
message: 'Issuer is not authorized to use Attempts API',
)
end

def service_provider_exists
return if service_provider.present?

errors.add(
:service_provider,
:not_authorized,
message: 'ServiceProvider does not exist',
)
end

def valid_request_token?
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Just for my own reference/note-taking, this is based on some of the previous implementation here

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

yes, apologies!

the big difference is we are hashing the token before saving it as config, so we don't have it stored in plaintext anywhere.

return if config_data[:tokens].any? do |valid_token|
scrypt_salt = cost + OpenSSL::Digest::SHA256.hexdigest(valid_token[:salt])
scrypted = SCrypt::Engine.hash_secret token, scrypt_salt, 32
hashed_req_token = SCrypt::Password.new(scrypted).digest
ActiveSupport::SecurityUtils.secure_compare(valid_token[:value], hashed_req_token)
end

errors.add(
:request_token,
:not_valid,
message: 'Request token is not valid',
)
end

def config_data
@config_data ||= IdentityConfig.store.allowed_attempts_providers.find do |config|
config[:issuer] == issuer
end
end

def config_data_exists?
config_data.present?
end

def cost
IdentityConfig.store.scrypt_cost
end

def service_provider
@service_provider ||= ServiceProvider.find_by(issuer:)
end
end
end
1 change: 1 addition & 0 deletions config/application.yml.default
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ acuant_sdk_initialization_endpoint: 'https://us.acas.acuant.net'
add_email_link_valid_for_hours: 24
address_identity_proofing_supported_country_codes: '["AS", "GU", "MP", "PR", "US", "VI"]'
all_redirect_uris_cache_duration_minutes: 2
allowed_attempts_providers: '[]'
allowed_ialmax_providers: '[]'
allowed_verified_within_providers: '[]'
asset_host: ''
Expand Down
3 changes: 0 additions & 3 deletions docs/attempts-api/openapi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,6 @@ paths:
$ref: './paths/poll.yml'
/api/attempts/status:
$ref: './paths/status.yml'
/api/attempts/verification:
$ref: './paths/verification.yml'


components:
schemas:
Expand Down
27 changes: 0 additions & 27 deletions docs/attempts-api/paths/verification.yml

This file was deleted.

1 change: 1 addition & 0 deletions lib/identity_config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ def self.store
config.add(:add_email_link_valid_for_hours, type: :integer)
config.add(:address_identity_proofing_supported_country_codes, type: :json)
config.add(:all_redirect_uris_cache_duration_minutes, type: :integer)
config.add(:allowed_attempts_providers, type: :json)
config.add(:allowed_ialmax_providers, type: :json)
config.add(:allowed_verified_within_providers, type: :json)
config.add(:asset_host, type: :string)
Expand Down
95 changes: 92 additions & 3 deletions spec/controllers/api/attempts/events_controller_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,41 @@
end

describe '#poll' do
let(:action) { post :poll }
let(:sp) { create(:service_provider) }
let(:issuer) { sp.issuer }
let(:payload) do
{
maxEvents: '1000',
acks: [
'acknowleded-jti-id-1',
'acknowleded-jti-id-2',
],
}
end

let(:token) { 'a-shared-secret' }
let(:salt) { SecureRandom.hex(32) }
let(:cost) { IdentityConfig.store.scrypt_cost }

let(:hashed_token) do
scrypt_salt = cost + OpenSSL::Digest::SHA256.hexdigest(salt)
scrypted = SCrypt::Engine.hash_secret token, scrypt_salt, 32
SCrypt::Password.new(scrypted).digest
end

let(:auth_header) { "Bearer #{issuer} #{token}" }

before do
request.headers['Authorization'] = auth_header
allow(IdentityConfig.store).to receive(:allowed_attempts_providers).and_return(
[{
issuer: sp.issuer,
tokens: [{ value: hashed_token, salt: }],
}],
)
end

let(:action) { post :poll, params: payload }

context 'when the Attempts API is not enabled' do
it 'returns 404 not found' do
Expand All @@ -19,8 +53,63 @@

context 'when the Attempts API is enabled' do
let(:enabled) { true }
it 'returns 405 method not allowed' do
expect(action.status).to eq(405)

context 'with a valid authorization header' do
it 'returns 405 method not allowed' do
expect(action.status).to eq(405)
end
end

context 'with an invalid authorization header' do
context 'with no Authorization header' do
let(:auth_header) { nil }

it 'returns a 401' do
expect(action.status).to eq 401
end
end

context 'when Authorization header is an empty string' do
let(:auth_header) { '' }

it 'returns a 401' do
expect(action.status).to eq 401
end
end

context 'without a Bearer token Authorization header' do
let(:auth_header) { "#{issuer} #{token}" }

it 'returns a 401' do
expect(action.status).to eq 401
end
end

context 'without a valid issuer' do
context 'an unknown issuer' do
let(:issuer) { 'random-issuer' }

it 'returns a 401' do
expect(action.status).to eq 401
end
end
end

context 'without a valid token' do
let(:auth_header) { "Bearer #{issuer}" }

it 'returns a 401' do
expect(action.status).to eq 401
end
end

context 'with a valid but not config token' do
let(:auth_header) { "Bearer #{issuer} not-shared-secret" }

it 'returns a 401' do
expect(action.status).to eq 401
end
end
end
end
end
Expand Down
95 changes: 95 additions & 0 deletions spec/services/attempts_api/request_token_validator_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
require 'rails_helper'

RSpec.describe AttemptsApi::RequestTokenValidator do
let(:sp) { create(:service_provider) }
let(:issuer) { sp.issuer }
let(:token) { 'a-shared-secret' }
let(:salt) { SecureRandom.hex(32) }
let(:cost) { IdentityConfig.store.scrypt_cost }

let(:hashed_token) do
scrypt_salt = cost + OpenSSL::Digest::SHA256.hexdigest(salt)
scrypted = SCrypt::Engine.hash_secret token, scrypt_salt, 32
SCrypt::Password.new(scrypted).digest
end

let(:auth_header) { "Bearer #{issuer} #{token}" }

before do
allow(IdentityConfig.store).to receive(:allowed_attempts_providers).and_return(
[{
issuer: sp.issuer,
tokens: [{ value: hashed_token, salt: }],
}],
)
end

subject { AttemptsApi::RequestTokenValidator.new(auth_header) }

describe 'validations' do
context 'with a valid auth header' do
it 'returns true' do
expect(subject.valid?).to be true
end
end

context 'with no auth header' do
let(:auth_header) { nil }

it 'returns false' do
expect(subject.valid?).to be false
end
end

context 'with an empty string for auth header' do
let(:auth_header) { '' }

it 'returns false' do
expect(subject.valid?).to be false
end
end

context 'without a Bearer token' do
let(:auth_header) { "#{sp.issuer} #{token}" }

it 'returns false' do
expect(subject.valid?).to be false
end
end

context 'without a valid issuer' do
context 'with an unknown issuer' do
let(:issuer) { 'unknown-issuer' }

it 'returns false' do
expect(subject.valid?).to be false
end
end

context 'with an issuer associated with an unauthorized sp' do
let(:unauth_sp) { create(:service_provider) }
let(:issuer) { unauth_sp.issuer }

it 'returns false' do
expect(subject.valid?).to be false
end
end
end

context 'without a token' do
let(:token) { nil }

it 'returns false' do
expect(subject.valid?).to be false
end
end

context 'with an invalid token' do
let(:auth_header) { "Bearer #{issuer} not-shared-secret" }

it 'returns false' do
expect(subject.valid?).to be false
end
end
end
end