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:
Juan José Mata
2026-06-27 06:43:12 +02:00
committed by GitHub
parent 112b09f425
commit b416618558
15 changed files with 389 additions and 7 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 %>

View 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

View File

@@ -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

View File

@@ -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
View File

@@ -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"

View File

@@ -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)

View 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

View 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

View File

@@ -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