Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ module MicrosoftKiotaAbstractions
# Maintains a list of valid hosts and allows authentication providers to check whether
# a host is valid before authenticating a request
class AllowedHostsValidator
LOCALHOST_STRINGS = ['localhost', '::1', '[::1]', '127.0.0.1'].freeze

# creates a new AllocatedHostsValidator with provided values
def initialize(allowed_hosts)
self.allowed_hosts = allowed_hosts
Expand All @@ -24,15 +26,25 @@ def allowed_hosts=(val)

# checks whether the provided host is valid
def url_host_valid?(url)
return true if @allowed_hosts.empty?
return false if url.nil?

parsed_url = URI(url)
return false if parsed_url.host.nil?

return false unless parsed_url.is_a?(URI::HTTPS)
is_https = parsed_url.is_a?(URI::HTTPS)
is_http_localhost = parsed_url.is_a?(URI::HTTP) && localhost?(parsed_url)
return false unless is_https || is_http_localhost

return true if @allowed_hosts.empty?

host = parsed_url.host.downcase
return true if @allowed_hosts.key?(host)

stripped_host = host.delete_prefix('[').delete_suffix(']')
return true if @allowed_hosts.key?(stripped_host)

hostname = parsed_url.host.downcase
return true if @allowed_hosts.key? hostname
hostname = (parsed_url.hostname || host).downcase
return true if @allowed_hosts.key?(hostname)

@allowed_hosts.any? { |allowed_host, _| allowed_host.start_with?('.') && hostname.end_with?(allowed_host) }
rescue URI::InvalidURIError
Expand All @@ -41,5 +53,19 @@ def url_host_valid?(url)

# gets the list of valid hosts
attr_reader :allowed_hosts

private

def localhost?(parsed_url)
host = parsed_url.host
return false if host.nil?

return true if LOCALHOST_STRINGS.include?(host.downcase)

hostname = parsed_url.hostname
return false if hostname.nil?

LOCALHOST_STRINGS.include?(hostname.downcase)
end
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ def authenticate_request(request, additional_properties = {})
end
else
Fiber.new do
token = @access_token_provider.get_authorization_token(request.uri, additional_properties).resume
auth_fiber = @access_token_provider.get_authorization_token(request.uri, additional_properties)
token = auth_fiber.resume if auth_fiber
request.headers.add(AUTHORIZATION_HEADER_KEY, "Bearer #{token}") unless token.nil? || token.empty?
end
end
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# frozen_string_literal: true

require 'uri'
require 'microsoft_kiota_abstractions'
require_relative 'access_token_provider_mock'

RSpec.describe MicrosoftKiotaAbstractions::BaseBearerTokenAuthenticationProvider do
it 'creates a token authentication provider with existing authorization header' do
access_token_provider = AccessTokenProviderMock.new
token_provider = MicrosoftKiotaAbstractions::BaseBearerTokenAuthenticationProvider.new(access_token_provider)
expect(token_provider).not_to be nil
request_obj = MicrosoftKiotaAbstractions::RequestInformation.new
request_obj.headers.add(
MicrosoftKiotaAbstractions::BaseBearerTokenAuthenticationProvider::AUTHORIZATION_HEADER_KEY, 'SAMPLE'
)
token_provider.authenticate_request(request_obj).resume
expect(request_obj.headers.get(MicrosoftKiotaAbstractions::BaseBearerTokenAuthenticationProvider::AUTHORIZATION_HEADER_KEY).at(0)).to eq 'SAMPLE'
end

it 'creates a token authentication provider with mock access token' do
access_token_provider = AccessTokenProviderMock.new
token_provider = MicrosoftKiotaAbstractions::BaseBearerTokenAuthenticationProvider.new(access_token_provider)
expect(token_provider).not_to be nil
request_obj = MicrosoftKiotaAbstractions::RequestInformation.new
request_obj.uri = 'http://sample.com'
token_provider.authenticate_request(request_obj).resume
expect(request_obj.headers.get(MicrosoftKiotaAbstractions::BaseBearerTokenAuthenticationProvider::AUTHORIZATION_HEADER_KEY).at(0)).to eq "Bearer #{AccessTokenProviderMock::DUMMY_TOKEN}"
end

