Skip to content
This repository was archived by the owner on Feb 3, 2022. It is now read-only.
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
81 changes: 67 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,64 @@ Add the following to your `Gemfile` and run `bundle install`:
gem 'wealthsimple', git: 'https://github.com/wealthsimple/wealthsimple-ruby.git'
```

### Authentication

The Wealthsimple API uses OAuth 2.0 for authentication. Presently this gem only supports Client Credentials and Authorization Code flows.

#### Authorization Code

To generate the authorization URL to display to or redirect your users:

```ruby
Wealthsimple.authorize_url
# => "https://staging.wealthsimple.com/app/authorize?client_id=58a99e4862a1b246a7745523ca230e61dd7feff351056fcb22c73a5d7a2fcd69&redirect_uri=urn:ietf:wg:oauth:2.0:oob&response_type=code"
```

You can also pass any valid OAuth params like `state` or `scope`:

```ruby
Wealthsimple.authorize_url(state: 123, scope: 'read write')
# => "https://staging.wealthsimple.com/app/authorize?client_id=58a99e4862a1b246a7745523ca230e61dd7feff351056fcb22c73a5d7a2fcd69&redirect_uri=urn:ietf:wg:oauth:2.0:oob&response_type=code&state=123&scope=read%20write"
```

To obtain access token (and refresh token if applicable) after the redirect back to your application:

```ruby
code = params[:code] # The authorization URL will have called back to your app with this
Wealthsimple.get_token(code)
=> #<OAuth2::AccessToken:0x00007ff2d50c9070 ...>
```

You can save this access token object in whichever way you like and restore it to continue a user session:

```ruby
save_context_to_db(my_user_id, Wealthsimple.auth.to_hash)
=> true

# wait 10 hours

Wealthsimple.auth = load_context_to_db(my_user_id)
=> #<OAuth2::AccessToken:0x00007ff2d50c9070 ...>
```

If you're allowed to use refresh tokens the gem will refresh its access tokens as needed.

### Application Credentials

You can authenticate as your application instead of any particular user.

```ruby
Wealthsimple.get_application_token
=> #<OAuth2::AccessToken:0x00007ff2d50c9070 ...>
```

You can also pass any valid OAuth params like `scope`:

```ruby
Wealthsimple.get_application_token(scope: 'read write')
=> #<OAuth2::AccessToken:0x00007ff2d50c9070 ...>
```

### Example usage

See [samples directory](./samples) for a wide range of samples, or see the basic example below:
Expand All @@ -20,26 +78,21 @@ Wealthsimple.configure do |config|
config.env = :sandbox
config.api_version = :v1
config.client_id = "<oauth_client_id>"

# Optional: Depending on grant_type may or may not be needed:
config.client_secret = "<oauth_client_secret>"

# Optional: If available, you can optionally specify a previous auth response
# so that the user does not have to login again:
config.auth = { ...prior server response... }
# Optional: You can pass it to Wealthsimple.authorize_url as a param and Wealthsimple.get_token as a second parameter instead.
config.redirect_uri = "<oauth_client_redirect_uri>"
end

url = Wealthsimple.authorize_url(scope: 'read write')
pp url # display this URL to your user

# Have a callback controller parse the authorization code
Wealthsimple.get_token(params[:code])

health = Wealthsimple.get("/healthcheck")
pp health.resource

auth = Wealthsimple.authenticate({
grant_type: "password",
scope: "read write",
username: "peter@example.com",
password: "abc123$",
})
pp auth.resource

user = Wealthsimple.get("/users/#{auth.resource.resource_owner_id}")
user = Wealthsimple.get("/users/#{Wealthsimple.user_id}")
pp user.resource
```
2 changes: 2 additions & 0 deletions lib/wealthsimple.rb
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
require 'active_support'
require 'active_support/core_ext'
require 'oauth2'
require 'faraday'
require 'json'
require 'recursive-open-struct'

require 'wealthsimple/version'
require 'wealthsimple/configuration'
require 'wealthsimple/authentication'
require 'wealthsimple/errors'
require 'wealthsimple/request'
require 'wealthsimple/response'
Expand Down
76 changes: 76 additions & 0 deletions lib/wealthsimple/authentication.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
module Wealthsimple
def self.authorize_url(params = {})
oauth_client.auth_code.authorize_url({ redirect_uri: config.redirect_uri }.merge(params))
end

