diff --git a/lib/dfe/analytics.rb b/lib/dfe/analytics.rb index 612d9b06..ab6eb15e 100644 --- a/lib/dfe/analytics.rb +++ b/lib/dfe/analytics.rb @@ -2,6 +2,7 @@ require 'request_store_rails' require 'i18n' +require 'httparty' require 'google/cloud/bigquery' require 'dfe/analytics/event_schema' require 'dfe/analytics/fields' diff --git a/lib/dfe/analytics/azure_federated_auth.rb b/lib/dfe/analytics/azure_federated_auth.rb index af28a567..effee5e8 100644 --- a/lib/dfe/analytics/azure_federated_auth.rb +++ b/lib/dfe/analytics/azure_federated_auth.rb @@ -8,6 +8,7 @@ module Analytics class AzureFederatedAuth DEFAULT_AZURE_SCOPE = 'api://AzureADTokenExchange/.default' DEFAULT_GCP_SCOPE = 'https://www.googleapis.com/auth/cloud-platform' + ACCESS_TOKEN_EXPIRY_LEEWAY = 10.seconds def self.gcp_client_credentials return @gcp_client_credentials if @gcp_client_credentials && !@gcp_client_credentials.expired? @@ -18,7 +19,9 @@ def self.gcp_client_credentials google_token, expiry_time = google_access_token(azure_google_exchange_token) - @gcp_client_credentials = Google::Auth::UserRefreshCredentials.new(access_token: google_token, expires_at: expiry_time) + expiry_time_with_leeway = expiry_time - ACCESS_TOKEN_EXPIRY_LEEWAY + + @gcp_client_credentials = Google::Auth::UserRefreshCredentials.new(access_token: google_token, expires_at: expiry_time_with_leeway) end def self.azure_access_token @@ -38,7 +41,7 @@ def self.azure_access_token Rails.logger.error error_message - raise AzureFederatedAuthError, error_message + raise Error, error_message end azure_token_response.parsed_response['access_token'] @@ -54,21 +57,21 @@ def self.azure_google_exchange_access_token(azure_token) subject_token_type: DfE::Analytics.config.google_cloud_credentials[:subject_token_type] } - exchange_token_response = http_client.post(DfE::Analytics.config.google_cloud_credentials[:token_url], body: request_body) + exchange_token_response = HTTParty.post(DfE::Analytics.config.google_cloud_credentials[:token_url], body: request_body) unless exchange_token_response.success? error_message = "Error calling google exchange token API: status: #{exchange_token_response.code} body: #{exchange_token_response.body}" Rails.logger.error error_message - raise AzureFederatedAuthError, error_message + raise Error, error_message end exchange_token_response.parsed_response['access_token'] end def self.google_access_token(azure_google_exchange_token) - google_token_response = http_client.post( + google_token_response = HTTParty.post( DfE::Analytics.config.google_cloud_credentials[:service_account_impersonation_url], headers: { 'Authorization' => "Bearer #{azure_google_exchange_token}" }, body: { scope: DfE::Analytics.config.gcp_scope } @@ -79,15 +82,15 @@ def self.google_access_token(azure_google_exchange_token) Rails.logger.error error_message - raise AzureFederatedAuthError, error_message + raise Error, error_message end parsed_response = google_token_response.parsed_response [parsed_response['accessToken'], parsed_response['expiryTime']] end - end - class AzureFederatedAuthError < StandardError; end + class Error < StandardError; end + end end end diff --git a/lib/dfe/analytics/testing/helpers.rb b/lib/dfe/analytics/testing/helpers.rb index 1477937e..c5585899 100644 --- a/lib/dfe/analytics/testing/helpers.rb +++ b/lib/dfe/analytics/testing/helpers.rb @@ -74,6 +74,224 @@ def stub_analytics_legacy_event_submission_with_insert_errors .to_return(status: 200, body: body.to_json, headers: { 'Content-Type' => 'application/json' }) end + def stub_azure_access_token_request + # will noop if called more than once + @stub_azure_access_token_request ||= DfE::Analytics.configure do |config| + config.azure_client_id = 'fake_az_client_id_1234' + config.azure_scope = 'fake_az_scope' + config.azure_token_path = 'fake_az_token_path' + config.google_cloud_credentials = { + credential_source: { + url: 'https://login.microsoftonline.com/fake-az-token-id/oauth2/v2.0/token' + } + } + end + + request_body = + 'grant_type=client_credentials&client_id=fake_az_client_id_1234&scope=fake_az_scope&' \ + 'client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer&' \ + 'client_assertion=fake_az_token' + + response_body = { + 'token_type' => 'Bearer', + 'expires_in' => 86_399, + 'ext_expires_in' => 86_399, + 'access_token' => 'fake_az_response_token' + }.to_json + + stub_request(:get, 'https://login.microsoftonline.com/fake-az-token-id/oauth2/v2.0/token') + .with( + body: request_body, + headers: { + 'Accept' => '*/*', + 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', + 'User-Agent' => 'Ruby' + } + ) + .to_return( + status: 200, + body: response_body, + headers: { + 'content-type' => ['application/json; charset=utf-8'] + } + ) + end + + def stub_azure_access_token_request_with_auth_error + # will noop if called more than once + @stub_azure_access_token_request_with_auth_error ||= DfE::Analytics.configure do |config| + config.azure_client_id = 'fake_az_client_id_1234' + config.azure_scope = 'fake_az_scope' + config.azure_token_path = 'fake_az_token_path' + config.google_cloud_credentials = { + credential_source: { + url: 'https://login.microsoftonline.com/fake-az-token-id/oauth2/v2.0/token' + } + } + end + + error_response_body = { + 'error' => 'unsupported_grant_type', + 'error_description' => 'AADSTS70003: The app requested an unsupported grant type ...', + 'error_codes' => [70_003], + 'timestamp' => '2024-03-18 19:55:40Z', + 'trace_id' => '0e58a943-a980-6d7e-89ba-c9740c572100', + 'correlation_id' => '84f1c2d2-5288-4879-a038-429c31193c9c' + }.to_json + + stub_request(:get, 'https://login.microsoftonline.com/fake-az-token-id/oauth2/v2.0/token') + .to_return( + status: 400, + body: error_response_body, + headers: { + 'content-type' => ['application/json; charset=utf-8'] + } + ) + end + + def stub_azure_google_exchange_access_token_request + # will noop if called more than once + @stub_azure_google_exchange_access_token_request ||= DfE::Analytics.configure do |config| + config.gcp_scope = 'fake_gcp_scope' + config.azure_token_path = 'fake_az_token_path' + config.google_cloud_credentials = { + audience: 'fake_gcp_aud', + subject_token_type: 'fake_sub_token_type', + token_url: 'https://sts.googleapis.com/v1/token' + } + end + + request_body = 'grant_type=urn%3Aietf%3Ap1Garams%3Aoauth%3Agrant-type%3Atoken-exchange' \ + '&audience=fake_gcp_aud&scope=fake_gcp_scope&requested_token_type=urn%3' \ + 'Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token&subject_token=' \ + 'fake_az_response_token&subject_token_type=fake_sub_token_type' + + response_body = { + token_type: 'Bearer', + expires_in: 3599, + issued_token_type: 'urn:ietf:params:oauth:token-type:access_token', + access_token: 'fake_az_gcp_exchange_token_response' + }.to_json + + stub_request(:post, 'https://sts.googleapis.com/v1/token') + .with( + body: request_body, + headers: { + 'Accept' => '*/*', + 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', + 'User-Agent' => 'Ruby' + } + ) + .to_return( + status: 200, + body: response_body, + headers: { + 'content-type' => ['application/json; charset=utf-8'] + } + ) + end + + def stub_azure_google_exchange_access_token_request_with_auth_error + # will noop if called more than once + @stub_azure_google_exchange_access_token_request_with_auth_error ||= DfE::Analytics.configure do |config| + config.gcp_scope = 'fake_gcp_scope' + config.azure_token_path = 'fake_az_token_path' + config.google_cloud_credentials = { + audience: 'fake_gcp_aud', + subject_token_type: 'fake_sub_token_type', + token_url: 'https://sts.googleapis.com/v1/token' + } + end + + error_response_body = { + error: 'invalid_grant', + error_description: 'Unable to parse the ID Token.' + }.to_json + + stub_request(:post, 'https://sts.googleapis.com/v1/token') + .to_return( + status: 400, + body: error_response_body, + headers: { + 'content-type' => ['application/json; charset=utf-8'] + } + ) + end + + def stub_google_access_token_request + # will noop if called more than once + @stub_google_access_token_request ||= DfE::Analytics.configure do |config| + config.gcp_scope = 'fake_gcp_scope' + config.azure_token_path = 'fake_az_token_path' + config.google_cloud_credentials = { + service_account_impersonation_url: 'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/cip-gcp-spike@my_project.iam.gserviceaccount.com:generateAccessToken' + } + end + + request_body = 'scope=fake_gcp_scope' + + response_body = { + expiryTime: '2024-03-09T14:38:02Z', + accessToken: 'fake_google_response_token' + }.to_json + + stub_request( + :post, + 'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/cip-gcp-spike@my_project.iam.gserviceaccount.com:generateAccessToken' + ).with( + body: request_body, + headers: { + 'Accept' => '*/*', + 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', + 'User-Agent' => 'Ruby' + } + ).to_return( + status: 200, + body: response_body, + headers: { + 'content-type' => ['application/json; charset=utf-8'] + } + ) + end + + def stub_google_access_token_request_with_auth_error + # will noop if called more than once + @stub_google_access_token_request_with_auth_error ||= DfE::Analytics.configure do |config| + config.gcp_scope = 'fake_gcp_scope' + config.azure_token_path = 'fake_az_token_path' + config.google_cloud_credentials = { + service_account_impersonation_url: 'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/cip-gcp-spike@my_project.iam.gserviceaccount.com:generateAccessToken' + } + end + + error_response_body = { + error: { + code: 401, + message: 'Request had invalid authentication credentials. Expected OAuth 2 access token, login cookie or other valid authentication credential. See https://developers.google.com/identity/sign-in/web/devconsole-project.', + status: 'UNAUTHENTICATED', + details: [{ + '@type': 'type.googleapis.com/google.rpc.ErrorInfo', + reason: 'ACCESS_TOKEN_TYPE_UNSUPPORTED', + metadata: { + service: 'iamcredentials.googleapis.com', + method: 'google.iam.credentials.v1.IAMCredentials.GenerateAccessToken' + } + }] + } + }.to_json + + stub_request( + :post, + 'https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/cip-gcp-spike@my_project.iam.gserviceaccount.com:generateAccessToken' + ).to_return( + status: 401, + body: error_response_body, + headers: { + 'content-type' => ['application/json; charset=utf-8'] + } + ) + end + def with_analytics_config(options) old_config = DfE::Analytics.config.dup DfE::Analytics.configure do |config| diff --git a/spec/dfe/analytics/azure_federated_auth_spec.rb b/spec/dfe/analytics/azure_federated_auth_spec.rb new file mode 100644 index 00000000..85b47966 --- /dev/null +++ b/spec/dfe/analytics/azure_federated_auth_spec.rb @@ -0,0 +1,167 @@ +# frozen_string_literal: true + +RSpec.describe DfE::Analytics::AzureFederatedAuth do + before(:each) do + allow(DfE::Analytics.config).to receive(:azure_federated_auth).and_return(true) + + DfE::Analytics::Testing.webmock! + end + + let(:azure_access_token) { 'fake_az_response_token' } + let(:azure_google_exchange_access_token) { 'fake_az_gcp_exchange_token_response' } + let(:google_access_token) { 'fake_google_response_token' } + let(:google_access_token_expiry_time) { '2024-03-09T14:38:02Z' } + + describe '#azure_access_token' do + before do + allow(File).to receive(:read).with('fake_az_token_path').and_return('fake_az_token') + end + + context 'when azure access token endpoint returns OK response' do + it 'returns the access token' do + stub_azure_access_token_request + + expect(described_class.azure_access_token).to eq(azure_access_token) + end + end + + context 'when azure access token endpoint returns an error response' do + it 'raises the expected error' do + stub_azure_access_token_request_with_auth_error + + expected_err_msg = /Error calling azure token API: status: 400/ + + expect(Rails.logger).to receive(:error).with(expected_err_msg) + + expect { described_class.azure_access_token } + .to raise_error(DfE::Analytics::AzureFederatedAuth::Error, expected_err_msg) + end + end + end + + describe '#azure_google_exchange_access_token' do + context 'when google exchange access token endpoint returns OK response' do + it 'returns the access token' do + stub_azure_google_exchange_access_token_request + + expect(described_class.azure_google_exchange_access_token(azure_access_token)) + .to eq(azure_google_exchange_access_token) + end + end + + context 'when google exchange access token endpoint returns an error response' do + it 'raises the expected error' do + stub_azure_google_exchange_access_token_request_with_auth_error + + expected_err_msg = /Error calling google exchange token API: status: 400/ + + expect(Rails.logger).to receive(:error).with(expected_err_msg) + + expect { described_class.azure_google_exchange_access_token(azure_access_token) } + .to raise_error(DfE::Analytics::AzureFederatedAuth::Error, expected_err_msg) + end + end + end + + describe '#google_access_token' do + context 'when google access token endpoint returns OK response' do + it 'returns the access token' do + stub_google_access_token_request + + expect(described_class.google_access_token(azure_google_exchange_access_token)) + .to eq([google_access_token, google_access_token_expiry_time]) + end + end + + context 'when google access token endpoint returns an error response' do + it 'raises the expected error' do + stub_google_access_token_request_with_auth_error + + expected_err_msg = /Error calling google token API: status: 401/ + + expect(Rails.logger).to receive(:error).with(expected_err_msg) + + expect { described_class.google_access_token(azure_google_exchange_access_token) } + .to raise_error(DfE::Analytics::AzureFederatedAuth::Error, expected_err_msg) + end + end + end + + describe '#gcp_client_credentials' do + let(:future_expiry_time) { Time.now + 1.hour } + + before do + allow(described_class).to receive(:azure_access_token).and_return(azure_access_token) + + allow(described_class) + .to receive(:azure_google_exchange_access_token) + .with(azure_access_token).and_return(azure_google_exchange_access_token) + + allow(described_class) + .to receive(:google_access_token) + .with(azure_google_exchange_access_token).and_return([google_access_token, future_expiry_time]) + end + + it 'returns the expected client credentials' do + expect(described_class.gcp_client_credentials).to be_an_instance_of(Google::Auth::UserRefreshCredentials) + expect(described_class.gcp_client_credentials.access_token).to eq(google_access_token) + expect(described_class.gcp_client_credentials.expires_at) + .to be_within(DfE::Analytics::AzureFederatedAuth::ACCESS_TOKEN_EXPIRY_LEEWAY).of(future_expiry_time) + end + + context 'token expiry' do + context 'when expiry is in the future' do + it 'calls token APIs once only on mutiple calls to get access token' do + expect(described_class) + .to receive(:azure_access_token) + .and_return(azure_access_token) + .once + + expect(described_class) + .to receive(:azure_google_exchange_access_token) + .with(azure_access_token) + .and_return(azure_google_exchange_access_token) + .once + + expect(described_class) + .to receive(:google_access_token) + .with(azure_google_exchange_access_token) + .and_return([google_access_token, future_expiry_time]) + .once + + 5.times do + expect(described_class.gcp_client_credentials.access_token).to eq(google_access_token) + end + end + end + + context 'when the token expires on every call' do + it 'calls token APIs everytime there is a call to get access token' do + expect(described_class) + .to receive(:azure_access_token) + .and_return(azure_access_token) + .exactly(5) + .times + + expect(described_class) + .to receive(:azure_google_exchange_access_token) + .with(azure_access_token) + .and_return(azure_google_exchange_access_token) + .exactly(5) + .times + + expect(described_class) + .to receive(:google_access_token) + .with(azure_google_exchange_access_token) + .and_return([google_access_token, Time.now]) + .exactly(5) + .times + + 5.times do + expect(described_class.gcp_client_credentials.access_token).to eq(google_access_token) + end + end + end + end + end +end diff --git a/spec/dfe/analytics/big_query_api_spec.rb b/spec/dfe/analytics/big_query_api_spec.rb index 8c271f48..ed64e6e8 100644 --- a/spec/dfe/analytics/big_query_api_spec.rb +++ b/spec/dfe/analytics/big_query_api_spec.rb @@ -31,7 +31,7 @@ end end - context 'when authorization endoint returns OK response' do + context 'when authorization endpoint returns OK response' do it 'calls the expected big query apis' do with_analytics_config(test_dummy_config) do expect(described_class.events_client).to eq(events_client) diff --git a/spec/dfe/analytics/big_query_legacy_api_spec.rb b/spec/dfe/analytics/big_query_legacy_api_spec.rb index f52fea72..ece7d551 100644 --- a/spec/dfe/analytics/big_query_legacy_api_spec.rb +++ b/spec/dfe/analytics/big_query_legacy_api_spec.rb @@ -24,7 +24,7 @@ end end - context 'when authorization endoint returns OK response' do + context 'when authorization endpoint returns OK response' do let(:events_client) { double(:events_client) } before do diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 4db99ac2..997028db 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -64,6 +64,7 @@ def name; end DfE::Analytics.instance_variable_set(:@entity_model_mapping, nil) DfE::Analytics::BigQueryLegacyApi.instance_variable_set(:@events_client, nil) DfE::Analytics::BigQueryApi.instance_variable_set(:@events_client, nil) + DfE::Analytics::AzureFederatedAuth.instance_variable_set(:@gcp_client_credentials, nil) end config.expect_with :rspec do |c|