Skip to content

Commit 544eee2

Browse files
add support for refresh tokens
add dedicated unauthorized error allow token refreshing middleware to be enabled in configuration
1 parent 481f999 commit 544eee2

File tree

11 files changed

+456
-2
lines changed

11 files changed

+456
-2
lines changed

README.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,10 @@ client = ZendeskAPI::Client.new do |config|
6767
# More information on obtaining OAuth access tokens can be found here:
6868
# https://developer.zendesk.com/api-reference/introduction/security-and-auth/#oauth-access-token
6969
config.access_token = "your OAuth access token"
70+
# You can configure token refreshing by adding the OAuth client ID, secret and refresh token:
71+
config.client_id = "your OAuth client id"
72+
config.client_secret = "your OAuth client secret"
73+
config.refresh_token = "your OAuth refresh token"
7074

7175
# Optional:
7276

@@ -101,6 +105,18 @@ client = ZendeskAPI::Client.new do |config|
101105

102106
# Error codes when the request will be automatically retried. Defaults to 429, 503
103107
config.retry_codes = [ 429 ]
108+
109+
# When OAuth token refreshing is configured:
110+
# - sets access and refresh token expiration times
111+
config.access_token_expiration = 300 # time in seconds (between 5 minutes and 2 days: 300 to 172800)
112+
config.refresh_token_expiration = 605800 # time in seconds (between 7 and 90 days: 605800 to 7776000)
113+
# - set to true to automatically refresh tokens when an `ZendeskAPI::Errors::Unauthorized` error is raised;
114+
# defaults to false, meaning tokens must be refreshed explicitly using `ZendeskAPI::TokenRefresher`
115+
config.auto_refresh_tokens = true
116+
# - when automatic token refreshing is enabled, a callback that is called whenever tokens are refreshed
117+
config.refresh_tokens_callback = lambda do |access_token, refresh_token|
118+
# saves tokens for further use
119+
end
104120
end
105121
```
106122

@@ -163,6 +179,48 @@ zendesk_api_client_rb $ bundle console
163179
=> true
164180
```
165181

182+
### OAuth Token Refreshing
183+
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.
184+
185+
```ruby
186+
users = client.users.per_page(3)
187+
begin
188+
# A request with an expired access token is made.
189+
users.fetch!
190+
# The request is rejected with 401 (Unauthorized) status code.
191+
rescue ZendeskAPI::Error::Unauthorized
192+
# Refresh tokens and store them securely
193+
ZendeskAPI::TokenRefresher.new(client.config).refresh_token do |access_token, refresh_token|
194+
# The access and refresh tokens are passed here so you could persist them for later use.
195+
# The client's configuration is updated automatically.
196+
end
197+
# Issue the request again.
198+
users.fetch!
199+
end
200+
```
201+
202+
When automatic tokens refreshing is enabled:
203+
```ruby
204+
config.auto_refresh_tokens = true
205+
config.refresh_tokens_callback = lambda do |access_token, refresh_token|
206+
# The access and refresh tokens are passed here so you could persist them for later use.
207+
# The client's configuration is updated automatically.
208+
end
209+
```
210+
The above example could be changed to:
211+
```ruby
212+
users = client.users.per_page(3)
213+
begin
214+
# A request with an expired access token is made.
215+
users.fetch!
216+
# The request is rejected with 401 (Unauthorized) status code.
217+
rescue ZendeskAPI::Error::Unauthorized
218+
# Tokens are refreshed automatically.
219+
# Issue the request again.
220+
users.fetch!
221+
end
222+
```
223+
166224
### Pagination
167225

168226
`ZendeskAPI::Collections` can be paginated:

lib/zendesk_api.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,4 @@ module ZendeskAPI; end
66
require_relative "zendesk_api/helpers"
77
require_relative "zendesk_api/core_ext/inflection"
88
require_relative "zendesk_api/client"
9+
require_relative "zendesk_api/token_refresher"

lib/zendesk_api/client.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
require_relative "middleware/response/parse_iso_dates"
1919
require_relative "middleware/response/parse_json"
2020
require_relative "middleware/response/raise_error"
21+
require_relative "middleware/response/token_refresher"
2122
require_relative "middleware/response/logger"
2223
require_relative "delegator"
2324

@@ -257,7 +258,10 @@ def add_warning_callback
257258
# See https://lostisland.github.io/faraday/middleware/authentication
258259
def set_authentication(builder, config)
259260
if config.access_token && !config.url_based_access_token
260-
builder.request :authorization, "Bearer", config.access_token
261+
# Upon refreshing the access token, the configuration is updated accordingly.
262+
# Utilizing the proc here ensures that the token used is always valid.
263+
builder.request :authorization, "Bearer", -> { config.access_token }
264+
builder.use(ZendeskAPI::Middleware::Response::TokenRefresher, config) if config.auto_refresh_tokens
261265
elsif config.access_token
262266
builder.use ZendeskAPI::Middleware::Request::UrlBasedAccessToken, config.access_token
263267
else

lib/zendesk_api/configuration.rb

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,27 @@ class Configuration
3434
# @return [Boolean] Whether to allow non-HTTPS connections for development purposes.
3535
attr_accessor :allow_http
3636

