Skip to content

Commit e1d2604

Browse files
add support for refresh tokens
add dedicated unauthorized error
1 parent ab34ca8 commit e1d2604

File tree

11 files changed

+403
-2
lines changed

11 files changed

+403
-2
lines changed

README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,13 @@ client = ZendeskAPI::Client.new do |config|
6868
# More information on obtaining OAuth access tokens can be found here:
6969
# https://developer.zendesk.com/api-reference/introduction/security-and-auth/#oauth-access-token
7070
config.access_token = "your OAuth access token"
71+
# You can configure token refreshening by adding the OAuth client ID, secret and refresh token:
72+
config.client_id = "your OAuth client id"
73+
config.client_secret = "your OAuth client secret"
74+
config.refresh_token = "your OAuth refresh token"
75+
# Optionally you can set access and refresh token expiration:
76+
config.access_token_expiration = 300 # time in seconds between 5 minutes and 2 days (300 and 172800)
77+
config.refresh_token_expiration = 605800 # time in seconds between 7 and 90 days (605800 and 7776000)
7178

7279
# Optional:
7380

@@ -164,6 +171,26 @@ zendesk_api_client_rb $ bundle console
164171
=> true
165172
```
166173

174+
### OAuth Token Refreshing
175+
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.
176+
177+
```ruby
178+
users = client.users.per_page(3)
179+
begin
180+
# A request with an expired access token is made.
181+
users.fetch!
182+
# The request is rejected with 401 (Unauthorized) status code.
183+
rescue ZendeskAPI::Error::Unauthorized
184+
# Refresh tokens and store them securely
185+
ZendeskAPI::TokenRefresher.new(client.config).refresh_token do |access_token, refresh_token|
186+
# The access and refresh tokens are passed here so you could persist them for later use.
187+
# The client's configuration is updated automatically.
188+
end
189+
# Issue the request again.
190+
users.fetch!
191+
end
192+
```
193+
167194
### Pagination
168195

169196
`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 'zendesk_api/helpers'
77
require 'zendesk_api/core_ext/inflection'
88
require 'zendesk_api/client'
9+
require 'zendesk_api/token_refresher'

lib/zendesk_api/client.rb

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
require 'zendesk_api/middleware/response/parse_iso_dates'
1818
require 'zendesk_api/middleware/response/parse_json'
1919
require 'zendesk_api/middleware/response/raise_error'
20+
require 'zendesk_api/middleware/response/token_refresher'
2021
require 'zendesk_api/middleware/response/logger'
2122
require 'zendesk_api/delegator'
2223

@@ -236,7 +237,11 @@ def add_warning_callback
236237
# See https://lostisland.github.io/faraday/middleware/authentication
237238
def set_authentication(builder, config)
238239
if config.access_token && !config.url_based_access_token
239-
builder.request :authorization, "Bearer", config.access_token
240+
# Upon refreshing the access token, the configuration is updated accordingly.
241+
# Utilizing the proc here ensures that the token used is always valid.
242+
builder.request :authorization, "Bearer", -> { config.access_token }
243+
# TODO: If you decide to use TokenRefresher as a middleware, uncomment the below line.
244+
# builder.use ZendeskAPI::Middleware::Response::TokenRefresher, config
240245
elsif config.access_token
241246
builder.use ZendeskAPI::Middleware::Request::UrlBasedAccessToken, config.access_token
242247
else

lib/zendesk_api/configuration.rb

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,24 @@ 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+
# refresh_token_callback is 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_token_callback
3955

