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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## [Unreleased]

* Replace the `logging` gem with stdlib `logger`
* `EWSClient.new` and `Viewpoint::EWS::Connection.new` now take an `auth` hash (`endpoint:`, `type:`, `user:`, `password:`, `token:`) instead of positional `endpoint, username, password` arguments; OAuth bearer-token authentication is supported via `type: 'oauth'` with a `token:`, which is sent as an `Authorization: Bearer` header

## 1.2.0 (2025-10-28)

Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,12 @@ endpoint = 'https://example.com/ews/Exchange.asmx'
user = 'username'
pass = 'password'

cli = Viewpoint::EWSClient.new endpoint, user, pass
# Basic authentication
cli = Viewpoint::EWSClient.new({ endpoint: endpoint, type: 'basic', user: user, password: pass })

# OAuth bearer-token authentication
token = 'your-oauth-bearer-token'
cli = Viewpoint::EWSClient.new({ endpoint: endpoint, type: 'oauth', token: token })
```

There are also various options you can pass to EWSClient.
Expand Down
20 changes: 17 additions & 3 deletions lib/ews/connection.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,13 @@ class Connection

SUPPORTED_HTTPCLIENT_OPTS = %i[agent_name default_header].freeze

# @param [String] endpoint the URL of the web service.
# @param [Hash] auth authentication details
# @option auth [String] :endpoint the URL of the web service.
# @example https://<site>/ews/Exchange.asmx
# @option auth [String] :type the authentication type: 'basic' or 'oauth'
# @option auth [String] :user the user to authenticate as (basic auth)
# @option auth [String] :password the user password (basic auth)
# @option auth [String] :token the OAuth bearer token (oauth auth)
# @param [Hash] opts Misc config options (mostly for development)
# @option opts [Fixnum] :ssl_verify_mode
# @option opts [Fixnum] :receive_timeout override the default receive timeout
Expand All @@ -39,9 +44,12 @@ class Connection
# @option opts [OpenSSL::X509::Store] :cert_store a custom cert store
# @option opts [Array] :trust_ca an array of hashed dir paths or a file
# @option opts [String] :user_agent the http user agent to use in all requests
def initialize(endpoint, opts = {})
def initialize(auth, opts = {})
@log = Viewpoint::EWS.root_logger

@auth_type = auth[:type]
@auth_token = auth[:token]

httpclient_opts = opts.slice(*SUPPORTED_HTTPCLIENT_OPTS)
@httpcli = HTTPClient.new(**httpclient_opts)

Expand All @@ -64,7 +72,7 @@ def initialize(endpoint, opts = {})
@httpcli.keep_alive_timeout = 60
@httpcli.receive_timeout = opts[:receive_timeout] if opts[:receive_timeout]
@httpcli.connect_timeout = opts[:connect_timeout] if opts[:connect_timeout]
@endpoint = endpoint
@endpoint = auth[:endpoint]
end

def set_auth(user, pass)
Expand Down Expand Up @@ -111,11 +119,17 @@ def get
# the response.
def post(xmldoc)
headers = { 'Content-Type' => 'text/xml' }
headers['Authorization'] = "Bearer #{@auth_token}" if oauth_token?
check_response(@httpcli.post(@endpoint, xmldoc, headers))
end

private

# True when a non-empty OAuth bearer token was supplied.
def oauth_token?
@auth_type == 'oauth' && @auth_token.is_a?(String) && !@auth_token.empty?
end

def check_response(resp)
case resp.status
when 200
Expand Down
38 changes: 25 additions & 13 deletions lib/ews/ews_client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,27 +32,39 @@ class EWSClient
attr_reader :ews, :endpoint, :username

# Initialize the EWSClient instance.
# @param [String] endpoint The EWS endpoint we will be connecting to
# @param [String] user The user to authenticate as. If you are using
# NTLM or Negotiate authentication you do not need to pass this parameter.
# @param [String] pass The user password. If you are using NTLM or
# Negotiate authentication you do not need to pass this parameter.
# @param [Hash] auth authentication details
# @option auth [String] :endpoint The EWS endpoint we will be connecting to
# @option auth [String] :type the authentication type: 'basic' or 'oauth'
# @option auth [String] :user The user to authenticate as (basic auth). If
# you are using NTLM or Negotiate authentication you do not need to pass
# this parameter.
# @option auth [String] :password The user password (basic auth). If you are
# using NTLM or Negotiate authentication you do not need to pass this
# parameter.
# @option auth [String] :token The OAuth bearer token (oauth auth)
# @param [Hash] opts Various options to pass to the backends
# @option opts [String] :server_version The Exchange server version to
# target. See the VERSION_* constants in
# Viewpoint::EWS::SOAP::ExchangeWebService.
# @option opts [Object] :http_class specify an alternate HTTP connection class.
# @option opts [Hash] :http_opts options to pass to the connection
def initialize(endpoint, username, password, opts = {})
def initialize(auth, opts = {})
# dup all. @see ticket https://github.com/zenchild/Viewpoint/issues/68
@endpoint = endpoint.dup
@username = username.dup
password = password.dup
opts = opts.dup
auth = auth.dup
opts = opts.dup

@auth_type = auth[:type]
@auth_token = auth[:token]

@endpoint = auth[:endpoint]
@username = auth[:user]
password = @auth_type == 'basic' ? auth[:password] : nil

http_klass = opts[:http_class] || Viewpoint::EWS::Connection
con = http_klass.new(endpoint, opts[:http_opts] || {})
con.set_auth @username, password
@ews = SOAP::ExchangeWebService.new(con, opts)
connection = http_klass.new(auth, opts[:http_opts] || {})
connection.set_auth(@username, password) unless password.nil?

@ews = SOAP::ExchangeWebService.new(connection, opts)
end

# @param deepen [Boolean] true to autodeepen, false otherwise
Expand Down
38 changes: 37 additions & 1 deletion spec/ews/connection_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
let(:endpoint) { 'https://example.com/ews/Exchange.asmx' }

def ssl_config_for(opts)
described_class.new(endpoint, opts).instance_variable_get(:@httpcli).ssl_config
described_class.new({ endpoint: endpoint, type: 'basic' }, opts).instance_variable_get(:@httpcli).ssl_config
end

describe ':cert_store option' do
Expand All @@ -28,4 +28,40 @@ def ssl_config_for(opts)
expect(config.cert_store).not_to be('not-a-store')
end
end

describe 'oauth bearer token' do
let(:ok_response) { double('response', status: 200, body: 'ok') }

def posted_headers(auth)
connection = described_class.new(auth)
httpcli = connection.instance_variable_get(:@httpcli)
captured = nil
allow(httpcli).to receive(:post) do |_url, _body, headers|
captured = headers
ok_response
end
connection.post('<xml/>')
captured
end

it 'sends the Bearer token in the Authorization header' do
headers = posted_headers({ endpoint: endpoint, type: 'oauth', token: 'sekret' })
expect(headers['Authorization']).to eq('Bearer sekret')
end

it 'omits the Authorization header when no token is given' do
headers = posted_headers({ endpoint: endpoint, type: 'oauth' })
expect(headers).not_to have_key('Authorization')
end

it 'omits the Authorization header for basic auth' do
headers = posted_headers({ endpoint: endpoint, type: 'basic', user: 'u', password: 'p' })
expect(headers).not_to have_key('Authorization')
end

it 'still sends the Content-Type header' do
headers = posted_headers({ endpoint: endpoint, type: 'oauth', token: 'sekret' })
expect(headers['Content-Type']).to eq('text/xml')
end
end
end
20 changes: 19 additions & 1 deletion spec/ews/ews_client_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@

describe Viewpoint::EWSClient do
describe '#set_auto_deepen' do
let(:client) { described_class.new 'http://www.example.com', 'test', 'test' }
let(:client) do
described_class.new({ endpoint: 'http://www.example.com', type: 'basic', user: 'test', password: 'test' })
end

it 'sets autodeepen to true on the web service' do
ews = double 'ews'
Expand All @@ -21,4 +23,20 @@
client.set_auto_deepen false
end
end

describe 'oauth authentication' do
it 'does not call set_auth when the auth type is oauth' do
connection = double('connection').as_null_object
allow(Viewpoint::EWS::Connection).to receive(:new).and_return(connection)
expect(connection).not_to receive(:set_auth)
described_class.new({ endpoint: 'http://www.example.com', type: 'oauth', token: 'sekret' })
end

it 'calls set_auth for basic auth' do
connection = double('connection').as_null_object
allow(Viewpoint::EWS::Connection).to receive(:new).and_return(connection)
expect(connection).to receive(:set_auth).with('test', 'test')
described_class.new({ endpoint: 'http://www.example.com', type: 'basic', user: 'test', password: 'test' })
end
end
end
2 changes: 1 addition & 1 deletion spec/unit/mailbox_accessors_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
require_relative '../spec_helper'

describe Viewpoint::EWS::MailboxAccessors do
let(:ecli) { Viewpoint::EWSClient.new('dontcare', 'dontcare', 'dontcare') }
let(:ecli) { Viewpoint::EWSClient.new({ endpoint: 'dontcare', type: 'basic', user: 'dontcare', password: 'dontcare' }) }
let(:recipients) { ['anyrecipient'] }
let(:timezone_request) do
"<t:TimeZone>
Expand Down
2 changes: 1 addition & 1 deletion spec/unit/meeting_accessors_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
require_relative '../spec_helper'

describe Viewpoint::EWS::MeetingAccessors do
let(:ecli) { Viewpoint::EWSClient.new('dontcare', 'dontcare', 'dontcare') }
let(:ecli) { Viewpoint::EWSClient.new({ endpoint: 'dontcare', type: 'basic', user: 'dontcare', password: 'dontcare' }) }

let(:default_opts) do
{
Expand Down