def self.get_token(token, redirect_uri = nil)
config.auth = oauth_client.auth_code.get_token(token, redirect_uri: redirect_uri || config.redirect_uri)
end

def self.get_application_token(params = {})
access_token = oauth_client.client_credentials.get_token(params)
# abuse OAuth2 gem a bit, since we can't tell the context of how it was
# obtained and need to "refresh" client credentials automatically
access_token.instance_variable_get(:@params).merge!(_client_credentials: true, _params: params)
config.auth = access_token
end

def self.user_id
auth['resource_owner_id']
end

def self.client_id
auth['client_canonical_id']
end

def self.auth
raise AuthenticationError, 'Not authenticated' unless config.auth
if config.auth.expired?
return config.auth = config.auth.refresh! if config.auth.refresh_token
return get_application_token(config.auth[:_params]) if config.auth[:_client_credentials]
raise AuthenticationError, 'Access token expired'
end
config.auth
end

def self.auth=(auth_or_refresh_token)
case auth_or_refresh_token
when OAuth2::AccessToken
config.auth = auth_or_refresh_token
when Hash
config.auth = OAuth2::AccessToken.from_hash(
oauth_client,
auth_or_refresh_token
)
when String
config.auth = OAuth2::AccessToken.from_hash(
oauth_client,
refresh_token: auth_or_refresh_token
).refresh!
else
raise AuthenticationError, "Unknown auth value: #{auth_or_refresh_token}"
end
config.auth
end

def self.oauth_client
Wealthsimple.config.validate!
@oauth_client ||= OAuth2::Client.new(
Wealthsimple.config.client_id,
Wealthsimple.config.client_secret,
site: api_base_url,
authorize_url: "#{site_base_url}/authorize",
token_url: "#{api_base_url}/v1/oauth/token"
)
end

def self.api_base_url
Wealthsimple.config.validate!
"https://api.#{Wealthsimple.config.env}.wealthsimple.com"
end

def self.site_base_url
Wealthsimple.config.validate!
Wealthsimple.config.env == 'production' ? 'https://my.wealthsimple.com' : 'https://staging.wealthsimple.com'
end
end
4 changes: 2 additions & 2 deletions lib/wealthsimple/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ def self.reset_config!
end

class Configuration
REQUIRED_ATTRIBUTES = [:env, :api_version, :client_id]
ATTRIBUTES = [:client_secret, :auth] + REQUIRED_ATTRIBUTES
REQUIRED_ATTRIBUTES = [:env, :api_version, :client_id, :client_secret]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there no case where client_secret would be optional? (for ruby integrations)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not unless they wanted to use Implicit or Password.

For Implicit it makes no sense to use as that's less privileged than Authorization Code.

For Password we want to steer people away from that and will likely remove it from our API offering

ATTRIBUTES = [:auth, :redirect_uri] + REQUIRED_ATTRIBUTES
attr_accessor *ATTRIBUTES

def validate!
Expand Down
3 changes: 3 additions & 0 deletions lib/wealthsimple/errors.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,7 @@ def method_missing(method_name, *args, &block)
@response.send(method_name, *args, &block)
end
end

class AuthenticationError < StandardError
end
end
19 changes: 2 additions & 17 deletions lib/wealthsimple/request.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,6 @@ module Wealthsimple
end
end

def self.authenticate(oauth_details)
body = {
client_id: config.client_id,
client_secret: config.client_secret,
}.merge(oauth_details)
response = post("/oauth/token", { body: body })
config.auth = response.to_h
response
end

