mirror of
https://github.com/we-promise/sure.git
synced 2026-07-20 00:35:22 +00:00
Add SnapTrade OAuth device flow (#2494)
* Add SnapTrade OAuth device flow * Add SnapTrade OAuth device flow ### Motivation - Integrate SnapTrade device-code OAuth so administrators can start a device authorization flow and poll for tokens using SnapTrade's well-known OAuth metadata endpoint. - Persist and encrypt device-flow token material on `SnaptradeItem` to support long-lived API calls and future refresh handling. ### Description - Add OAuth discovery and device-code support to the SnapTrade provider with `oauth_authorization_server_metadata`, `start_device_authorization`, and `poll_device_token` helper methods and shared `oauth_connection` / `parse_oauth_response` helpers. - Persist OAuth device-flow tokens on `snaptrade_items` via a migration that adds `oauth_access_token`, `oauth_refresh_token`, `oauth_token_type`, `oauth_scope`, and `oauth_token_expires_at`, and update `db/schema.rb` accordingly. - Extend `SnaptradeItem` to encrypt stored `oauth_access_token` and `oauth_refresh_token` when ActiveRecord encryption is configured and add `oauth_token_active?` to check token validity. - Add high-level `start_oauth_device_flow` and `complete_oauth_device_flow!` helpers to the `SnaptradeItem::Provided` concern to start authorization and persist token responses. - Expose admin-only member routes and JSON controller actions `start_oauth_device_flow` and `complete_oauth_device_flow` on `SnaptradeItemsController` to drive the device flow from the UI. - Add focused Minitest coverage: `test/models/provider/snaptrade_oauth_test.rb` for provider requests and error handling and `test/models/snaptrade_item_oauth_test.rb` for token persistence. ### Testing - Ran the targeted test suite: `bin/rails test test/models/provider/snaptrade_oauth_test.rb test/models/snaptrade_item_oauth_test.rb test/controllers/snaptrade_items_controller_test.rb`, and all tests passed. - Ran `bin/rubocop` against modified files and no offenses were reported. * Add SnapTrade OAuth device flow ### Motivation - Integrate SnapTrade device-code OAuth so administrators can start a device authorization flow and poll for tokens using SnapTrade's well-known OAuth metadata endpoint. - Persist and encrypt device-flow token material on `SnaptradeItem` to support long-lived API calls and future refresh handling. ### Description - Add OAuth discovery and device-code support to the SnapTrade provider with `oauth_authorization_server_metadata`, `start_device_authorization`, and `poll_device_token` helper methods and shared `oauth_connection` / `parse_oauth_response` helpers. - Persist OAuth device-flow tokens on `snaptrade_items` via a migration that adds `oauth_access_token`, `oauth_refresh_token`, `oauth_token_type`, `oauth_scope`, and `oauth_token_expires_at`, and update `db/schema.rb` accordingly. - Extend `SnaptradeItem` to encrypt stored `oauth_access_token` and `oauth_refresh_token` when ActiveRecord encryption is configured and add `oauth_token_active?` to check token validity. - Add high-level `start_oauth_device_flow` and `complete_oauth_device_flow!` helpers to the `SnaptradeItem::Provided` concern to start authorization and persist token responses. - Expose admin-only member routes and JSON controller actions `start_oauth_device_flow` and `complete_oauth_device_flow` on `SnaptradeItemsController` to drive the device flow from the UI. - Add focused Minitest coverage: `test/models/provider/snaptrade_oauth_test.rb` for provider requests and error handling and `test/models/snaptrade_item_oauth_test.rb` for token persistence. ### Testing - Ran the targeted test suite: `bin/rails test test/models/provider/snaptrade_oauth_test.rb test/models/snaptrade_item_oauth_test.rb test/controllers/snaptrade_items_controller_test.rb`, and all tests passed. - Ran `bin/rubocop` against modified files and no offenses were reported. * Add SnapTrade OAuth device flow ### Motivation - Integrate SnapTrade device-code OAuth so administrators can start a device authorization flow and poll for tokens using SnapTrade's well-known OAuth metadata endpoint. - Persist and encrypt device-flow token material on `SnaptradeItem` to support long-lived API calls and future refresh handling. ### Description - Add OAuth discovery and device-code support to the SnapTrade provider with `oauth_authorization_server_metadata`, `start_device_authorization`, and `poll_device_token` helper methods and shared `oauth_connection` / `parse_oauth_response` helpers. - Persist OAuth device-flow tokens on `snaptrade_items` via a migration that adds `oauth_access_token`, `oauth_refresh_token`, `oauth_token_type`, `oauth_scope`, and `oauth_token_expires_at`, and update `db/schema.rb` accordingly. - Extend `SnaptradeItem` to encrypt stored `oauth_access_token` and `oauth_refresh_token` when ActiveRecord encryption is configured and add `oauth_token_active?` to check token validity. - Add high-level `start_oauth_device_flow` and `complete_oauth_device_flow!` helpers to the `SnaptradeItem::Provided` concern to start authorization and persist token responses. - Expose admin-only member routes and JSON controller actions `start_oauth_device_flow` and `complete_oauth_device_flow` on `SnaptradeItemsController` to drive the device flow from the UI. - Add focused Minitest coverage: `test/models/provider/snaptrade_oauth_test.rb` for provider requests and error handling and `test/models/snaptrade_item_oauth_test.rb` for token persistence. ### Testing - Ran the targeted test suite: `bin/rails test test/models/provider/snaptrade_oauth_test.rb test/models/snaptrade_item_oauth_test.rb test/controllers/snaptrade_items_controller_test.rb`, and all tests passed. - Ran `bin/rubocop` against modified files and no offenses were reported. * Add SnapTrade OAuth device flow ### Motivation - Integrate SnapTrade device-code OAuth so administrators can start a device authorization flow and poll for tokens using SnapTrade's well-known OAuth metadata endpoint. - Persist and encrypt device-flow token material on `SnaptradeItem` to support long-lived API calls and future refresh handling. ### Description - Add OAuth discovery and device-code support to the SnapTrade provider with `oauth_authorization_server_metadata`, `start_device_authorization`, and `poll_device_token` helper methods and shared `oauth_connection` / `parse_oauth_response` helpers. - Persist OAuth device-flow tokens on `snaptrade_items` via a migration that adds `oauth_access_token`, `oauth_refresh_token`, `oauth_token_type`, `oauth_scope`, and `oauth_token_expires_at`, and update `db/schema.rb` accordingly. - Extend `SnaptradeItem` to encrypt stored `oauth_access_token` and `oauth_refresh_token` when ActiveRecord encryption is configured and add `oauth_token_active?` to check token validity. - Add high-level `start_oauth_device_flow` and `complete_oauth_device_flow!` helpers to the `SnaptradeItem::Provided` concern to start authorization and persist token responses. - Expose admin-only member routes and JSON controller actions `start_oauth_device_flow` and `complete_oauth_device_flow` on `SnaptradeItemsController` to drive the device flow from the UI. - Add focused Minitest coverage: `test/models/provider/snaptrade_oauth_test.rb` for provider requests and error handling and `test/models/snaptrade_item_oauth_test.rb` for token persistence. ### Testing - Ran the targeted test suite: `bin/rails test test/models/provider/snaptrade_oauth_test.rb test/models/snaptrade_item_oauth_test.rb test/controllers/snaptrade_items_controller_test.rb`, and all tests passed. - Ran `bin/rubocop` against modified files and no offenses were reported. * Add SnapTrade OAuth device flow (provider, model, controller, routes, migration, tests) ### Motivation - Add support for SnapTrade OAuth 2.0 device authorization flow so users can authorize SnapTrade via device codes in addition to the existing portal flow. - Persist OAuth token metadata on `SnaptradeItem` for subsequent polling and usage by the provider and UI. ### Description - Implemented OAuth device flow in the provider with `oauth_authorization_server_metadata`, `start_device_authorization`, and `poll_device_token`, plus HTTP helper methods `oauth_connection`, `oauth_client_id`, and `parse_oauth_response` in `Provider::Snaptrade`. - Added controller endpoints `start_oauth_device_flow` and `complete_oauth_device_flow` in `SnaptradeItemsController` with robust error handling and helpers `oauth_error_payload` and `parse_oauth_error_body` to surface OAuth error fields. - Extended `SnaptradeItem` with encrypted columns for `oauth_access_token`, `oauth_refresh_token`, token metadata, `oauth_token_active?`, and model methods `start_oauth_device_flow` and `complete_oauth_device_flow!` to store token metadata. - Added migration `AddOauthDeviceFlowToSnaptradeItems` and updated `db/schema.rb` to include the new columns, registered a new initializer `config/initializers/snaptrade.rb` to read `SNAPTRADE_OAUTH_CLIENT_ID`, and updated `.env*.example` files to document `SNAPTRADE_OAUTH_CLIENT_ID`. - Exposed new routes `start_oauth_device_flow` and `complete_oauth_device_flow` for `snaptrade_items`. ### Testing - Added unit tests for provider OAuth behavior in `test/models/provider/snaptrade_oauth_test.rb`, model token persistence in `test/models/snaptrade_item_oauth_test.rb`, and controller error propagation in `test/controllers/snaptrade_items_controller_test.rb`. - Ran the test suite with `bin/rails test` and the full test run (including the new SnapTrade OAuth tests) passed. * Add SnapTrade OAuth device flow (start/poll), store tokens, and tests ### Motivation - Add support for SnapTrade OAuth Device Authorization flow so administrators can start device authorization and poll for tokens without exposing provider internals. - Persist OAuth token metadata on `SnaptradeItem` so the app can reuse and surface token state for SnapTrade integrations. - Improve error handling and sanitization for OAuth API errors returned by SnapTrade to avoid leaking upstream internals. ### Description - Introduces new environment examples and initializer: adds `SNAPTRADE_OAUTH_CLIENT_ID` to `.env.local.example`/`.env.test.example` and configures `Rails.configuration.x.snaptrade.oauth_client_id` in `config/initializers/snaptrade.rb`. - Extends `Provider::Snaptrade` with OAuth device flow support, adding discovery URL, device grant constant, Faraday `oauth_connection`, and methods `oauth_authorization_server_metadata`, `start_device_authorization`, `poll_device_token`, `oauth_client_id`, and `parse_oauth_response`, plus improved retry and API error wrapping. - Adds DB migration and schema changes to store OAuth fields on `snaptrade_items` (`oauth_access_token`, `oauth_refresh_token`, `oauth_token_type`, `oauth_scope`, `oauth_token_expires_at`) and marks those attributes encrypted when ActiveRecord encryption is enabled. - Adds `SnaptradeItem` helpers `start_oauth_device_flow`, `complete_oauth_device_flow!`, and `oauth_token_active?` to start/poll and persist token metadata. - Adds controller actions `start_oauth_device_flow` and `complete_oauth_device_flow` with sanitized error responses and helper methods `oauth_error_payload` and `parse_oauth_error_body`, and wires new member routes in `config/routes.rb`. - Updates `SnaptradeItemsController` before_action lists to include the new actions and adds user-facing error message helper `start_oauth_device_flow_error_message`. ### Testing - Added unit tests `test/models/provider/snaptrade_oauth_test.rb` to validate discovery, device authorization request, token polling, missing client ID handling, and OAuth error propagation, and all assertions passed. - Added `test/models/snaptrade_item_oauth_test.rb` to assert `complete_oauth_device_flow!` stores tokens and expiry and that `oauth_token_active?` reports correctly, and the test passed. - Extended `test/controllers/snaptrade_items_controller_test.rb` with controller-level tests confirming sanitized OAuth error payloads and behavior for start/complete endpoints, and these controller tests passed. * Add SnapTrade OAuth device flow support and token storage ### Motivation - Add support for the OAuth 2.0 device authorization flow for SnapTrade so users can link brokerages via a device-code flow without traditional browser-based client redirects. - Persist OAuth token metadata on the SnaptradeItem so tokens can be reused and expiration tracked. - Surface and sanitize provider error payloads to callers while avoiding leakage of internal configuration details. ### Description - Added new DB columns and a migration (`oauth_access_token`, `oauth_refresh_token`, `oauth_token_type`, `oauth_scope`, `oauth_token_expires_at`) and updated `db/schema.rb` to reflect the change. - Encrypted the new token fields on `SnaptradeItem` and added `oauth_token_active?`, `start_oauth_device_flow`, and `complete_oauth_device_flow!` helpers to the model. - Implemented OAuth logic in `Provider::Snaptrade` including discovery (`OAUTH_DISCOVERY_URL`), `start_device_authorization`, `poll_device_token`, request parsing, retry handling, and a small Faraday connection wrapper; added configuration accessors for `oauth_client_id` via `config.x.snaptrade`. - Added controller endpoints `start_oauth_device_flow` and `complete_oauth_device_flow` with safe error handling and helpers to format OAuth error payloads, and registered new member routes for `snaptrade_items`. - Added an initializer to load `SNAPTRADE_OAUTH_CLIENT_ID` into `Rails.configuration.x.snaptrade`, and updated `.env.local.example` and `.env.test.example` to include the new env var. - Added unit tests for provider OAuth behavior (`test/models/provider/snaptrade_oauth_test.rb`), SnaptradeItem token persistence (`test/models/snaptrade_item_oauth_test.rb`), and controller error handling (`test/controllers/snaptrade_items_controller_test.rb`), and updated controller tests accordingly. ### Testing - Ran provider-level tests in `Provider::SnaptradeOauthTest` to validate discovery, device authorization, token polling, and error handling, which passed. - Ran model tests in `SnaptradeItemOauthTest` to verify token metadata is stored and `oauth_token_active?` works, which passed. - Ran controller tests in `SnaptradeItemsControllerTest` that exercise the start/complete endpoints and error sanitization, which passed. * Add SnapTrade OAuth device-flow support and token storage ### Motivation - Add support for SnapTrade OAuth device authorization so users can perform device-code based OAuth without embedding secrets in the browser. - Persist OAuth token metadata on `SnaptradeItem` and expose programmatic start/complete endpoints while avoiding leaking provider internals on errors. - Make OAuth client ID configurable via environment or credentials for Sure deployments. ### Description - Introduce `SNAPTRADE_OAUTH_CLIENT_ID` to `.env` examples and add `config.x.snaptrade.oauth_client_id` initializer for configuration. - Add migration `AddOauthDeviceFlowToSnaptradeItems` and update `schema.rb` to add `oauth_access_token`, `oauth_refresh_token`, `oauth_token_type`, `oauth_scope`, and `oauth_token_expires_at` to `snaptrade_items`. - Persist and encrypt new token fields in `SnaptradeItem` and add helper methods `oauth_token_active?`, `start_oauth_device_flow`, and `complete_oauth_device_flow!`. - Extend `Provider::Snaptrade` with OAuth discovery, device authorization (`start_device_authorization`), token polling (`poll_device_token`), Faraday-based `oauth_connection`, parsing and error-wrapping logic, and retry handling. - Add controller actions `start_oauth_device_flow` and `complete_oauth_device_flow` to `SnaptradeItemsController`, plus helper methods to format OAuth error payloads and user-facing error messages. - Wire up new routes (`post :start_oauth_device_flow`, `post :complete_oauth_device_flow`) and add `config/initializers/snaptrade.rb`. - Add comprehensive tests for the OAuth flow and controller error handling and update a system test stub to avoid provider leakage during UI tests. ### Testing - Added `Provider::SnaptradeOauthTest` which stubs discovery, device authorization and token endpoints and asserts request payloads and responses; tests passed. - Added `SnaptradeItemOauthTest` which verifies token metadata persistence and `oauth_token_active?`; test passed. - Extended `SnaptradeItemsControllerTest` with scenarios for successful and failing device-flow completion and start flow error handling; tests passed. - Ran the affected system test changes in `TradesTest` (provider stubbing and modal behavior); the updated tests passed.
This commit is contained in:
@@ -12,6 +12,9 @@ SIMPLEFIN_DEBUG_RAW=false
|
||||
# SIMPLEFIN_INCLUDE_PENDING: when truthy, forces `pending=1` on SimpleFIN fetches when caller doesn't specify `pending:`
|
||||
SIMPLEFIN_INCLUDE_PENDING=false
|
||||
|
||||
# SnapTrade OAuth device flow client ID (public OAuth client identifier, configured by Sure deployments)
|
||||
SNAPTRADE_OAUTH_CLIENT_ID=
|
||||
|
||||
# Lunchflow runtime flags (default-off)
|
||||
# LUNCHFLOW_DEBUG_RAW: when truthy, logs the raw payload returned by Lunchflow (debug-only; can be noisy)
|
||||
LUNCHFLOW_DEBUG_RAW=false
|
||||
|
||||
@@ -11,6 +11,9 @@ SIMPLEFIN_DEBUG_RAW=false
|
||||
# SIMPLEFIN_INCLUDE_PENDING: when truthy, forces `pending=1` on SimpleFIN fetches when caller doesn't specify `pending:`
|
||||
SIMPLEFIN_INCLUDE_PENDING=false
|
||||
|
||||
# SnapTrade OAuth device flow client ID (public OAuth client identifier, configured by Sure deployments)
|
||||
SNAPTRADE_OAUTH_CLIENT_ID=
|
||||
|
||||
# Lunchflow runtime flags (default-off)
|
||||
# LUNCHFLOW_DEBUG_RAW: when truthy, logs the raw payload returned by Lunchflow (debug-only; can be noisy)
|
||||
LUNCHFLOW_DEBUG_RAW=false
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
class SnaptradeItemsController < ApplicationController
|
||||
before_action :set_snaptrade_item, only: [ :show, :edit, :update, :destroy, :sync, :connect, :setup_accounts, :complete_account_setup, :connections, :delete_connection, :delete_orphaned_user ]
|
||||
before_action :require_admin!, only: [ :new, :create, :preload_accounts, :select_accounts, :link_accounts, :select_existing_account, :link_existing_account, :edit, :update, :destroy, :sync, :connect, :callback, :setup_accounts, :complete_account_setup, :connections, :delete_connection, :delete_orphaned_user ]
|
||||
before_action :set_snaptrade_item, only: [ :show, :edit, :update, :destroy, :sync, :connect, :setup_accounts, :complete_account_setup, :connections, :start_oauth_device_flow, :complete_oauth_device_flow, :delete_connection, :delete_orphaned_user ]
|
||||
before_action :require_admin!, only: [ :new, :create, :preload_accounts, :select_accounts, :link_accounts, :select_existing_account, :link_existing_account, :edit, :update, :destroy, :sync, :connect, :callback, :setup_accounts, :complete_account_setup, :connections, :start_oauth_device_flow, :complete_oauth_device_flow, :delete_connection, :delete_orphaned_user ]
|
||||
|
||||
def index
|
||||
@snaptrade_items = Current.family.snaptrade_items.ordered
|
||||
@@ -257,6 +257,36 @@ class SnaptradeItemsController < ApplicationController
|
||||
}
|
||||
end
|
||||
|
||||
def start_oauth_device_flow
|
||||
render json: @snaptrade_item.start_oauth_device_flow(scope: params[:scope].presence || "read")
|
||||
rescue ActiveRecord::Encryption::Errors::Decryption => e
|
||||
Rails.logger.error "SnapTrade decryption error for item #{@snaptrade_item.id}: #{e.class} - #{e.message}"
|
||||
render json: { error: t("snaptrade_items.connect.decryption_failed") }, status: :unprocessable_entity
|
||||
rescue Provider::Snaptrade::Error => e
|
||||
Rails.logger.error "SnapTrade OAuth device authorization error: #{e.class} - #{e.message}"
|
||||
render json: { error: start_oauth_device_flow_error_message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def complete_oauth_device_flow
|
||||
if params[:device_code].blank?
|
||||
render json: { error: "device_code is required" }, status: :unprocessable_entity
|
||||
return
|
||||
end
|
||||
|
||||
token_response = @snaptrade_item.complete_oauth_device_flow!(device_code: params[:device_code])
|
||||
render json: {
|
||||
token_type: token_response["token_type"],
|
||||
scope: token_response["scope"],
|
||||
expires_in: token_response["expires_in"],
|
||||
expires_at: @snaptrade_item.oauth_token_expires_at&.iso8601
|
||||
}
|
||||
rescue Provider::Snaptrade::ApiError => e
|
||||
render json: oauth_error_payload(e), status: e.status_code || :unprocessable_entity
|
||||
rescue Provider::Snaptrade::Error, ActiveRecord::ActiveRecordError, ActiveRecord::Encryption::Errors::Base => e
|
||||
Rails.logger.error "SnapTrade OAuth device token error: #{e.class} - #{e.message}"
|
||||
render json: { error: complete_oauth_device_flow_error_message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
# Delete a brokerage connection
|
||||
def delete_connection
|
||||
authorization_id = params[:authorization_id]
|
||||
@@ -507,6 +537,36 @@ class SnaptradeItemsController < ApplicationController
|
||||
{ connections: [], orphaned_users: [] }
|
||||
end
|
||||
|
||||
def oauth_error_payload(error)
|
||||
parsed_body = parse_oauth_error_body(error.response_body)
|
||||
payload = parsed_body.slice("error", "error_description", "error_uri", "interval")
|
||||
payload["error"] ||= error.message
|
||||
payload
|
||||
end
|
||||
|
||||
def start_oauth_device_flow_error_message
|
||||
t(
|
||||
"snaptrade_items.start_oauth_device_flow.failed",
|
||||
default: "Unable to start SnapTrade OAuth device authorization. Please try again."
|
||||
)
|
||||
end
|
||||
|
||||
def complete_oauth_device_flow_error_message
|
||||
t(
|
||||
"snaptrade_items.complete_oauth_device_flow.failed",
|
||||
default: "Unable to complete SnapTrade OAuth device authorization. Please try again."
|
||||
)
|
||||
end
|
||||
|
||||
def parse_oauth_error_body(response_body)
|
||||
return {} if response_body.blank?
|
||||
|
||||
parsed_body = JSON.parse(response_body)
|
||||
parsed_body.is_a?(Hash) ? parsed_body : {}
|
||||
rescue JSON::ParserError
|
||||
{}
|
||||
end
|
||||
|
||||
def link_snaptrade_account(snaptrade_account)
|
||||
# Determine account type based on SnapTrade account type
|
||||
accountable_type = infer_accountable_type(snaptrade_account.account_type)
|
||||
|
||||
@@ -16,19 +16,72 @@ class Provider::Snaptrade
|
||||
MAX_RETRIES = 3
|
||||
INITIAL_RETRY_DELAY = 2 # seconds
|
||||
MAX_RETRY_DELAY = 30 # seconds
|
||||
OAUTH_DISCOVERY_URL = "https://api.snaptrade.com/.well-known/oauth-authorization-server".freeze
|
||||
DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code".freeze
|
||||
|
||||
attr_reader :client
|
||||
attr_reader :client, :client_id, :consumer_key
|
||||
|
||||
def initialize(client_id:, consumer_key:)
|
||||
raise ConfigurationError, "client_id is required" if client_id.blank?
|
||||
raise ConfigurationError, "consumer_key is required" if consumer_key.blank?
|
||||
|
||||
@client_id = client_id
|
||||
@consumer_key = consumer_key
|
||||
|
||||
configuration = SnapTrade::Configuration.new
|
||||
configuration.client_id = client_id
|
||||
configuration.consumer_key = consumer_key
|
||||
@client = SnapTrade::Client.new(configuration)
|
||||
end
|
||||
|
||||
def oauth_authorization_server_metadata
|
||||
with_retries("oauth_authorization_server_metadata") do
|
||||
response = oauth_connection.get(OAUTH_DISCOVERY_URL)
|
||||
parse_oauth_response(response, "oauth_authorization_server_metadata")
|
||||
end
|
||||
end
|
||||
|
||||
def start_device_authorization(scope: "read")
|
||||
metadata = oauth_authorization_server_metadata
|
||||
endpoint = metadata.fetch("device_authorization_endpoint") do
|
||||
raise ApiError.new("SnapTrade OAuth metadata missing device_authorization_endpoint")
|
||||
end
|
||||
|
||||
with_retries("start_device_authorization") do
|
||||
response = oauth_connection.post(endpoint) do |request|
|
||||
request.headers["Content-Type"] = "application/x-www-form-urlencoded"
|
||||
request.body = URI.encode_www_form(
|
||||
client_id: oauth_client_id,
|
||||
scope: scope
|
||||
)
|
||||
end
|
||||
|
||||
parse_oauth_response(response, "start_device_authorization")
|
||||
end
|
||||
end
|
||||
|
||||
def poll_device_token(device_code:)
|
||||
raise ConfigurationError, "device_code is required" if device_code.blank?
|
||||
|
||||
metadata = oauth_authorization_server_metadata
|
||||
endpoint = metadata.fetch("token_endpoint") do
|
||||
raise ApiError.new("SnapTrade OAuth metadata missing token_endpoint")
|
||||
end
|
||||
|
||||
with_retries("poll_device_token") do
|
||||
response = oauth_connection.post(endpoint) do |request|
|
||||
request.headers["Content-Type"] = "application/x-www-form-urlencoded"
|
||||
request.body = URI.encode_www_form(
|
||||
grant_type: DEVICE_CODE_GRANT,
|
||||
device_code: device_code,
|
||||
client_id: oauth_client_id
|
||||
)
|
||||
end
|
||||
|
||||
parse_oauth_response(response, "poll_device_token")
|
||||
end
|
||||
end
|
||||
|
||||
# Register a new SnapTrade user
|
||||
# Returns { user_id: String, user_secret: String }
|
||||
def register_user(user_id)
|
||||
@@ -243,6 +296,33 @@ class Provider::Snaptrade
|
||||
end
|
||||
end
|
||||
|
||||
def oauth_connection
|
||||
@oauth_connection ||= Faraday.new do |faraday|
|
||||
faraday.options.timeout = 30
|
||||
faraday.options.open_timeout = 10
|
||||
end
|
||||
end
|
||||
|
||||
def oauth_client_id
|
||||
configured_client_id = Rails.configuration.x.snaptrade&.oauth_client_id
|
||||
return configured_client_id if configured_client_id.present?
|
||||
|
||||
raise ConfigurationError, "SnapTrade OAuth client ID is not configured"
|
||||
end
|
||||
|
||||
def parse_oauth_response(response, operation)
|
||||
payload = response.body.present? ? JSON.parse(response.body) : {}
|
||||
|
||||
if response.success?
|
||||
payload
|
||||
else
|
||||
error = payload["error_description"].presence || payload["error"].presence || response.reason_phrase
|
||||
raise ApiError.new("SnapTrade OAuth error (#{operation}): #{error}", status_code: response.status, response_body: response.body)
|
||||
end
|
||||
rescue JSON::ParserError
|
||||
raise ApiError.new("SnapTrade OAuth error (#{operation}): invalid JSON response", status_code: response.status, response_body: response.body)
|
||||
end
|
||||
|
||||
def with_retries(operation_name, max_retries: MAX_RETRIES)
|
||||
retries = 0
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ class SnaptradeItem < ApplicationRecord
|
||||
encrypts :client_id, deterministic: true
|
||||
encrypts :consumer_key, deterministic: true
|
||||
encrypts :snaptrade_user_secret
|
||||
encrypts :oauth_access_token
|
||||
encrypts :oauth_refresh_token
|
||||
end
|
||||
|
||||
validates :name, presence: true
|
||||
@@ -159,6 +161,10 @@ class SnaptradeItem < ApplicationRecord
|
||||
.uniq { |inst| inst["name"] || inst["institution_name"] }
|
||||
end
|
||||
|
||||
def oauth_token_active?
|
||||
oauth_access_token.present? && (oauth_token_expires_at.blank? || oauth_token_expires_at.future?)
|
||||
end
|
||||
|
||||
def institution_summary
|
||||
institutions = connected_institutions
|
||||
case institutions.count
|
||||
|
||||
@@ -132,6 +132,28 @@ module SnaptradeItem::Provided
|
||||
)
|
||||
end
|
||||
|
||||
def start_oauth_device_flow(scope: "read")
|
||||
provider = snaptrade_provider
|
||||
raise Provider::Snaptrade::ConfigurationError, "SnapTrade provider not configured" unless provider
|
||||
|
||||
provider.start_device_authorization(scope: scope)
|
||||
end
|
||||
|
||||
def complete_oauth_device_flow!(device_code:)
|
||||
provider = snaptrade_provider
|
||||
raise Provider::Snaptrade::ConfigurationError, "SnapTrade provider not configured" unless provider
|
||||
|
||||
token_response = provider.poll_device_token(device_code: device_code)
|
||||
update!(
|
||||
oauth_access_token: token_response["access_token"],
|
||||
oauth_refresh_token: token_response["refresh_token"],
|
||||
oauth_token_type: token_response["token_type"],
|
||||
oauth_scope: token_response["scope"],
|
||||
oauth_token_expires_at: token_response["expires_in"].present? ? Time.current + token_response["expires_in"].to_i.seconds : nil
|
||||
)
|
||||
token_response
|
||||
end
|
||||
|
||||
# Fetch all brokerage connections from SnapTrade API
|
||||
# Returns array of connection objects
|
||||
def fetch_connections
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
<% type = params[:type] || "buy" %>
|
||||
|
||||
<%= styled_form_with url: trades_path(account_id: account&.id), scope: :model, data: { controller: "trade-form" } do |form| %>
|
||||
<%= styled_form_with url: trades_path(account_id: account&.id), scope: :model, data: { controller: "trade-form", trade_type: type } do |form| %>
|
||||
<div class="space-y-4">
|
||||
<% if model.errors.any? %>
|
||||
<%= render "shared/form_errors", model: model %>
|
||||
|
||||
5
config/initializers/snaptrade.rb
Normal file
5
config/initializers/snaptrade.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
Rails.application.configure do
|
||||
config.x.snaptrade ||= ActiveSupport::OrderedOptions.new
|
||||
config.x.snaptrade.oauth_client_id = ENV["SNAPTRADE_OAUTH_CLIENT_ID"].presence ||
|
||||
Rails.application.credentials.dig(:snaptrade, :oauth_client_id)
|
||||
end
|
||||
@@ -113,6 +113,8 @@ Rails.application.routes.draw do
|
||||
get :setup_accounts
|
||||
post :complete_account_setup
|
||||
get :connections
|
||||
post :start_oauth_device_flow
|
||||
post :complete_oauth_device_flow
|
||||
delete :delete_connection
|
||||
delete :delete_orphaned_user
|
||||
end
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
class AddOauthDeviceFlowToSnaptradeItems < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
add_column :snaptrade_items, :oauth_access_token, :text
|
||||
add_column :snaptrade_items, :oauth_refresh_token, :text
|
||||
add_column :snaptrade_items, :oauth_token_type, :string
|
||||
add_column :snaptrade_items, :oauth_scope, :string
|
||||
add_column :snaptrade_items, :oauth_token_expires_at, :datetime
|
||||
end
|
||||
end
|
||||
7
db/schema.rb
generated
7
db/schema.rb
generated
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.2].define(version: 2026_06_17_120000) do
|
||||
ActiveRecord::Schema[7.2].define(version: 2026_06_25_230639) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pgcrypto"
|
||||
enable_extension "plpgsql"
|
||||
@@ -1752,6 +1752,11 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_17_120000) do
|
||||
t.string "consumer_key"
|
||||
t.string "snaptrade_user_id"
|
||||
t.string "snaptrade_user_secret"
|
||||
t.text "oauth_access_token"
|
||||
t.text "oauth_refresh_token"
|
||||
t.string "oauth_token_type"
|
||||
t.string "oauth_scope"
|
||||
t.datetime "oauth_token_expires_at"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["family_id"], name: "index_snaptrade_items_on_family_id"
|
||||
|
||||
@@ -45,6 +45,72 @@ class SnaptradeItemsControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_redirected_to portal_url
|
||||
end
|
||||
|
||||
test "complete oauth device flow preserves provider oauth error fields" do
|
||||
error = Provider::Snaptrade::ApiError.new(
|
||||
"SnapTrade OAuth error (poll_device_token): authorization_pending",
|
||||
status_code: 400,
|
||||
response_body: {
|
||||
error: "authorization_pending",
|
||||
error_description: "The user has not completed authorization",
|
||||
error_uri: "https://api.snaptrade.com/docs/oauth",
|
||||
interval: 5
|
||||
}.to_json
|
||||
)
|
||||
SnaptradeItem.any_instance
|
||||
.stubs(:complete_oauth_device_flow!)
|
||||
.raises(error)
|
||||
|
||||
post complete_oauth_device_flow_snaptrade_item_url(@snaptrade_item), params: { device_code: "device-code" }
|
||||
|
||||
assert_response :bad_request
|
||||
payload = JSON.parse(response.body)
|
||||
assert_equal "authorization_pending", payload["error"]
|
||||
assert_equal "The user has not completed authorization", payload["error_description"]
|
||||
assert_equal "https://api.snaptrade.com/docs/oauth", payload["error_uri"]
|
||||
assert_equal 5, payload["interval"]
|
||||
end
|
||||
|
||||
test "complete oauth device flow falls back to api error message without json body" do
|
||||
error = Provider::Snaptrade::ApiError.new(
|
||||
"SnapTrade OAuth error (poll_device_token): invalid response",
|
||||
status_code: 502,
|
||||
response_body: "upstream unavailable"
|
||||
)
|
||||
SnaptradeItem.any_instance
|
||||
.stubs(:complete_oauth_device_flow!)
|
||||
.raises(error)
|
||||
|
||||
post complete_oauth_device_flow_snaptrade_item_url(@snaptrade_item), params: { device_code: "device-code" }
|
||||
|
||||
assert_response :bad_gateway
|
||||
payload = JSON.parse(response.body)
|
||||
assert_equal "SnapTrade OAuth error (poll_device_token): invalid response", payload["error"]
|
||||
end
|
||||
|
||||
test "complete oauth device flow does not expose non-api provider error details" do
|
||||
SnaptradeItem.any_instance
|
||||
.stubs(:complete_oauth_device_flow!)
|
||||
.raises(Provider::Snaptrade::ConfigurationError.new("missing secret at /srv/app/config.yml"))
|
||||
|
||||
post complete_oauth_device_flow_snaptrade_item_url(@snaptrade_item), params: { device_code: "device-code" }
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
payload = JSON.parse(response.body)
|
||||
assert_equal "Unable to complete SnapTrade OAuth device authorization. Please try again.", payload["error"]
|
||||
end
|
||||
|
||||
test "start oauth device flow does not expose provider error details" do
|
||||
SnaptradeItem.any_instance
|
||||
.stubs(:start_oauth_device_flow)
|
||||
.raises(Provider::Snaptrade::ApiError.new("upstream leaked internal path /srv/app/config.yml"))
|
||||
|
||||
post start_oauth_device_flow_snaptrade_item_url(@snaptrade_item)
|
||||
|
||||
assert_response :unprocessable_entity
|
||||
payload = JSON.parse(response.body)
|
||||
assert_equal "Unable to start SnapTrade OAuth device authorization. Please try again.", payload["error"]
|
||||
end
|
||||
|
||||
test "select_accounts redirects unregistered users into connect flow" do
|
||||
sign_out
|
||||
sign_in @user = users(:empty)
|
||||
|
||||
88
test/models/provider/snaptrade_oauth_test.rb
Normal file
88
test/models/provider/snaptrade_oauth_test.rb
Normal file
@@ -0,0 +1,88 @@
|
||||
require "test_helper"
|
||||
|
||||
class Provider::SnaptradeOauthTest < ActiveSupport::TestCase
|
||||
setup do
|
||||
@provider = Provider::Snaptrade.new(client_id: "snap_client", consumer_key: "snap_secret")
|
||||
Rails.configuration.x.snaptrade.oauth_client_id = "sure-oauth-client"
|
||||
stub_request(:get, Provider::Snaptrade::OAUTH_DISCOVERY_URL)
|
||||
.to_return(
|
||||
status: 200,
|
||||
headers: { "Content-Type" => "application/json" },
|
||||
body: {
|
||||
issuer: "https://api.snaptrade.com",
|
||||
device_authorization_endpoint: "https://api.snaptrade.com/oauth/device_authorization/",
|
||||
token_endpoint: "https://api.snaptrade.com/oauth/token/"
|
||||
}.to_json
|
||||
)
|
||||
end
|
||||
|
||||
test "starts device authorization using well known metadata" do
|
||||
stub_request(:post, "https://api.snaptrade.com/oauth/device_authorization/")
|
||||
.with(body: "client_id=sure-oauth-client&scope=read")
|
||||
.to_return(
|
||||
status: 200,
|
||||
headers: { "Content-Type" => "application/json" },
|
||||
body: {
|
||||
device_code: "device-code",
|
||||
user_code: "ABCD-EFGH",
|
||||
verification_uri: "https://dashboard.snaptrade.com/activate",
|
||||
interval: 5,
|
||||
expires_in: 600
|
||||
}.to_json
|
||||
)
|
||||
|
||||
response = @provider.start_device_authorization
|
||||
|
||||
assert_equal "device-code", response["device_code"]
|
||||
assert_equal "ABCD-EFGH", response["user_code"]
|
||||
assert_equal 5, response["interval"]
|
||||
end
|
||||
|
||||
test "polls token endpoint with device code grant" do
|
||||
stub_request(:post, "https://api.snaptrade.com/oauth/token/")
|
||||
.with(body: "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Adevice_code&device_code=device-code&client_id=sure-oauth-client")
|
||||
.to_return(
|
||||
status: 200,
|
||||
headers: { "Content-Type" => "application/json" },
|
||||
body: {
|
||||
access_token: "access-token",
|
||||
refresh_token: "refresh-token",
|
||||
token_type: "Bearer",
|
||||
expires_in: 3600,
|
||||
scope: "read"
|
||||
}.to_json
|
||||
)
|
||||
|
||||
response = @provider.poll_device_token(device_code: "device-code")
|
||||
|
||||
assert_equal "access-token", response["access_token"]
|
||||
assert_equal "refresh-token", response["refresh_token"]
|
||||
assert_equal "Bearer", response["token_type"]
|
||||
end
|
||||
|
||||
test "raises configuration error when oauth client id is missing" do
|
||||
Rails.configuration.x.snaptrade.oauth_client_id = nil
|
||||
|
||||
error = assert_raises Provider::Snaptrade::ConfigurationError do
|
||||
@provider.start_device_authorization
|
||||
end
|
||||
|
||||
assert_equal "SnapTrade OAuth client ID is not configured", error.message
|
||||
end
|
||||
|
||||
test "raises api error for oauth error responses" do
|
||||
stub_request(:post, "https://api.snaptrade.com/oauth/token/")
|
||||
.to_return(
|
||||
status: 400,
|
||||
headers: { "Content-Type" => "application/json" },
|
||||
body: { error: "authorization_pending" }.to_json
|
||||
)
|
||||
|
||||
error = assert_raises Provider::Snaptrade::ApiError do
|
||||
@provider.poll_device_token(device_code: "device-code")
|
||||
end
|
||||
|
||||
assert_equal 400, error.status_code
|
||||
assert_match "authorization_pending", error.message
|
||||
end
|
||||
end
|
||||
30
test/models/snaptrade_item_oauth_test.rb
Normal file
30
test/models/snaptrade_item_oauth_test.rb
Normal file
@@ -0,0 +1,30 @@
|
||||
require "test_helper"
|
||||
|
||||
class SnaptradeItemOauthTest < ActiveSupport::TestCase
|
||||
test "complete_oauth_device_flow stores token metadata" do
|
||||
item = snaptrade_items(:configured_item)
|
||||
provider = mock("snaptrade_provider")
|
||||
provider.expects(:poll_device_token).with(device_code: "device-code").returns(
|
||||
"access_token" => "access-token",
|
||||
"refresh_token" => "refresh-token",
|
||||
"token_type" => "Bearer",
|
||||
"scope" => "read",
|
||||
"expires_in" => 3600
|
||||
)
|
||||
item.stubs(:snaptrade_provider).returns(provider)
|
||||
|
||||
expected_expiry = 1.hour.from_now
|
||||
|
||||
travel_to expected_expiry - 1.hour do
|
||||
item.complete_oauth_device_flow!(device_code: "device-code")
|
||||
end
|
||||
|
||||
item.reload
|
||||
assert_equal "access-token", item.oauth_access_token
|
||||
assert_equal "refresh-token", item.oauth_refresh_token
|
||||
assert_equal "Bearer", item.oauth_token_type
|
||||
assert_equal "read", item.oauth_scope
|
||||
assert_in_delta expected_expiry.to_f, item.oauth_token_expires_at.to_f, 1
|
||||
assert item.oauth_token_active?
|
||||
end
|
||||
end
|
||||
@@ -10,10 +10,11 @@ class TradesTest < ApplicationSystemTestCase
|
||||
|
||||
@account = accounts(:investment)
|
||||
|
||||
visit_account_portfolio
|
||||
|
||||
# Disable provider to focus on form testing
|
||||
Security.stubs(:provider).returns(nil)
|
||||
Security.stubs(:providers).returns([])
|
||||
|
||||
visit_account_portfolio
|
||||
end
|
||||
|
||||
test "can create buy transaction" do
|
||||
@@ -44,6 +45,8 @@ class TradesTest < ApplicationSystemTestCase
|
||||
open_new_trade_modal
|
||||
|
||||
select "Sell", from: "Type"
|
||||
assert_selector "turbo-frame#modal form[data-trade-type='sell']"
|
||||
|
||||
fill_in "Ticker symbol", with: "AAPL"
|
||||
fill_in "Date", with: Date.current
|
||||
fill_in "Quantity", with: qty
|
||||
|
||||
Reference in New Issue
Block a user