Skip to content

Commit 317e26a

Browse files
committed
Implement functionality to refresh access tokens
1 parent 81cfc97 commit 317e26a

16 files changed

+673
-133
lines changed

README.md

Lines changed: 79 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -56,111 +56,115 @@ DropboxApi::Client.new
5656
#=> #<DropboxApi::Client ...>
5757
```
5858

59-
Note that setting an ENV variable is only a feasible choice if you're only
60-
using one account.
59+
The official documentation on the process to get an authorization code is
60+
[here](https://developers.dropbox.com/es-es/oauth-guide#implementing-oauth),
61+
it describes the two options listed below.
6162

6263

6364
#### Option A: Get your access token from the website
6465

65-
The easiest way to obtain an access token is to get it from the Dropbox website.
66-
You just need to log in to Dropbox and refer to the *developers* section, go to
67-
*My apps* and select your application, you may need to create one if you
68-
haven't done so yet.
66+
For a quick test, you can obtain an access token from the App Console in
67+
[Dropbox's website](https://www.dropbox.com/developers/). Select from
68+
*My apps* your application, you may need to create one if you
69+
haven't done so yet. Under your application settings, find section
70+
*OAuth 2*, there is a button to generate an access token.
6971

70-
Under your application settings, find section *OAuth 2*. You'll find a button
71-
to generate an access token.
72+
#### Option B: OAuth2 Code Flow
7273

73-
#### Option B: Use `DropboxApi::Authenticator`
74-
75-
You can obtain an authorization code with this library:
74+
This is typically what you will use in production, you can obtain an
75+
authorization code with a 3-step process:
7676

7777
```ruby
78+
# 1. Get an authorization URL.
7879
authenticator = DropboxApi::Authenticator.new(CLIENT_ID, CLIENT_SECRET)
7980
authenticator.auth_code.authorize_url #=> "https://www.dropbox.com/..."
8081

81-
# Now you need to open the authorization URL in your browser,
82-
# authorize the application and copy your code.
83-
84-
auth_bearer = authenticator.auth_code.get_token(CODE) #=> #<OAuth2::AccessToken ...>`
85-
auth_bearer.token #=> "VofXAX8D..."
86-
# Keep this token, you'll need it to initialize a `DropboxApi::Client` object
87-
```
88-
89-
#### Standard OAuth 2 flow
90-
91-
This is what many web applications will use. The process is described in
92-
Dropbox's [OAuth guide](https://www.dropbox.com/developers/reference/oauth-guide#oauth-2-on-the-web).
93-
94-
If you have a Rails application, you might be interested in this [setup guide](http://jesus.github.io/dropbox_api/file.rails_setup.html).
82+
# 2. Log into Dropbox and authorize your app. You need to open the
83+
# authorization URL in your browser.
9584

85+
# 3. Exchange the authorization code for a reusable access token (not visible
86+
# to the user).
87+
access_token = authenticator.auth_code.get_token(CODE) #=> #<OAuth2::AccessToken ...>`
88+
access_token.token #=> "VofXAX8D..."
9689

97-
### Authorize with short lived token
90+
# Keep this token, you'll need it to initialize a `DropboxApi::Client` object:
91+
client = DropboxApi::Client.new(access_token: access_token)
9892

