From 544eee2cdda7d9b7a778df7ec460ef09c109e5d1 Mon Sep 17 00:00:00 2001 From: zendesk-mradmacher Date: Wed, 8 Oct 2025 16:38:21 +0200 Subject: [PATCH] add support for refresh tokens add dedicated unauthorized error allow token refreshing middleware to be enabled in configuration --- README.md | 58 +++++++ lib/zendesk_api.rb | 1 + lib/zendesk_api/client.rb | 6 +- lib/zendesk_api/configuration.rb | 19 +++ lib/zendesk_api/error.rb | 3 + lib/zendesk_api/middleware/request/retry.rb | 1 - .../middleware/response/raise_error.rb | 2 + .../middleware/response/token_refresher.rb | 27 ++++ lib/zendesk_api/token_refresher.rb | 59 ++++++++ .../response/token_refresher_spec.rb | 139 +++++++++++++++++ spec/core/token_refresher_spec.rb | 143 ++++++++++++++++++ 11 files changed, 456 insertions(+), 2 deletions(-) create mode 100644 lib/zendesk_api/middleware/response/token_refresher.rb create mode 100644 lib/zendesk_api/token_refresher.rb create mode 100644 spec/core/middleware/response/token_refresher_spec.rb create mode 100644 spec/core/token_refresher_spec.rb diff --git a/README.md b/README.md index 3646b809..361e7894 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,10 @@ client = ZendeskAPI::Client.new do |config| # More information on obtaining OAuth access tokens can be found here: # https://developer.zendesk.com/api-reference/introduction/security-and-auth/#oauth-access-token config.access_token = "your OAuth access token" + # You can configure token refreshing by adding the OAuth client ID, secret and refresh token: + config.client_id = "your OAuth client id" + config.client_secret = "your OAuth client secret" + config.refresh_token = "your OAuth refresh token" # Optional: @@ -101,6 +105,18 @@ client = ZendeskAPI::Client.new do |config| # Error codes when the request will be automatically retried. Defaults to 429, 503 config.retry_codes = [ 429 ] + + # When OAuth token refreshing is configured: + # - sets access and refresh token expiration times + config.access_token_expiration = 300 # time in seconds (between 5 minutes and 2 days: 300 to 172800) + config.refresh_token_expiration = 605800 # time in seconds (between 7 and 90 days: 605800 to 7776000) + # - set to true to automatically refresh tokens when an `ZendeskAPI::Errors::Unauthorized` error is raised; + # defaults to false, meaning tokens must be refreshed explicitly using `ZendeskAPI::TokenRefresher` + config.auto_refresh_tokens = true + # - when automatic token refreshing is enabled, a callback that is called whenever tokens are refreshed + config.refresh_tokens_callback = lambda do |access_token, refresh_token| + # saves tokens for further use + end end ``` @@ -163,6 +179,48 @@ zendesk_api_client_rb $ bundle console => true ``` +### OAuth Token Refreshing +To take advantage of token refreshing you need to configure the client first providing by minimum OAuth client ID and secret and access and refresh tokens. + +```ruby +users = client.users.per_page(3) +begin + # A request with an expired access token is made. + users.fetch! + # The request is rejected with 401 (Unauthorized) status code. +rescue ZendeskAPI::Error::Unauthorized + # Refresh tokens and store them securely + ZendeskAPI::TokenRefresher.new(client.config).refresh_token do |access_token, refresh_token| + # The access and refresh tokens are passed here so you could persist them for later use. + # The client's configuration is updated automatically. + end + # Issue the request again. + users.fetch! +end +``` + +When automatic tokens refreshing is enabled: +```ruby + config.auto_refresh_tokens = true + config.refresh_tokens_callback = lambda do |access_token, refresh_token| + # The access and refresh tokens are passed here so you could persist them for later use. + # The client's configuration is updated automatically. + end +``` +The above example could be changed to: +```ruby +users = client.users.per_page(3) +begin + # A request with an expired access token is made. + users.fetch! + # The request is rejected with 401 (Unauthorized) status code. +rescue ZendeskAPI::Error::Unauthorized + # Tokens are refreshed automatically. + # Issue the request again. + users.fetch! +end +``` + ### Pagination `ZendeskAPI::Collections` can be paginated: diff --git a/lib/zendesk_api.rb b/lib/zendesk_api.rb index c8ebe13d..8f68dd3c 100644 --- a/lib/zendesk_api.rb +++ b/lib/zendesk_api.rb @@ -6,3 +6,4 @@ module ZendeskAPI; end require_relative "zendesk_api/helpers" require_relative "zendesk_api/core_ext/inflection" require_relative "zendesk_api/client" +require_relative "zendesk_api/token_refresher" diff --git a/lib/zendesk_api/client.rb b/lib/zendesk_api/client.rb index 57e41808..11b85b56 100644 --- a/lib/zendesk_api/client.rb +++ b/lib/zendesk_api/client.rb @@ -18,6 +18,7 @@ require_relative "middleware/response/parse_iso_dates" require_relative "middleware/response/parse_json" require_relative "middleware/response/raise_error" +require_relative "middleware/response/token_refresher" require_relative "middleware/response/logger" require_relative "delegator" @@ -257,7 +258,10 @@ def add_warning_callback # See https://lostisland.github.io/faraday/middleware/authentication def set_authentication(builder, config) if config.access_token && !config.url_based_access_token - builder.request :authorization, "Bearer", config.access_token + # Upon refreshing the access token, the configuration is updated accordingly. + # Utilizing the proc here ensures that the token used is always valid. + builder.request :authorization, "Bearer", -> { config.access_token } + builder.use(ZendeskAPI::Middleware::Response::TokenRefresher, config) if config.auto_refresh_tokens elsif config.access_token builder.use ZendeskAPI::Middleware::Request::UrlBasedAccessToken, config.access_token else diff --git a/lib/zendesk_api/configuration.rb b/lib/zendesk_api/configuration.rb index bf8d27af..b430b6b8 100644 --- a/lib/zendesk_api/configuration.rb +++ b/lib/zendesk_api/configuration.rb @@ -34,8 +34,27 @@ class Configuration # @return [Boolean] Whether to allow non-HTTPS connections for development purposes. attr_accessor :allow_http + # Client ID and secret, together with the refresh token, are used to obtain a new access token, after the old expires + attr_accessor :client_id, :client_secret + # @return [String] OAuth2 access_token attr_accessor :access_token + # @return [String] OAuth2 refresh token used to obtain a new access token after the old expires + attr_accessor :refresh_token + + # @return [Integer] Time in seconds after the refreshed access token expires. + # Value between 5 minutes and 2 days (300 and 172800) + attr_accessor :access_token_expiration + # @return [Integer] Time in seconds after the refresh token, generated after access token refreshing, expires. + # Value between 7 and 90 days (604800 and 7776000) + attr_accessor :refresh_token_expiration + + # @return [Proc] A lambda that handles the response when the refresh_token is used to obtain a new access_token. + # This allows the access_token to be saved for re-use later. + attr_accessor :refresh_tokens_callback + # @return [Boolean] Whether to automatically refresh tokens when an unauthorized error happens for an OAuth request. + # The unauthorized error is still raised so the request could be retried with tokens refreshed. + attr_accessor :auto_refresh_tokens attr_accessor :url_based_access_token diff --git a/lib/zendesk_api/error.rb b/lib/zendesk_api/error.rb index 8ccc28c2..f73629b0 100644 --- a/lib/zendesk_api/error.rb +++ b/lib/zendesk_api/error.rb @@ -38,6 +38,9 @@ def generate_error_msg(response_body) end class NetworkError < ClientError; end + # The Unauthorized class inherits from NetworkError to maintain backward compatibility. + # In previous versions, a NetworkError was raised for HTTP 401 response codes. + class Unauthorized < NetworkError; end class RecordNotFound < ClientError; end class RateLimited < ClientError; end end diff --git a/lib/zendesk_api/middleware/request/retry.rb b/lib/zendesk_api/middleware/request/retry.rb index 206519f0..e95241c3 100644 --- a/lib/zendesk_api/middleware/request/retry.rb +++ b/lib/zendesk_api/middleware/request/retry.rb @@ -31,7 +31,6 @@ def call(env) end if exception_happened || @error_codes.include?(response.env[:status]) - if exception_happened seconds_left = DEFAULT_RETRY_AFTER.to_i @logger&.warn "An exception happened, waiting #{seconds_left} seconds... #{e}" diff --git a/lib/zendesk_api/middleware/response/raise_error.rb b/lib/zendesk_api/middleware/response/raise_error.rb index eebdc1ae..cf13793f 100644 --- a/lib/zendesk_api/middleware/response/raise_error.rb +++ b/lib/zendesk_api/middleware/response/raise_error.rb @@ -12,6 +12,8 @@ def call(env) def on_complete(env) case env[:status] + when 401 + raise Error::Unauthorized.new(env) when 404 raise Error::RecordNotFound.new(env) when 422, 413 diff --git a/lib/zendesk_api/middleware/response/token_refresher.rb b/lib/zendesk_api/middleware/response/token_refresher.rb new file mode 100644 index 00000000..3c6f949e --- /dev/null +++ b/lib/zendesk_api/middleware/response/token_refresher.rb @@ -0,0 +1,27 @@ +module ZendeskAPI + # @private + module Middleware + # @private + module Response + # This middleware is responsible for obtaining new access and refresh tokens + # when the current expires. + class TokenRefresher < Faraday::Middleware + ERROR_CODES = [401].freeze + + def initialize(app, config) + super(app) + @config = config + @refresh_tokens_callback = @config.refresh_tokens_callback.is_a?(Proc) ? @config.refresh_tokens_callback : ->(_, _) {} + end + + def on_complete(env) + return unless ERROR_CODES.include?(env[:status]) + + ZendeskAPI::TokenRefresher.new(@config).refresh_token do |access_token, refresh_token| + @refresh_tokens_callback.call(access_token, refresh_token) + end + end + end + end + end +end diff --git a/lib/zendesk_api/token_refresher.rb b/lib/zendesk_api/token_refresher.rb new file mode 100644 index 00000000..a1fc2c1b --- /dev/null +++ b/lib/zendesk_api/token_refresher.rb @@ -0,0 +1,59 @@ +module ZendeskAPI + # Obtains new OAuth access and refresh tokens. + class TokenRefresher + def initialize(config) + @config = config + end + + def valid_config? + return false unless @config.client_id + return false unless @config.client_secret + return false unless @config.refresh_token + + true + end + + def refresh_token + return unless valid_config? + + response = connection.post "/oauth/tokens" do |req| + req.body = { + grant_type: "refresh_token", + refresh_token: @config.refresh_token, + client_id: @config.client_id, + client_secret: @config.client_secret + }.tap do |params| + params[:expires_in] = @config.access_token_expiration if @config.access_token_expiration + params[:refresh_token_expires_in] = @config.refresh_token_expiration if @config.refresh_token_expiration + end + end + new_access_token = response.body["access_token"] + new_refresh_token = response.body["refresh_token"] + @config.access_token = new_access_token + @config.refresh_token = new_refresh_token + + yield new_access_token, new_refresh_token if block_given? + end + + private + + def connection + @connection ||= Faraday.new(faraday_options) do |builder| + builder.use ZendeskAPI::Middleware::Response::RaiseError + builder.use ZendeskAPI::Middleware::Response::Logger, @config.logger if @config.logger + builder.use ZendeskAPI::Middleware::Response::ParseJson + builder.use ZendeskAPI::Middleware::Response::SanitizeResponse + builder.use ZendeskAPI::Middleware::Request::EncodeJson + + adapter = @config.adapter || Faraday.default_adapter + builder.adapter(*adapter, &@config.adapter_proc) + end + end + + def faraday_options + { + url: @config.url + } + end + end +end diff --git a/spec/core/middleware/response/token_refresher_spec.rb b/spec/core/middleware/response/token_refresher_spec.rb new file mode 100644 index 00000000..4851a7b3 --- /dev/null +++ b/spec/core/middleware/response/token_refresher_spec.rb @@ -0,0 +1,139 @@ +require "core/spec_helper" + +describe ZendeskAPI::Middleware::Response::TokenRefresher do + let(:refresh_token_body) do + { + access_token: "newacc123", + refresh_token: "newref123", + token_type: "bearer", + scope: "read write", + expires_in: 300, + refresh_token_expires_in: 604800 + } + end + + before do + client.config.client_id = "client" + client.config.client_secret = "secret" + client.config.refresh_token = "ref123" + client.config.auto_refresh_tokens = true + end + + describe "with access token" do + before do + client.config.access_token = "acc123" + end + + describe "when unauthorized" do + before do + stub_request(:any, /whatever/).to_return( + status: 401, + body: "", + headers: {content_type: "application/json"} + ) + stub_request(:post, %r{/oauth/tokens}).to_return( + status: refresh_token_status, + body: refresh_token_body.to_json, + headers: {content_type: "application/json"} + ) + end + + describe "when refreshing token succeeds" do + let(:refresh_token_status) { 200 } + + it "refreshes token" do + expect { client.connection.get "/whatever" }.to raise_error(ZendeskAPI::Error::Unauthorized) + + expect(client.config.access_token).to eq "newacc123" + expect(client.config.refresh_token).to eq "newref123" + end + + it "calls refresh tokens callback" do + new_access_token = nil + new_refresh_token = nil + client.config.refresh_tokens_callback = lambda do |access_token, refresh_token| + new_access_token = access_token + new_refresh_token = refresh_token + end + expect { client.connection.get "/whatever" }.to raise_error(ZendeskAPI::Error::Unauthorized) + + expect(new_access_token).to eq "newacc123" + expect(new_refresh_token).to eq "newref123" + end + + it "is ok when refresh tokens callback is not configured" do + expect { client.connection.get "/whatever" }.to raise_error(ZendeskAPI::Error::Unauthorized) + expect(client.config.access_token).to eq "newacc123" + expect(client.config.refresh_token).to eq "newref123" + end + + it "raises unauthorized exception" do + expect { client.connection.get "/whatever" }.to raise_error(ZendeskAPI::Error::Unauthorized) + end + end + + describe "when refreshing token fails" do + let(:refresh_token_status) { 500 } + + it "does not update configuration" do + expect { client.connection.get "/whatever" }.to raise_error(ZendeskAPI::Error::NetworkError) + + expect(client.config.access_token).to eq "acc123" + expect(client.config.refresh_token).to eq "ref123" + end + end + + describe "when auto token refreshing is disabled" do + let(:refresh_token_status) { 200 } + + before do + client.config.auto_refresh_tokens = false + end + + it "does not refresh token" do + expect { client.connection.get "/whatever" }.to raise_error(ZendeskAPI::Error::Unauthorized) + + expect_any_instance_of(ZendeskAPI::TokenRefresher).to receive(:refresh_token).never + expect(client.config.access_token).to eq "acc123" + expect(client.config.refresh_token).to eq "ref123" + end + end + end + + describe "when status request is ok" do + before do + stub_request(:any, /whatever/).to_return( + status: 200, + body: "", + headers: {content_type: "application/json"} + ) + end + + it "does not refresh token" do + client.connection.get "/whatever" + + expect_any_instance_of(ZendeskAPI::TokenRefresher).to receive(:refresh_token).never + expect(client.config.access_token).to eq "acc123" + end + end + end + + describe "with other type of authorization" do + before do + client.config.username = "xyz" + client.config.password = "xyz" + + stub_request(:any, /whatever/).to_return( + status: 401, + body: "", + headers: {content_type: "application/json"} + ) + end + + it "does not refresh tokens" do + expect { client.connection.get "/whatever" }.to raise_error(ZendeskAPI::Error::Unauthorized) + + expect_any_instance_of(ZendeskAPI::TokenRefresher).to receive(:refresh_token).never + end + end +end diff --git a/spec/core/token_refresher_spec.rb b/spec/core/token_refresher_spec.rb new file mode 100644 index 00000000..dc1ac962 --- /dev/null +++ b/spec/core/token_refresher_spec.rb @@ -0,0 +1,143 @@ +require "core/spec_helper" + +describe ZendeskAPI::TokenRefresher do + let(:refresh_token_body) do + { + access_token: "newacc123", + refresh_token: "newref123", + token_type: "bearer", + scope: "read write", + expires_in: 300, + refresh_token_expires_in: 604800 + } + end + let(:subject) { ZendeskAPI::TokenRefresher } + + before do + client.config.client_id = "client" + client.config.client_secret = "secret" + client.config.refresh_token = "ref123" + client.config.access_token = "acc123" + end + + describe "when refreshing token succeeds" do + let(:refresh_token_status) { 200 } + + before do + stub_request(:post, %r{/oauth/tokens}).to_return( + status: refresh_token_status, + body: refresh_token_body.to_json, + headers: {content_type: "application/json"} + ) + end + + it "refreshes token" do + subject.new(client.config).refresh_token + + assert_requested :post, %r{oauth/tokens} + expect(client.config.access_token).to eq "newacc123" + expect(client.config.refresh_token).to eq "newref123" + end + + it "yields new tokens to provided block" do + new_access_token = nil + new_refresh_token = nil + subject.new(client.config).refresh_token do |access_token, refresh_token| + new_access_token = access_token + new_refresh_token = refresh_token + end + + expect(new_access_token).to eq "newacc123" + expect(new_refresh_token).to eq "newref123" + end + + it "does not include expiration params when not configured" do + subject.new(client.config).refresh_token + + assert_requested(:post, %r{oauth/tokens}) do |req| + expect(req.body).to_not match(/expires_in/) + expect(req.body).to_not match(/refresh_token_expires_in/) + end + end + + it "includes expiration params when configured" do + client.config.access_token_expiration = 300 + client.config.refresh_token_expiration = 604800 + subject.new(client.config).refresh_token + + assert_requested(:post, %r{oauth/tokens}) do |req| + expect(req.body).to match(/expires_in/) + expect(req.body).to match(/refresh_token_expires_in/) + end + end + end + + describe "with client id not configuration" do + before do + client.config.client_id = nil + end + + it "does not try to refresh token" do + block_called = false + subject.new(client.config).refresh_token { block_called = true } + + refute_requested :post, %r{/oauth/tokens} + expect(block_called).to be_falsey + expect(client.config.access_token).to eq "acc123" + expect(client.config.refresh_token).to eq "ref123" + end + end + + describe "with client secret not configuration" do + before do + client.config.client_secret = nil + end + + it "does not try to refresh token" do + block_called = false + subject.new(client.config).refresh_token { block_called = true } + + refute_requested :post, %r{/oauth/tokens} + expect(block_called).to be_falsey + expect(client.config.access_token).to eq "acc123" + expect(client.config.refresh_token).to eq "ref123" + end + end + + describe "with client secret not configuration" do + before do + client.config.refresh_token = nil + end + + it "does not try to refresh token" do + block_called = false + subject.new(client.config).refresh_token { block_called = true } + + refute_requested :post, %r{oauth/tokens} + expect(block_called).to be_falsey + expect(client.config.access_token).to eq "acc123" + end + end + + describe "when refreshing token fails" do + let(:refresh_token_status) { 500 } + + before do + stub_request(:post, %r{/oauth/tokens}).to_return( + status: refresh_token_status, + body: refresh_token_body.to_json, + headers: {content_type: "application/json"} + ) + end + + it "does not change token configuration" do + block_called = false + expect { subject.new(client.config).refresh_token { block_called = true } }.to raise_error(ZendeskAPI::Error::NetworkError) + + assert_requested :post, %r{oauth/tokens} + expect(block_called).to be_falsey + expect(client.config.access_token).to eq "acc123" + expect(client.config.refresh_token).to eq "ref123" + end + end +end