Skip to content

Commit

Permalink
Merge pull request #88 from Jesus/refresh-access-tokens
Browse files Browse the repository at this point in the history
Refresh access tokens
  • Loading branch information
Jesus authored Sep 29, 2021
2 parents b2b14d8 + 317e26a commit 8fc2be2
Show file tree
Hide file tree
Showing 16 changed files with 689 additions and 49 deletions.
115 changes: 93 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,44 +56,115 @@ DropboxApi::Client.new
#=> #<DropboxApi::Client ...>
```

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

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

The easiest way to obtain an access token is to get it from the Dropbox website.
You just need to log in to Dropbox and refer to the *developers* section, go to
*My apps* and select your application, you may need to create one if you
haven't done so yet.
#### Option A: Get your access token from the website

Under your application settings, find section *OAuth 2*. You'll find a button
to generate an access token.
For a quick test, you can obtain an access token from the App Console in
[Dropbox's website](https://www.dropbox.com/developers/). Select from
*My apps* your application, you may need to create one if you
haven't done so yet. Under your application settings, find section
*OAuth 2*, there is a button to generate an access token.

#### Option B: Use `DropboxApi::Authenticator`
#### Option B: OAuth2 Code Flow

You can obtain an authorization code with this library:
This is typically what you will use in production, you can obtain an
authorization code with a 3-step process:

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

# Now you need to open the authorization URL in your browser,
# authorize the application and copy your code.
# 2. Log into Dropbox and authorize your app. You need to open the
# authorization URL in your browser.

auth_bearer = authenticator.get_token(CODE) #=> #<OAuth2::AccessToken ...>`
auth_bearer.token #=> "VofXAX8D..."
# Keep this token, you'll need it to initialize a `DropboxApi::Client` object
```
# 3. Exchange the authorization code for a reusable access token (not visible
# to the user).
access_token = authenticator.auth_code.get_token(CODE) #=> #<OAuth2::AccessToken ...>`
access_token.token #=> "VofXAX8D..."

#### Standard OAuth 2 flow
# Keep this token, you'll need it to initialize a `DropboxApi::Client` object:
client = DropboxApi::Client.new(access_token: access_token)

# For backwards compatibility, the following also works:
client = DropboxApi::Client.new(access_token.token)
```

This is what many web applications will use. The process is described in
Dropbox's [OAuth guide]
(https://www.dropbox.com/developers/reference/oauth-guide#oauth-2-on-the-web).
##### Integration with Rails

If you have a Rails application, you might be interested in this [setup
guide](http://jesus.github.io/dropbox_api/file.rails_setup.html).


##### Using refresh tokens

Access tokens are short-lived by default (as of September 30th, 2021),
applications that require long-lived access to the API without additional
interaction with the user should use refresh tokens.

The process is similar but a token refresh might seamlessly occur as you
perform API calls. When this happens you'll need to store the
new token hash if you want to continue using this session, you can use the
`on_token_refreshed` callback to do this.

```ruby
# 1. Get an authorization URL, requesting offline access type.
authenticator = DropboxApi::Authenticator.new(CLIENT_ID, CLIENT_SECRET)
authenticator.auth_code.authorize_url(token_access_type: 'offline')

# 2. Log into Dropbox and authorize your app. You need to open the
# authorization URL in your browser.

# 3. Exchange the authorization code for a reusable access token
access_token = authenticator.auth_code.get_token(CODE) #=> #<OAuth2::AccessToken ...>`

# You can now use the access token to initialize a DropboxApi::Client, you
# should also provide a callback function to store the updated access token
# whenever it's refreshed.
client = DropboxApi::Client.new(
access_token: access_token,
on_token_refreshed: lambda { |new_token_hash|
# token_hash is a serializable Hash, something like this:
# {
# "uid"=>"440",
# "token_type"=>"bearer",
# "scope"=>"account_info.read account_info.write...",
# "account_id"=>"dbid:AABOLtA1rT6rRK4vajKZ...",
# :access_token=>"sl.A5Ez_CBsqJILhDawHlmXSoZEhLZ4nuLFVRs6AJ...",
# :refresh_token=>"iMg4Me_oKYUAAAAAAAAAAapQixCgwfXOxuubCuK_...",
# :expires_at=>1632948328
# }
SomewhereSafe.save(new_token_hash)
}
)
```

Once you've gone through the process above, you can skip the steps that require
user interaction in subsequent initializations of `DropboxApi::Client`. For
example:

```ruby
# 1. Initialize an authenticator
authenticator = DropboxApi::Authenticator.new(CLIENT_ID, CLIENT_SECRET)