99-
Dropbox introduces, effective on September, 30 2021, a new policy for OAuth2 token based authentication. It impacts all new applications as well as being suggested for existing apps. You can [read more about the change applied and how it impacts the authentication proces](https://dropbox.tech/developers/migrating-app-permissions-and-access-tokens#updating-access-token-type)
100-
101-
In short, the persistent, long lived tokens are being replaced with short lived tokens, which are valid for up to a few hours. With the current approach the application has to revalidate permission by executing a full handshake involving an interactive user to make a new token, which will expire.
93+
# For backwards compatibility, the following also works:
94+
client = DropboxApi::Client.new(access_token.token)
95+
```
10296

103-
Apps that require background ('offline') access but have not yet implemented refresh tokens will be impacted.
97+
##### Integration with Rails
10498

105-
To keep “offline” access in the background the application must change authentication strategy and obtain a new token every time the old one expires with a simplified `refresh` procedure. The app performing a full token generation (the first step) must ask for special “offline” mode which will generate, except regular authentication, an additional refresh token that can be reused for future quick re-refresh procedures. Thus the refresh token is important and has to be securely stored with the application, as it will be required every time the short term token expires.
99+
If you have a Rails application, you might be interested in this [setup
100+
guide](http://jesus.github.io/dropbox_api/file.rails_setup.html).
106101

107-
To prevent the app to lose connectivity and access to Dropbox resources using the library following changes has to be applied:
108102

109-
#### Implement own `DropboxApi::Token`
103+
##### Using refresh tokens
110104

111-
Application must replace current fixed token if it has used one with a dynamic, secure store that updates every time a token expires. For that purpose a new class `DropboxApi::Token` has been introduced, which implements short lived tokens, and replaces current fixed string approach.
105+
Access tokens are short-lived by default (as of September 30th, 2021),
106+
applications that require long-lived access to the API without additional
107+
interaction with the user should use refresh tokens.
112108

113-
Furthermore overriding the class on your own and implement `save_token` method allows to keep tokens within your application secure store or session data, every time needed.
114-
115-
```ruby
116-
class MyDropboxToken < DropboxApi::Token
117-
def save_token(token)
118-
# Implement your own store method, token is a `Hash` instance in here, easy to serialize:
119-
puts 'Token to be saved somewhere in the database', token
120-
end
121-
end
122-
```
123-
124-
#### Obtaining the offline token
125-
126-
The application must obtain a new token for “offline use”.
127-
In case of use of Authenticator approach, following change has to be applied:
109+
The process is similar but a token refresh might seamlessly occur as you
110+
perform API calls. When this happens you'll need to store the
111+
new token hash if you want to continue using this session, you can use the
112+
`on_token_refreshed` callback to do this.
128113

129114
```ruby
115+
# 1. Get an authorization URL, requesting offline access type.
130116
authenticator = DropboxApi::Authenticator.new(CLIENT_ID, CLIENT_SECRET)
131-
132-
# Change 1: ask for offline token type:
133-
authenticator.auth_code.authorize_url(token_access_type: 'offline') #=> "https://www.dropbox.com/..."
134-
135-
# Now you need to open the authorization URL in your browser,
136-
# authorize the application and copy your code.
137-
138-
# Change 2: Use own token to save it
139-
token = MyDropboxToken.from_code(authenticator, CODE) #=> #<DropboxApi::Token ...>`
140-
# First save your data using overriden token implementation:
141-
token.save!
117+
authenticator.auth_code.authorize_url(token_access_type: 'offline')
118+
119+
# 2. Log into Dropbox and authorize your app. You need to open the
120+
# authorization URL in your browser.
121+
122+
# 3. Exchange the authorization code for a reusable access token
123+
access_token = authenticator.auth_code.get_token(CODE) #=> #<OAuth2::AccessToken ...>`
124+
125+
# You can now use the access token to initialize a DropboxApi::Client, you
126+
# should also provide a callback function to store the updated access token
127+
# whenever it's refreshed.
128+
client = DropboxApi::Client.new(
129+
access_token: access_token,
130+
on_token_refreshed: lambda { |new_token_hash|
131+
# token_hash is a serializable Hash, something like this:
132+
# {
133+
# "uid"=>"440",
134+
# "token_type"=>"bearer",
135+
# "scope"=>"account_info.read account_info.write...",
136+
# "account_id"=>"dbid:AABOLtA1rT6rRK4vajKZ...",
137+
# :access_token=>"sl.A5Ez_CBsqJILhDawHlmXSoZEhLZ4nuLFVRs6AJ...",
138+
# :refresh_token=>"iMg4Me_oKYUAAAAAAAAAAapQixCgwfXOxuubCuK_...",
139+
# :expires_at=>1632948328
140+
# }
141+
SomewhereSafe.save(new_token_hash)
142+
}
143+
)
142144
```
143145

144-
#### Using token performing API calls
146+
Once you've gone through the process above, you can skip the steps that require
147+
user interaction in subsequent initializations of `DropboxApi::Client`. For
148+
example:
145149

146150
```ruby
147-
authenticator = DropboxApi::Authenticator.new(DROPBOX_APP_KEY, DROPBOX_APP_SECRET)
148-
149-
# Change 3: Use own class and the deserialized token to intilize the Client:
150-
151-
token_hash = DESERIALIZED_TOKEN_HASH # Implement your own method to load the hash from secure store
152-
token = MyDropboxToken.new(authenticator, token_hash)
153-
154-
# Intialize API with a dynamic Token:
155-
dropbox = DropboxApi::Client.new(token)
151+
# 1. Initialize an authenticator
152+
authenticator = DropboxApi::Authenticator.new(CLIENT_ID, CLIENT_SECRET)
156153

157-
# Enjoy the API:
158-
puts dropbox.get_metadata('/Temp/duck.jpg').to_hash
154+
# 2. Retrieve the token hash you previously stored somewhere safe, you can use
155+
# it to build a new access token.
156+
access_token = OAuth2::AccessToken.from_hash(authenticator, token_hash)
157+
158+
# 3. You now have an access token, so you can initialize a client like you
159+
# would normally:
160+
client = DropboxApi::Client.new(
161+
access_token: access_token,
162+
on_token_refreshed: lambda { |new_token_hash|
163+
SomewhereSafe.save(new_token_hash)
164+
}
165+
)
159166
```
160167

161-
NOTE: When token expires it will automatically call refresh procedure in the background and invoke `save_token`
162-
method from overriden class, to keep new token secure for the future use.
163-
164168
### Performing API calls
165169

166170
Once you've initialized a client, for example:

lib/dropbox_api/authenticator.rb

Lines changed: 0 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -10,42 +10,4 @@ def initialize(client_id, client_secret)
1010
})
1111
end
1212
end
13-
14-
class Token
15-
extend Forwardable
16-
def_delegators :@token, :token, :refresh_token, :expired
17-
18-
def initialize(authenticator, token_hash = nil)
19-
@authenticator = authenticator
20-
load_token(token_hash) if token_hash
21-
end
22-
23-
def self.from_code(authenticator, code)
24-
self.new(authenticator, authenticator.auth_code.get_token(code))
25-
end
26-
27-
def load_token(token_hash)
28-
if token_hash.is_a?(OAuth2::AccessToken)
29-
@token = token_hash
30-
else
31-
@token = OAuth2::AccessToken.from_hash(@authenticator, token_hash)
32-
end
33-
end
34-
35-
def refresh_token()
36-
@token = @token.refresh!
37-
save!
38-
end
39-
40-
def save_token(token_hash); end
41-
42-
def save!
43-
save_token(@token.to_hash)
44-
end
45-
46-
def short_lived_token()
47-
refresh_token if @token.expired?
48-
@token.token
49-
end
50-
end
5113
end

lib/dropbox_api/client.rb

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,21 @@
11
# frozen_string_literal: true
22
module DropboxApi
33
class Client
4-
def initialize(oauth_bearer = ENV['DROPBOX_OAUTH_BEARER'])
5-
@connection_builder = ConnectionBuilder.new(oauth_bearer)
4+
def initialize(
5+
oauth_bearer = ENV['DROPBOX_OAUTH_BEARER'],
6+
access_token: nil,
7+
on_token_refreshed: nil
8+
)
9+
if access_token
10+
@connection_builder = ConnectionBuilder.new(
11+
access_token: access_token,
12+
on_token_refreshed: on_token_refreshed
13+
)
14+
elsif oauth_bearer
15+
@connection_builder = ConnectionBuilder.new(oauth_bearer)
16+
else
17+
raise ArgumentError, "Either oauth_bearer or access_token should be set"
18+
end
619
end
720

821
def middleware

lib/dropbox_api/connection_builder.rb

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,51 @@ module DropboxApi
33
class ConnectionBuilder
44
attr_accessor :namespace_id
55

6-
def initialize(oauth_bearer)
7-
@oauth_bearer = oauth_bearer
6+
def initialize(oauth_bearer = nil, access_token: nil, on_token_refreshed: nil)
7+
if access_token
8+
if !access_token.is_a?(OAuth2::AccessToken)
9+
raise ArgumentError, "access_token should be an OAuth2::AccessToken"
10+
end
11+
12+
@access_token = access_token
13+
@on_token_refreshed = on_token_refreshed
14+
elsif oauth_bearer
15+
@oauth_bearer = oauth_bearer
16+
else
17+
raise ArgumentError, "Either oauth_bearer or access_token should be set"
18+
end
819
end
920

1021
def middleware
1122
@middleware ||= MiddleWare::Stack.new
1223
end
1324

25+
def can_refresh_access_token?
26+
@access_token && @access_token.refresh_token
27+
end
28+
29+
def refresh_access_token
30+
@access_token = @access_token.refresh!
31+
@on_token_refreshed.call(@access_token.to_hash) if @on_token_refreshed
32+
end
33+
34+
private def bearer
35+
@oauth_bearer or oauth_bearer_from_access_token
36+
end
37+
38+
private def oauth_bearer_from_access_token
39+
refresh_access_token if @access_token.expired?
40+
41+
@access_token.token
42+
end
43+
1444
def build(url)
1545
Faraday.new(url) do |connection|
1646
connection.use DropboxApi::MiddleWare::PathRoot, {
1747
namespace_id: self.namespace_id
1848
}
1949
middleware.apply(connection) do
20-
connection.authorization :Bearer, @oauth_bearer.is_a?(DropboxApi::Token) ? @oauth_bearer.short_lived_token : @oauth_bearer
50+
connection.authorization :Bearer, bearer
2151
yield connection
2252
end
2353
end

lib/dropbox_api/endpoints/base.rb

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
# frozen_string_literal: true
22
module DropboxApi::Endpoints
33
class Base
4+
def initialize(builder)
5+
@builder = builder
6+
build_connection
7+
end
8+
49
def self.add_endpoint(name, &block)
510
define_method(name, block)
611
DropboxApi::Client.add_endpoint(name, self)
@@ -10,6 +15,14 @@ def self.add_endpoint(name, &block)
1015

1116
def perform_request(params)
1217
process_response(get_response(params))
18+
rescue DropboxApi::Errors::ExpiredAccessTokenError => e
19+
if @builder.can_refresh_access_token?
20+
@builder.refresh_access_token
21+
build_connection
22+
process_response(get_response(params))
23+
else
24+
raise e
25+
end
1326
end
1427

1528
def get_response(*args)

lib/dropbox_api/endpoints/content_download.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# frozen_string_literal: true
22
module DropboxApi::Endpoints
33
class ContentDownload < DropboxApi::Endpoints::Base
4-
def initialize(builder)
5-
@connection = builder.build('https://content.dropboxapi.com') do |c|
4+
def build_connection
5+
@connection = @builder.build('https://content.dropboxapi.com') do |c|
66
c.response :decode_result
77
end
88
end

lib/dropbox_api/endpoints/content_upload.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# frozen_string_literal: true
22
module DropboxApi::Endpoints
33
class ContentUpload < DropboxApi::Endpoints::Base
4-
def initialize(builder)
5-
@connection = builder.build('https://content.dropboxapi.com') do |c|
4+
def build_connection
5+
@connection = @builder.build('https://content.dropboxapi.com') do |c|
66
c.response :decode_result
77
end
88
end

lib/dropbox_api/endpoints/rpc.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# frozen_string_literal: true
22
module DropboxApi::Endpoints
33
class Rpc < DropboxApi::Endpoints::Base
4-
def initialize(builder)
5-
@connection = builder.build('https://api.dropboxapi.com') do |c|
4+
def build_connection
5+
@connection = @builder.build('https://api.dropboxapi.com') do |c|
66
c.response :decode_result
77
end
88
end

lib/dropbox_api/endpoints/rpc_content.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# frozen_string_literal: true
22
module DropboxApi::Endpoints
33
class RpcContent < DropboxApi::Endpoints::Rpc
4-
def initialize(builder)
5-
@connection = builder.build('https://content.dropboxapi.com') do |c|
4+
def build_connection
5+
@connection = @builder.build('https://content.dropboxapi.com') do |c|
66
c.response :decode_result
77
end
88
end

lib/dropbox_api/endpoints/rpc_notify.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# frozen_string_literal: true
22
module DropboxApi::Endpoints
33
class RpcNotify < DropboxApi::Endpoints::Rpc
4-
def initialize(builder)
5-
@connection = builder.build('https://notify.dropboxapi.com') do |c|
4+
def build_connection
5+
@connection = @builder.build('https://notify.dropboxapi.com') do |c|
66
c.headers.delete 'Authorization'
77

88
c.response :decode_result

0 commit comments

Comments
 (0)