37+
# Client ID and secret, together with the refresh token, are used to obtain a new access token, after the old expires
38+
attr_accessor :client_id, :client_secret
39+
3740
# @return [String] OAuth2 access_token
3841
attr_accessor :access_token
42+
# @return [String] OAuth2 refresh token used to obtain a new access token after the old expires
43+
attr_accessor :refresh_token
44+
45+
# @return [Integer] Time in seconds after the refreshed access token expires.
46+
# Value between 5 minutes and 2 days (300 and 172800)
47+
attr_accessor :access_token_expiration
48+
# @return [Integer] Time in seconds after the refresh token, generated after access token refreshing, expires.
49+
# Value between 7 and 90 days (604800 and 7776000)
50+
attr_accessor :refresh_token_expiration
51+
52+
# @return [Proc] A lambda that handles the response when the refresh_token is used to obtain a new access_token.
53+
# This allows the access_token to be saved for re-use later.
54+
attr_accessor :refresh_tokens_callback
55+
# @return [Boolean] Whether to automatically refresh tokens when an unauthorized error happens for an OAuth request.
56+
# The unauthorized error is still raised so the request could be retried with tokens refreshed.
57+
attr_accessor :auto_refresh_tokens
3958

4059
attr_accessor :url_based_access_token
4160

lib/zendesk_api/error.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ def generate_error_msg(response_body)
3838
end
3939

4040
class NetworkError < ClientError; end
41+
# The Unauthorized class inherits from NetworkError to maintain backward compatibility.
42+
# In previous versions, a NetworkError was raised for HTTP 401 response codes.
43+
class Unauthorized < NetworkError; end
4144
class RecordNotFound < ClientError; end
4245
class RateLimited < ClientError; end
4346
end

lib/zendesk_api/middleware/request/retry.rb

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ def call(env)
3131
end
3232

3333
if exception_happened || @error_codes.include?(response.env[:status])
34-
3534
if exception_happened
3635
seconds_left = DEFAULT_RETRY_AFTER.to_i
3736
@logger&.warn "An exception happened, waiting #{seconds_left} seconds... #{e}"

lib/zendesk_api/middleware/response/raise_error.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ def call(env)
1212

1313
def on_complete(env)
1414
case env[:status]
15+
when 401
16+
raise Error::Unauthorized.new(env)
1517
when 404
1618
raise Error::RecordNotFound.new(env)
1719
when 422, 413
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
module ZendeskAPI
2+
# @private
3+
module Middleware
4+
# @private
5+
module Response
6+
# This middleware is responsible for obtaining new access and refresh tokens
7+
# when the current expires.
8+
class TokenRefresher < Faraday::Middleware
9+
ERROR_CODES = [401].freeze
10+
11+
def initialize(app, config)
12+
super(app)
13+
@config = config
14+
@refresh_tokens_callback = @config.refresh_tokens_callback.is_a?(Proc) ? @config.refresh_tokens_callback : ->(_, _) {}
15+
end
16+
17+
def on_complete(env)
18+
return unless ERROR_CODES.include?(env[:status])
19+
20+
ZendeskAPI::TokenRefresher.new(@config).refresh_token do |access_token, refresh_token|
21+
@refresh_tokens_callback.call(access_token, refresh_token)
22+
end
23+
end
24+
end
25+
end
26+
end
27+
end