4056
attr_accessor :url_based_access_token
4157

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
@@ -30,7 +30,6 @@ def call(env)
3030
end
3131

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

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: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
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+
end
15+
16+
def on_complete(env)
17+
return unless ERROR_CODES.include?(env[:status])
18+
19+
ZendeskAPI::TokenRefresher.new(@config).refresh_token do |access_token, refresh_token|
20+
if @config.refresh_token_callback.is_a?(Proc)
21+
@config.refresh_token_callback.call(access_token, refresh_token)
22+
end
23+
end
24+
end
25+
end
26+
end
27+
end
28+
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.merge!(expires_in: @config.access_token_expiration) if @config.access_token_expiration
27+
params.merge!(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: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
require 'core/spec_helper'
2+
3+
describe ZendeskAPI::Middleware::Response::TokenRefresher do
4+
let(:refresh_token_body) do
5+
{
6+
access_token: "nt",
7+
refresh_token: "nrt",
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+
skip "TODO: If you decide to use TokenRefresher as a middleware, remove this skip."
17+
18+
client.config.client_id = "client"
19+
client.config.client_secret = "secret"
20+
client.config.refresh_token = "abc"
21+
end
22+
23+
describe "with access token" do
24+
before do
25+
client.config.access_token = "xyz"
26+
end
27+
28+
describe "when unauthorized" do
29+
before do
30+
stub_request(:any, /whatever/).to_return(
31+
status: 401,
32+
body: "",
33+
headers: { content_type: "application/json" }
34+
)
35+
stub_request(:post, %r{/oauth/tokens}).to_return(
36+
status: refresh_token_status,
37+
body: refresh_token_body.to_json,
38+
headers: { content_type: "application/json" }
39+
)
40+
end
41+
42+
describe "when refreshing token succeeds" do
43+
let(:refresh_token_status) { 200 }
44+
45+
it "refreshes token" do
46+
expect { client.connection.get "/whatever" }.to raise_error(ZendeskAPI::Error::Unauthorized)
47+
48+
expect(client.config.access_token).to eq "nt"
49+
expect(client.config.refresh_token).to eq "nrt"
50+
end
51+
52+
it "calls refresh token callback" do
53+
new_access_token = nil
54+
new_refresh_token = nil
55+
client.config.refresh_token_callback = lambda do |access_token, refresh_token|
56+
new_access_token = access_token
57+
new_refresh_token = refresh_token
58+
end
59+
expect { client.connection.get "/whatever" }.to raise_error(ZendeskAPI::Error::Unauthorized)
60+
61+
expect(new_access_token).to eq "nt"
62+
expect(new_refresh_token).to eq "nrt"
63+
end
64+
65+
it "raises unauthorized exception" do
66+
expect { client.connection.get "/whatever" }.to raise_error(ZendeskAPI::Error::Unauthorized)
67+
end
68+
end
69+
70+
describe "when refreshing token fails" do
71+
let(:refresh_token_status) { 500 }
72+
73+
it "does not update configuration" do
74+
expect { client.connection.get "/whatever" }.to raise_error(ZendeskAPI::Error::NetworkError)
75+
76+
expect(client.config.access_token).to eq "xyz"
77+
expect(client.config.refresh_token).to eq "abc"
78+
end
79+
end
80+
end
81+
82+
describe "when ok" do
83+
before do
84+
stub_request(:any, /whatever/).to_return(
85+
status: 200,
86+
body: "",
87+
headers: { content_type: "application/json" }
88+
)
89+
end
90+
91+
it "does not refresh token" do
92+
client.connection.get "/whatever"
93+
94+
expect_any_instance_of(ZendeskAPI::TokenRefresher).to receive(:refresh_token).never
95+
expect(client.config.access_token).to eq "xyz"
96+
end
97+
end
98+
end
99+
100+
describe "with other type of authorization" do
101+
before do
102+
client.config.username = "xyz"
103+
client.config.password = "xyz"
104+
105+
stub_request(:any, /whatever/).to_return(
106+
status: 401,
107+
body: "",
108+
headers: { content_type: "application/json" }
109+
)
110+
end
111+
112+
it "refreshes token" do
113+
expect { client.connection.get "/whatever" }.to raise_error(ZendeskAPI::Error::Unauthorized)
114+
115+
expect_any_instance_of(ZendeskAPI::TokenRefresher).to receive(:refresh_token).never
116+
end
117+
end
118+
end

0 commit comments

Comments
 (0)