# 2. Retrieve the token hash you previously stored somewhere safe, you can use
# it to build a new access token.
access_token = OAuth2::AccessToken.from_hash(authenticator, token_hash)

# 3. You now have an access token, so you can initialize a client like you
# would normally:
client = DropboxApi::Client.new(
access_token: access_token,
on_token_refreshed: lambda { |new_token_hash|
SomewhereSafe.save(new_token_hash)
}
)
```

### Performing API calls

Once you've initialized a client, for example:
Expand Down
8 changes: 2 additions & 6 deletions lib/dropbox_api/authenticator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,11 @@

module DropboxApi
class Authenticator < OAuth2::Client
extend Forwardable

def initialize(client_id, client_secret)
@auth_code = OAuth2::Client.new(client_id, client_secret, {
super(client_id, client_secret, {
authorize_url: 'https://www.dropbox.com/oauth2/authorize',
token_url: 'https://api.dropboxapi.com/oauth2/token'
}).auth_code
})
end

def_delegators :@auth_code, :authorize_url, :get_token
end
end
17 changes: 15 additions & 2 deletions lib/dropbox_api/client.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
# frozen_string_literal: true
module DropboxApi
class Client
def initialize(oauth_bearer = ENV['DROPBOX_OAUTH_BEARER'])
@connection_builder = ConnectionBuilder.new(oauth_bearer)
def initialize(
oauth_bearer = ENV['DROPBOX_OAUTH_BEARER'],
access_token: nil,
on_token_refreshed: nil
)
if access_token
@connection_builder = ConnectionBuilder.new(
access_token: access_token,
on_token_refreshed: on_token_refreshed
)
elsif oauth_bearer
@connection_builder = ConnectionBuilder.new(oauth_bearer)
else
raise ArgumentError, "Either oauth_bearer or access_token should be set"
end
end

def middleware
Expand Down
37 changes: 33 additions & 4 deletions lib/dropbox_api/connection_builder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,51 @@ module DropboxApi
class ConnectionBuilder
attr_accessor :namespace_id

def initialize(oauth_bearer)
@oauth_bearer = oauth_bearer
def initialize(oauth_bearer = nil, access_token: nil, on_token_refreshed: nil)
if access_token
if !access_token.is_a?(OAuth2::AccessToken)
raise ArgumentError, "access_token should be an OAuth2::AccessToken"
end

@access_token = access_token
@on_token_refreshed = on_token_refreshed
elsif oauth_bearer
@oauth_bearer = oauth_bearer
else
raise ArgumentError, "Either oauth_bearer or access_token should be set"
end
end

def middleware
@middleware ||= MiddleWare::Stack.new
end

def can_refresh_access_token?
@access_token && @access_token.refresh_token
end

def refresh_access_token
@access_token = @access_token.refresh!
@on_token_refreshed.call(@access_token.to_hash) if @on_token_refreshed
end

private def bearer
@oauth_bearer or oauth_bearer_from_access_token
end

private def oauth_bearer_from_access_token
refresh_access_token if @access_token.expired?

@access_token.token
end

def build(url)
Faraday.new(url) do |connection|
connection.use DropboxApi::MiddleWare::PathRoot, {
namespace_id: self.namespace_id
}
middleware.apply(connection) do
connection.authorization :Bearer, @oauth_bearer

connection.authorization :Bearer, bearer
yield connection
end
end
Expand Down
13 changes: 13 additions & 0 deletions lib/dropbox_api/endpoints/base.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
# frozen_string_literal: true
module DropboxApi::Endpoints
class Base
def initialize(builder)
@builder = builder
build_connection
end

def self.add_endpoint(name, &block)
define_method(name, block)
DropboxApi::Client.add_endpoint(name, self)
Expand All @@ -10,6 +15,14 @@ def self.add_endpoint(name, &block)

def perform_request(params)
process_response(get_response(params))
rescue DropboxApi::Errors::ExpiredAccessTokenError => e
if @builder.can_refresh_access_token?
@builder.refresh_access_token
build_connection
process_response(get_response(params))
else
raise e
end
end

def get_response(*args)
Expand Down
4 changes: 2 additions & 2 deletions lib/dropbox_api/endpoints/content_download.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# frozen_string_literal: true
module DropboxApi::Endpoints
class ContentDownload < DropboxApi::Endpoints::Base
def initialize(builder)
@connection = builder.build('https://content.dropboxapi.com') do |c|
def build_connection
@connection = @builder.build('https://content.dropboxapi.com') do |c|
c.response :decode_result
end
end
Expand Down
4 changes: 2 additions & 2 deletions lib/dropbox_api/endpoints/content_upload.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# frozen_string_literal: true
module DropboxApi::Endpoints
class ContentUpload < DropboxApi::Endpoints::Base
def initialize(builder)
@connection = builder.build('https://content.dropboxapi.com') do |c|
def build_connection
@connection = @builder.build('https://content.dropboxapi.com') do |c|
c.response :decode_result
end
end
Expand Down
4 changes: 2 additions & 2 deletions lib/dropbox_api/endpoints/rpc.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# frozen_string_literal: true
module DropboxApi::Endpoints
class Rpc < DropboxApi::Endpoints::Base
def initialize(builder)
@connection = builder.build('https://api.dropboxapi.com') do |c|
def build_connection
@connection = @builder.build('https://api.dropboxapi.com') do |c|
c.response :decode_result
end
end
Expand Down
4 changes: 2 additions & 2 deletions lib/dropbox_api/endpoints/rpc_content.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# frozen_string_literal: true
module DropboxApi::Endpoints
class RpcContent < DropboxApi::Endpoints::Rpc
def initialize(builder)
@connection = builder.build('https://content.dropboxapi.com') do |c|
def build_connection
@connection = @builder.build('https://content.dropboxapi.com') do |c|
c.response :decode_result
end
end
Expand Down
4 changes: 2 additions & 2 deletions lib/dropbox_api/endpoints/rpc_notify.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# frozen_string_literal: true
module DropboxApi::Endpoints
class RpcNotify < DropboxApi::Endpoints::Rpc
def initialize(builder)
@connection = builder.build('https://notify.dropboxapi.com') do |c|
def build_connection
@connection = @builder.build('https://notify.dropboxapi.com') do |c|
c.headers.delete 'Authorization'

c.response :decode_result
Expand Down
40 changes: 40 additions & 0 deletions spec/authenticator_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# frozen_string_literal: true
module DropboxApi
describe Authenticator do
before :each do
# These details belong to an account I manually created for testing
client_id = "CLIENT_ID"
client_secret = "CLIENT_SECRET"

@authenticator = DropboxApi::Authenticator.new(client_id, client_secret)
end

it 'successfully generates a short lived token', cassette: 'authenticator/success_with_short_lived_token' do
# At this point we could get an authorization URL with:
# `@authenticator.auth_code.authorize_url` # => 'https://www.dropbox...'

# The URL above gave us the following access code:
access_code = "ACCESS_CODEhVAVTMlCvO0Qs"

access_token = @authenticator.auth_code.get_token(access_code)

expect(access_token).to be_a(OAuth2::AccessToken)
expect(access_token.token).to eq("MOCK_ACCESS_TOKEN")
expect(access_token.refresh_token).to be_nil
end

it 'successfully generates a refresh token', cassette: 'authenticator/success_with_refresh_token' do
# We now get an authorization URL with:
# `@authenticator.auth_code.authorize_url(token_access_type: 'offline')`

# We got the following access code:
access_code = "ACCESS_CODEpLfs_y4vgnb3M"

access_token = @authenticator.auth_code.get_token(access_code)

expect(access_token).to be_a(OAuth2::AccessToken)
expect(access_token.token).to eq("MOCK_ACCESS_TOKEN")
expect(access_token.refresh_token).to eq("MOCK_REFRESH_TOKEN")
end
end
end
Loading

0 comments on commit 8fc2be2

Please sign in to comment.