it 'safely handles nil token or nil fiber from access token provider' do
nil_provider = double('AccessTokenProvider', get_authorization_token: nil)
token_provider = MicrosoftKiotaAbstractions::BaseBearerTokenAuthenticationProvider.new(nil_provider)
request_obj = MicrosoftKiotaAbstractions::RequestInformation.new
request_obj.uri = 'https://example.com'
header_key = MicrosoftKiotaAbstractions::BaseBearerTokenAuthenticationProvider::AUTHORIZATION_HEADER_KEY
expect { token_provider.authenticate_request(request_obj).resume }.not_to raise_error
expect(request_obj.headers.get_all.key?(header_key)).to be(false)
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -21,32 +21,6 @@
token_provider.authenticate_request(nil).resume
end

it 'creates a token authentication provider with existing authorization header' do
access_token_provider = AccessTokenProviderMock.new
token_provider = MicrosoftKiotaAbstractions::BaseBearerTokenAuthenticationProvider.new(access_token_provider)
expect(token_provider).not_to be nil
request_obj = MicrosoftKiotaAbstractions::RequestInformation.new
request_obj.headers.add(
MicrosoftKiotaAbstractions::BaseBearerTokenAuthenticationProvider::AUTHORIZATION_HEADER_KEY, 'SAMPLE'
)
# Authentication does nothing besides returning an empty Fiber
token_provider.authenticate_request(request_obj).resume
# The request header should not have changed
expect(request_obj.headers.get(MicrosoftKiotaAbstractions::BaseBearerTokenAuthenticationProvider::AUTHORIZATION_HEADER_KEY).at(0)).to eq 'SAMPLE'
end

it 'creates a token authentication provider with mock access token' do
access_token_provider = AccessTokenProviderMock.new
token_provider = MicrosoftKiotaAbstractions::BaseBearerTokenAuthenticationProvider.new(access_token_provider)
expect(token_provider).not_to be nil
request_obj = MicrosoftKiotaAbstractions::RequestInformation.new
# We have to set a URI, otherwise the code will fail ("undefined method 'chars' for nil")
request_obj.uri = 'http://sample.com'
token_provider.authenticate_request(request_obj).resume
# The request header should now contain the "DUMMY_TOKEN" value:
expect(request_obj.headers.get(MicrosoftKiotaAbstractions::BaseBearerTokenAuthenticationProvider::AUTHORIZATION_HEADER_KEY).at(0)).to eq "Bearer #{AccessTokenProviderMock::DUMMY_TOKEN}"
end

it 'returns the raw URI when set via setter' do
request_obj = MicrosoftKiotaAbstractions::RequestInformation.new
request_obj.path_parameters['term'] = 'search'
Expand Down
23 changes: 23 additions & 0 deletions components/abstractions/spec/request_information_uri_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,27 @@ def uri_for(template, path_parameters = {}, query_parameters = {})
expect { validator.allowed_hosts = ['https://bad.com'] }.to raise_error(ArgumentError)
expect(validator.url_host_valid?('https://anything.com')).to be(false)
end

it 'validates host with https scheme and allows http for localhost' do
validator = described_class.new(['example.com', 'localhost'])
expect(validator.url_host_valid?('http://example.com')).to be(false)
expect(validator.url_host_valid?('http://localhost:8080')).to be(true)
expect(validator.url_host_valid?('https://example.com')).to be(true)
expect(validator.url_host_valid?('https://other.com')).to be(false)
expect(validator.url_host_valid?('http://other.com')).to be(false)
end

it 'validates IPv6 loopback hosts with both bracketed and unbracketed allowlist entries' do
bracketed_validator = described_class.new(['[::1]'])
expect(bracketed_validator.url_host_valid?('https://[::1]:8080/v1.0')).to be(true)
expect(bracketed_validator.url_host_valid?('http://[::1]:8080/v1.0')).to be(true)
expect(bracketed_validator.url_host_valid?('http://example.com')).to be(false)
expect(bracketed_validator.url_host_valid?('https://example.com')).to be(false)

