Skip to content

Commit

Permalink
Add spec for DfE::Analytics::AzureFederatedAuth class
Browse files Browse the repository at this point in the history
  • Loading branch information
asatwal committed Mar 20, 2024
1 parent a548d3f commit b7b8206
Show file tree
Hide file tree
Showing 7 changed files with 400 additions and 10 deletions.
1 change: 1 addition & 0 deletions lib/dfe/analytics.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
19 changes: 11 additions & 8 deletions lib/dfe/analytics/azure_federated_auth.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand All @@ -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
Expand All @@ -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']
Expand All @@ -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 }
Expand All @@ -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
218 changes: 218 additions & 0 deletions lib/dfe/analytics/testing/helpers.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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|
Expand Down
Loading

0 comments on commit b7b8206

Please sign in to comment.