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
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ 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 refreshening 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"
# Optionally you can set access and refresh token expiration:
config.access_token_expiration = 300 # time in seconds between 5 minutes and 2 days (300 and 172800)
config.refresh_token_expiration = 605800 # time in seconds between 7 and 90 days (605800 and 7776000)

# Optional:

Expand Down Expand Up @@ -164,6 +171,26 @@ 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
```

### 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 'zendesk_api/helpers'
require 'zendesk_api/core_ext/inflection'
require 'zendesk_api/client'
require 'zendesk_api/token_refresher'
7 changes: 6 additions & 1 deletion lib/zendesk_api/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
require 'zendesk_api/middleware/response/parse_iso_dates'
require 'zendesk_api/middleware/response/parse_json'
require 'zendesk_api/middleware/response/raise_error'
require 'zendesk_api/middleware/response/token_refresher'
require 'zendesk_api/middleware/response/logger'
require 'zendesk_api/delegator'

Expand Down Expand Up @@ -236,7 +237,11 @@ 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 }
# TODO: If you decide to use TokenRefresher as a middleware, uncomment the below line.
# builder.use ZendeskAPI::Middleware::Response::TokenRefresher, config
elsif config.access_token
builder.use ZendeskAPI::Middleware::Request::UrlBasedAccessToken, config.access_token
else
Expand Down
16 changes: 16 additions & 0 deletions lib/zendesk_api/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,24 @@ 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

# refresh_token_callback is 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_token_callback

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 @@ -30,7 +30,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}" if @logger
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
28 changes: 28 additions & 0 deletions lib/zendesk_api/middleware/response/token_refresher.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
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
end

def on_complete(env)
return unless ERROR_CODES.include?(env[:status])

ZendeskAPI::TokenRefresher.new(@config).refresh_token do |access_token, refresh_token|
if @config.refresh_token_callback.is_a?(Proc)
@config.refresh_token_callback.call(access_token, refresh_token)
end
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.merge!(expires_in: @config.access_token_expiration) if @config.access_token_expiration
params.merge!(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
118 changes: 118 additions & 0 deletions spec/core/middleware/response/token_refresher_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
require 'core/spec_helper'

describe ZendeskAPI::Middleware::Response::TokenRefresher do
let(:refresh_token_body) do
{
access_token: "nt",
refresh_token: "nrt",
token_type: "bearer",
scope: "read write",
expires_in: 300,
refresh_token_expires_in: 604800
}
end

before do
skip "TODO: If you decide to use TokenRefresher as a middleware, remove this skip."

client.config.client_id = "client"
client.config.client_secret = "secret"
client.config.refresh_token = "abc"
end

describe "with access token" do
before do
client.config.access_token = "xyz"
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 "nt"
expect(client.config.refresh_token).to eq "nrt"
end

it "calls refresh token callback" do
new_access_token = nil
new_refresh_token = nil
client.config.refresh_token_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 "nt"
expect(new_refresh_token).to eq "nrt"
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 "xyz"
expect(client.config.refresh_token).to eq "abc"
end
end
end

describe "when 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 "xyz"
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 "refreshes token" 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
Loading