unbracketed_validator = described_class.new(['::1'])
expect(unbracketed_validator.url_host_valid?('https://[::1]:8080/v1.0')).to be(true)
expect(unbracketed_validator.url_host_valid?('http://[::1]:8080/v1.0')).to be(true)
expect(unbracketed_validator.url_host_valid?('http://example.com')).to be(false)
expect(unbracketed_validator.url_host_valid?('https://example.com')).to be(false)
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
module MicrosoftKiotaAuthenticationOAuth
# Access Token Provider class implementation
class OAuthAccessTokenProvider
LOCALHOST_STRINGS = ['localhost', '::1', '[::1]', '127.0.0.1'].freeze

# This is the initializer for OAuthAccessTokenProvider.
# :params
# token_request_context: a instance of one of our token request context or a custom implementation
Expand Down Expand Up @@ -48,11 +50,24 @@ def initialize(token_request_context, allowed_hosts = [], scopes = [])
# additional_params: hash of symbols to string values, ie { response_mode: 'fragment', prompt: 'login' }
# default is empty hash
def get_authorization_token(uri, _additional_properties = {})
nil if !uri || !@host_validator.url_host_valid?(uri)
return Fiber.new { nil } if !uri || uri.to_s.empty?

begin
parsed_url = URI(uri)
rescue URI::InvalidURIError, ArgumentError
return Fiber.new { nil }
end

parsed_url = URI(uri)
return Fiber.new { nil } if parsed_url.nil? || parsed_url.host.nil?

raise StandardError, 'Only https is supported' if parsed_url.scheme != 'https'
https_version = parsed_url.dup
https_version.scheme = 'https'
return Fiber.new { nil } unless @host_validator.url_host_valid?(https_version.to_s)

is_localhost = localhost?(parsed_url)
is_https = parsed_url.scheme == 'https'
is_http_localhost = parsed_url.scheme == 'http' && is_localhost
raise StandardError, 'Only https is supported' unless is_https || is_http_localhost

@scopes << "#{parsed_url.scheme}://#{parsed_url.host}/.default" if @scopes.empty?
@token_request_context.initialize_scopes(@scopes)
Expand Down Expand Up @@ -83,5 +98,19 @@ def get_authorization_token(uri, _additional_properties = {})
protected

attr_writer :host_validator, :token_credential, :scopes, :cached_token

private

def localhost?(parsed_url)
host = parsed_url.host
return false if host.nil?

return true if LOCALHOST_STRINGS.include?(host.downcase)

hostname = parsed_url.hostname
return false if hostname.nil?

LOCALHOST_STRINGS.include?(hostname.downcase)
end
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# frozen_string_literal: true

require 'uri'
require 'microsoft_kiota_abstractions'
require_relative 'spec_helper'

RSpec.describe MicrosoftKiotaAuthenticationOAuth::OAuthAccessTokenProvider do
it 'returns a fiber resolving to nil when uri is nil or not in allowed hosts' do
context = MicrosoftKiotaAuthenticationOAuth::ClientCredentialContext.new(
'tenant_id', 'client_id', 'client_secret', {}
)
provider = MicrosoftKiotaAuthenticationOAuth::OAuthAccessTokenProvider.new(context, ['graph.microsoft.com'])

fiber_nil = provider.get_authorization_token(nil)
expect(fiber_nil).to be_a(Fiber)
expect(fiber_nil.resume).to be_nil

fiber_untrusted = provider.get_authorization_token('https://untrusted.com/v1.0')
expect(fiber_untrusted).to be_a(Fiber)
expect(fiber_untrusted.resume).to be_nil

fiber_http_untrusted = provider.get_authorization_token('http://untrusted.com/v1.0')
expect(fiber_http_untrusted).to be_a(Fiber)
expect(fiber_http_untrusted.resume).to be_nil