lib/zendesk_api/token_refresher.rb

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
module ZendeskAPI
2+
# Obtains new OAuth access and refresh tokens.
3+
class TokenRefresher
4+
def initialize(config)
5+
@config = config
6+
end
7+
8+
def valid_config?
9+
return false unless @config.client_id
10+
return false unless @config.client_secret
11+
return false unless @config.refresh_token
12+
13+
true
14+
end
15+
16+
def refresh_token
17+
return unless valid_config?
18+
19+
response = connection.post "/oauth/tokens" do |req|
20+
req.body = {
21+
grant_type: "refresh_token",
22+
refresh_token: @config.refresh_token,
23+
client_id: @config.client_id,
24+
client_secret: @config.client_secret
25+
}.tap do |params|
26+
params[:expires_in] = @config.access_token_expiration if @config.access_token_expiration
27+
params[:refresh_token_expires_in] = @config.refresh_token_expiration if @config.refresh_token_expiration
28+
end
29+
end
30+
new_access_token = response.body["access_token"]
31+
new_refresh_token = response.body["refresh_token"]
32+
@config.access_token = new_access_token
33+
@config.refresh_token = new_refresh_token
34+
35+
yield new_access_token, new_refresh_token if block_given?
36+
end
37+
38+
private
39+
40+
def connection
41+
@connection ||= Faraday.new(faraday_options) do |builder|
42+
builder.use ZendeskAPI::Middleware::Response::RaiseError
43+
builder.use ZendeskAPI::Middleware::Response::Logger, @config.logger if @config.logger
44+
builder.use ZendeskAPI::Middleware::Response::ParseJson
45+
builder.use ZendeskAPI::Middleware::Response::SanitizeResponse
46+
builder.use ZendeskAPI::Middleware::Request::EncodeJson
47+
48+
adapter = @config.adapter || Faraday.default_adapter
49+
builder.adapter(*adapter, &@config.adapter_proc)
50+
end
51+
end
52+
53+
def faraday_options
54+
{
55+
url: @config.url
56+
}
57+
end
58+
end
59+
end
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
require "core/spec_helper"
2+
3+
describe ZendeskAPI::Middleware::Response::TokenRefresher do
4+
let(:refresh_token_body) do
5+
{
6+
access_token: "newacc123",
7+
refresh_token: "newref123",
8+
token_type: "bearer",
9+
scope: "read write",
10+
expires_in: 300,
11+
refresh_token_expires_in: 604800
12+
}
13+
end
14+
15+
before do
16+
client.config.client_id = "client"
17+
client.config.client_secret = "secret"
18+
client.config.refresh_token = "ref123"
19+
client.config.auto_refresh_tokens = true
20+
end
21+
22+
describe "with access token" do
23+
before do
24+
client.config.access_token = "acc123"
25+
end
26+
27+
describe "when unauthorized" do
28+
before do
29+
stub_request(:any, /whatever/).to_return(
30+
status: 401,
31+
body: "",
32+
headers: {content_type: "application/json"}
33+
)
34+
stub_request(:post, %r{/oauth/tokens}).to_return(
35+
status: refresh_token_status,
36+
body: refresh_token_body.to_json,
37+
headers: {content_type: "application/json"}
38+
)
39+
end
40+
41+
describe "when refreshing token succeeds" do
42+
let(:refresh_token_status) { 200 }
43+
44+
it "refreshes token" do
45+
expect { client.connection.get "/whatever" }.to raise_error(ZendeskAPI::Error::Unauthorized)
46+
47+
expect(client.config.access_token).to eq "newacc123"
48+
expect(client.config.refresh_token).to eq "newref123"
49+
end
50+
51+
it "calls refresh tokens callback" do
52+
new_access_token = nil
53+
new_refresh_token = nil
54+
client.config.refresh_tokens_callback = lambda do |access_token, refresh_token|
55+
new_access_token = access_token
56+
new_refresh_token = refresh_token
57+
end
58+
expect { client.connection.get "/whatever" }.to raise_error(ZendeskAPI::Error::Unauthorized)
59+
60+
expect(new_access_token).to eq "newacc123"
61+
expect(new_refresh_token).to eq "newref123"
62+
end
63+
64+
it "is ok when refresh tokens callback is not configured" do
65+
expect { client.connection.get "/whatever" }.to raise_error(ZendeskAPI::Error::Unauthorized)
66+
expect(client.config.access_token).to eq "newacc123"
67+
expect(client.config.refresh_token).to eq "newref123"
68+
end
69+
70+
it "raises unauthorized exception" do
71+
expect { client.connection.get "/whatever" }.to raise_error(ZendeskAPI::Error::Unauthorized)
72+
end
73+
end
74+
75+
describe "when refreshing token fails" do
76+
let(:refresh_token_status) { 500 }
77+
78+
it "does not update configuration" do
79+
expect { client.connection.get "/whatever" }.to raise_error(ZendeskAPI::Error::NetworkError)
80+
81+
expect(client.config.access_token).to eq "acc123"
82+
expect(client.config.refresh_token).to eq "ref123"
83+
end
84+
end
85+
86+
describe "when auto token refreshing is disabled" do
87+
let(:refresh_token_status) { 200 }
88+
89+
before do
90+
client.config.auto_refresh_tokens = false
91+
end
92+
93+
it "does not refresh token" do
94+
expect { client.connection.get "/whatever" }.to raise_error(ZendeskAPI::Error::Unauthorized)
95+
96+
expect_any_instance_of(ZendeskAPI::TokenRefresher).to receive(:refresh_token).never
97+
expect(client.config.access_token).to eq "acc123"
98+
expect(client.config.refresh_token).to eq "ref123"
99+
end
100+
end
101+
end
102+
103+
describe "when status request is ok" do
104+
before do
105+
stub_request(:any, /whatever/).to_return(
106+
status: 200,
107+
body: "",
108+
headers: {content_type: "application/json"}
109+
)
110+
end
111+
112+
it "does not refresh token" do
113+
client.connection.get "/whatever"
114+
115+
expect_any_instance_of(ZendeskAPI::TokenRefresher).to receive(:refresh_token).never
116+
expect(client.config.access_token).to eq "acc123"
117+
end
118+
end
119+
end
120+
121+
describe "with other type of authorization" do
122+
before do
123+
client.config.username = "xyz"
124+
client.config.password = "xyz"
125+
126+
stub_request(:any, /whatever/).to_return(
127+
status: 401,
128+
body: "",
129+
headers: {content_type: "application/json"}
130+
)
131+
end
132+
133+
it "does not refresh tokens" do
134+
expect { client.connection.get "/whatever" }.to raise_error(ZendeskAPI::Error::Unauthorized)
135+
136+
expect_any_instance_of(ZendeskAPI::TokenRefresher).to receive(:refresh_token).never
137+
end
138+
end
139+
end

0 commit comments

Comments
 (0)