Skip to content
Open
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
58 changes: 58 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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
```

Expand Down Expand Up @@ -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:
Expand Down
1 change: 1 addition & 0 deletions lib/zendesk_api.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"
6 changes: 5 additions & 1 deletion lib/zendesk_api/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions lib/zendesk_api/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions lib/zendesk_api/error.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion lib/zendesk_api/middleware/request/retry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
2 changes: 2 additions & 0 deletions lib/zendesk_api/middleware/response/raise_error.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions lib/zendesk_api/middleware/response/token_refresher.rb
Original file line number Diff line number Diff line change
@@ -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
59 changes: 59 additions & 0 deletions lib/zendesk_api/token_refresher.rb
Original file line number Diff line number Diff line change
@@ -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
139 changes: 139 additions & 0 deletions spec/core/middleware/response/token_refresher_spec.rb
Original file line number Diff line number Diff line change
@@ -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
Loading