From 57b6c4255b5eb3e1ed299bab91cbca1fbe47a14a Mon Sep 17 00:00:00 2001 From: Peter Cai <222655+pcai@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:45:23 -0400 Subject: [PATCH] Add OAuth bearer-token authentication support Reimplement WinRb/Viewpoint#295 (oauth support by @fliebe92), which had gone conflicted with main. EWSClient.new and Viewpoint::EWS::Connection.new now take an auth hash (endpoint:, type:, user:, password:, token:) instead of positional endpoint, username, password arguments. With type: 'oauth' and a token:, requests carry an Authorization: Bearer header and the basic-auth set_auth handshake is skipped; with type: 'basic' behavior is unchanged. Also fixes a latent bug in the original PR: it called @auth_token.present?, which requires ActiveSupport. The token check is now plain Ruby. Update specs for the new signature, add OAuth header specs, and refresh the README setup docs. --- CHANGELOG.md | 1 + README.md | 7 +++++- lib/ews/connection.rb | 20 ++++++++++++--- lib/ews/ews_client.rb | 38 +++++++++++++++++++---------- spec/ews/connection_spec.rb | 38 ++++++++++++++++++++++++++++- spec/ews/ews_client_spec.rb | 20 ++++++++++++++- spec/unit/mailbox_accessors_spec.rb | 2 +- spec/unit/meeting_accessors_spec.rb | 2 +- 8 files changed, 107 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ec9f995..6933a351 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/README.md b/README.md index a83aff34..9113065c 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/lib/ews/connection.rb b/lib/ews/connection.rb index c1bc9c07..3e442f9b 100644 --- a/lib/ews/connection.rb +++ b/lib/ews/connection.rb @@ -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:///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 @@ -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) @@ -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) @@ -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 diff --git a/lib/ews/ews_client.rb b/lib/ews/ews_client.rb index 479b131c..03aaf9c6 100644 --- a/lib/ews/ews_client.rb +++ b/lib/ews/ews_client.rb @@ -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 diff --git a/spec/ews/connection_spec.rb b/spec/ews/connection_spec.rb index 48664c6e..92e62f8f 100644 --- a/spec/ews/connection_spec.rb +++ b/spec/ews/connection_spec.rb @@ -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 @@ -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('') + 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 diff --git a/spec/ews/ews_client_spec.rb b/spec/ews/ews_client_spec.rb index b542adec..75a0e74a 100644 --- a/spec/ews/ews_client_spec.rb +++ b/spec/ews/ews_client_spec.rb @@ -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' @@ -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 diff --git a/spec/unit/mailbox_accessors_spec.rb b/spec/unit/mailbox_accessors_spec.rb index ca60fbbc..e872bd33 100644 --- a/spec/unit/mailbox_accessors_spec.rb +++ b/spec/unit/mailbox_accessors_spec.rb @@ -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 " diff --git a/spec/unit/meeting_accessors_spec.rb b/spec/unit/meeting_accessors_spec.rb index fc616e98..9090cda9 100644 --- a/spec/unit/meeting_accessors_spec.rb +++ b/spec/unit/meeting_accessors_spec.rb @@ -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 {