fiber_loopback_unapproved = provider.get_authorization_token('http://localhost:8080/v1.0')
expect(fiber_loopback_unapproved).to be_a(Fiber)
expect(fiber_loopback_unapproved.resume).to be_nil
end

it 'raises error for allowlisted external HTTP target' do
context = MicrosoftKiotaAuthenticationOAuth::ClientCredentialContext.new(
'tenant_id', 'client_id', 'client_secret', {}
)
provider = MicrosoftKiotaAuthenticationOAuth::OAuthAccessTokenProvider.new(context, ['graph.microsoft.com'])

expect do
provider.get_authorization_token('http://graph.microsoft.com/v1.0')
end.to raise_error(StandardError, 'Only https is supported')
end

it 'allows http on localhost and loopback addresses when allowed hosts is empty or includes loopback' do
context = MicrosoftKiotaAuthenticationOAuth::ClientCredentialContext.new(
'tenant_id', 'client_id', 'client_secret', {}
)
allow(context).to receive(:get_token).and_return(double('token', to_hash: {}, token: 'test_token'))

['localhost', '127.0.0.1', '[::1]'].each do |host|
url = "http://#{host}:8080/v1.0"

provider_empty = MicrosoftKiotaAuthenticationOAuth::OAuthAccessTokenProvider.new(context, [])
fiber_empty = provider_empty.get_authorization_token(url)
expect(fiber_empty).to be_a(Fiber)
expect(fiber_empty.resume).to eq('test_token')

[host, host.delete_prefix('[').delete_suffix(']')].uniq.each do |allowed_entry|
provider_allowlisted = MicrosoftKiotaAuthenticationOAuth::OAuthAccessTokenProvider.new(
context, [allowed_entry]
)
fiber_allowlisted = provider_allowlisted.get_authorization_token(url)
expect(fiber_allowlisted).to be_a(Fiber)
expect(fiber_allowlisted.resume).to eq('test_token')
end
end
end

it 'raises error for unsupported schemes even on localhost' do
context = MicrosoftKiotaAuthenticationOAuth::ClientCredentialContext.new(
'tenant_id', 'client_id', 'client_secret', {}
)
provider = MicrosoftKiotaAuthenticationOAuth::OAuthAccessTokenProvider.new(context, [])

['ftp://localhost:8080/v1.0', 'ws://localhost:8080/v1.0', '//localhost:8080/v1.0'].each do |url|
expect do
provider.get_authorization_token(url)
end.to raise_error(StandardError, 'Only https is supported')
end
end

it 'leaves request unauthenticated when host is not allowed via OAuthAuthenticationProvider' do
context = MicrosoftKiotaAuthenticationOAuth::ClientCredentialContext.new(
'tenant_id', 'client_id', 'client_secret', {}
)
auth_provider = MicrosoftKiotaAuthenticationOAuth::OAuthAuthenticationProvider.new(
context, ['graph.microsoft.com'], []
)
request = MicrosoftKiotaAbstractions::RequestInformation.new
request.uri = 'https://untrusted.com/v1.0'

expect { auth_provider.authenticate_request(request).resume }.not_to raise_error
expect(request.headers.get_all.key?('Authorization')).to be(false)
end

it 'authenticates http localhost request via OAuthAuthenticationProvider' do
context = MicrosoftKiotaAuthenticationOAuth::ClientCredentialContext.new(
'tenant_id', 'client_id', 'client_secret', {}
)
allow(context).to receive(:get_token).and_return(double('token', to_hash: {}, token: 'test_token'))

auth_provider = MicrosoftKiotaAuthenticationOAuth::OAuthAuthenticationProvider.new(
context, ['localhost'], []
)
request = MicrosoftKiotaAbstractions::RequestInformation.new
request.uri = 'http://localhost:8080/v1.0'

auth_provider.authenticate_request(request).resume
expect(request.headers.get('Authorization').first).to eq('Bearer test_token')
end
end
Loading