diff --git a/README.md b/README.md index 7a5ca00..71e51a0 100644 --- a/README.md +++ b/README.md @@ -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) +=> # +``` + +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) +=> # +``` + +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 +=> # +``` + +You can also pass any valid OAuth params like `scope`: + +```ruby +Wealthsimple.get_application_token(scope: 'read write') +=> # +``` + ### Example usage See [samples directory](./samples) for a wide range of samples, or see the basic example below: @@ -20,26 +78,21 @@ Wealthsimple.configure do |config| config.env = :sandbox config.api_version = :v1 config.client_id = "" - - # Optional: Depending on grant_type may or may not be needed: config.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 = "" 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 ``` diff --git a/lib/wealthsimple.rb b/lib/wealthsimple.rb index d89f10b..3ca89be 100644 --- a/lib/wealthsimple.rb +++ b/lib/wealthsimple.rb @@ -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' diff --git a/lib/wealthsimple/authentication.rb b/lib/wealthsimple/authentication.rb new file mode 100644 index 0000000..1dd5472 --- /dev/null +++ b/lib/wealthsimple/authentication.rb @@ -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 diff --git a/lib/wealthsimple/configuration.rb b/lib/wealthsimple/configuration.rb index bc7f483..a202dc9 100644 --- a/lib/wealthsimple/configuration.rb +++ b/lib/wealthsimple/configuration.rb @@ -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] + ATTRIBUTES = [:auth, :redirect_uri] + REQUIRED_ATTRIBUTES attr_accessor *ATTRIBUTES def validate! diff --git a/lib/wealthsimple/errors.rb b/lib/wealthsimple/errors.rb index 0c2f16b..469f9bd 100644 --- a/lib/wealthsimple/errors.rb +++ b/lib/wealthsimple/errors.rb @@ -13,4 +13,7 @@ def method_missing(method_name, *args, &block) @response.send(method_name, *args, &block) end end + + class AuthenticationError < StandardError + end end diff --git a/lib/wealthsimple/request.rb b/lib/wealthsimple/request.rb index 8b0d43c..4be89de 100644 --- a/lib/wealthsimple/request.rb +++ b/lib/wealthsimple/request.rb @@ -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) @@ -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| @@ -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 diff --git a/samples/authenticated_request.rb b/samples/authenticated_request.rb index 9de17f2..21d5f06 100644 --- a/samples/authenticated_request.rb +++ b/samples/authenticated_request.rb @@ -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 diff --git a/samples/error_handling.rb b/samples/error_handling.rb index ec427d6..2eb13ab 100644 --- a/samples/error_handling.rb +++ b/samples/error_handling.rb @@ -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 diff --git a/spec/request_spec.rb b/spec/request_spec.rb index 8c27e25..f283131 100644 --- a/spec/request_spec.rb +++ b/spec/request_spec.rb @@ -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 diff --git a/wealthsimple.gemspec b/wealthsimple.gemspec index 48304e9..3051a5e 100644 --- a/wealthsimple.gemspec +++ b/wealthsimple.gemspec @@ -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"