class Request
attr_reader :extra_attributes
def initialize(method:, path:, headers: {}, query: {}, body: nil, **extra_attributes)
Expand All @@ -31,7 +21,7 @@ def initialize(method:, path:, headers: {}, query: {}, body: nil, **extra_attrib
end

def execute
connection = Faraday.new(url: base_url) do |faraday|
connection = Faraday.new(url: Wealthsimple.api_base_url) do |faraday|
faraday.adapter Faraday.default_adapter
end
response = connection.send(@method) do |request|
Expand Down Expand Up @@ -60,19 +50,14 @@ def execute

private

def base_url
"https://api.#{Wealthsimple.config.env}.wealthsimple.com"
end

def headers
headers = {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Date': Time.now.utc.httpdate,
}.merge(@headers)
# TODO: check auth expiration
if Wealthsimple.config.auth.present?
headers['Authorization'] = "Bearer #{Wealthsimple.config.auth['access_token']}"
headers['Authorization'] = "Bearer #{Wealthsimple.auth.token}"
end
headers
end
Expand Down
31 changes: 18 additions & 13 deletions samples/authenticated_request.rb
Original file line number Diff line number Diff line change
@@ -1,21 +1,26 @@
require "bundler/setup"
require "wealthsimple"
require "pry"
require "dotenv/load"
require 'bundler/setup'
require 'wealthsimple'
require 'pry'
require 'dotenv/load'

Wealthsimple.configure do |config|
config.env = :sandbox
config.api_version = :v1
config.client_id = "58a99e4862a1b246a7745523ca230e61dd7feff351056fcb22c73a5d7a2fcd69"
config.client_id = '58a99e4862a1b246a7745523ca230e61dd7feff351056fcb22c73a5d7a2fcd69'
config.client_secret = '3f3741cfad562a3d29d2b1835efed25e55d82f062fb4b53178e9679d9f248f20'
config.redirect_uri = 'urn:ietf:wg:oauth:2.0:oob'
end

auth = Wealthsimple.authenticate({
"grant_type": "password",
"username": ENV["EMAIL"],
"password": ENV["PASSWORD"],
"scope": "read write",
})
pp auth.resource
if ENV['AUTHORIZATION_CODE']
Wealthsimple.get_token ENV['AUTHORIZATION_CODE']
elsif ENV['REFRESH_TOKEN']
Wealthsimple.auth = ENV['REFRESH_TOKEN']
else
puts "Visit this URL in your browser:\n\n#{Wealthsimple.authorize_url}\n\n"
print 'What is the authorization code: '
code = gets.strip
Wealthsimple.get_token code
end

user = Wealthsimple.get("/users/#{auth.resource.resource_owner_id}")
user = Wealthsimple.get("/users/#{Wealthsimple.user_id}")
pp user.resource
30 changes: 18 additions & 12 deletions samples/error_handling.rb
Original file line number Diff line number Diff line change
@@ -1,23 +1,29 @@
require "bundler/setup"
require "wealthsimple"
require "pry"
require "dotenv/load"
require 'bundler/setup'
require 'wealthsimple'
require 'pry'
require 'dotenv/load'

Wealthsimple.configure do |config|
config.env = :sandbox
config.api_version = :v1
config.client_id = "58a99e4862a1b246a7745523ca230e61dd7feff351056fcb22c73a5d7a2fcd69"
config.client_id = '58a99e4862a1b246a7745523ca230e61dd7feff351056fcb22c73a5d7a2fcd69'
config.client_secret = '3f3741cfad562a3d29d2b1835efed25e55d82f062fb4b53178e9679d9f248f20'
config.redirect_uri = 'urn:ietf:wg:oauth:2.0:oob'
end

auth = Wealthsimple.authenticate({
"grant_type": "password",
"username": ENV["EMAIL"],
"password": ENV["PASSWORD"],
"scope": "read write",
})
if ENV['AUTHORIZATION_CODE']
Wealthsimple.get_token ENV['AUTHORIZATION_CODE']
elsif ENV['REFRESH_TOKEN']
Wealthsimple.auth = ENV['REFRESH_TOKEN']
else
puts "Visit this URL in your browser:\n#{Wealthsimple.authorize_url}\n\n"
print 'What is the authorization code: '
code = gets.strip
Wealthsimple.get_token code
end

begin
user = Wealthsimple.get("/users/invalid")
Wealthsimple.get('/users/invalid')
rescue Wealthsimple::ApiError => e
pp e.status, e.resource.message
pp e.to_h
Expand Down
1 change: 1 addition & 0 deletions spec/request_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
config.env = :production
config.api_version = "v1"
config.client_id = "oauth_client_1"
config.client_secret = "oauth_client_secret_1"
end
end

Expand Down
1 change: 1 addition & 0 deletions wealthsimple.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ Gem::Specification.new do |s|
s.license = "MIT"
s.required_ruby_version = ">= 2.3"

s.add_runtime_dependency 'oauth2'
s.add_runtime_dependency 'faraday'
s.add_runtime_dependency 'json'
s.add_runtime_dependency "activesupport"
Expand Down