From b416618558b9689caa0435f22cbdc9b4b794cf80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Mata?= Date: Sat, 27 Jun 2026 06:43:12 +0200 Subject: [PATCH] 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. --- .env.local.example | 3 + .env.test.example | 3 + app/controllers/snaptrade_items_controller.rb | 64 +++++++++++++- app/models/provider/snaptrade.rb | 82 ++++++++++++++++- app/models/snaptrade_item.rb | 6 ++ app/models/snaptrade_item/provided.rb | 22 +++++ app/views/trades/_form.html.erb | 2 +- config/initializers/snaptrade.rb | 5 ++ config/routes.rb | 2 + ...dd_oauth_device_flow_to_snaptrade_items.rb | 9 ++ db/schema.rb | 7 +- .../snaptrade_items_controller_test.rb | 66 ++++++++++++++ test/models/provider/snaptrade_oauth_test.rb | 88 +++++++++++++++++++ test/models/snaptrade_item_oauth_test.rb | 30 +++++++ test/system/trades_test.rb | 7 +- 15 files changed, 389 insertions(+), 7 deletions(-) create mode 100644 config/initializers/snaptrade.rb create mode 100644 db/migrate/20260625230639_add_oauth_device_flow_to_snaptrade_items.rb create mode 100644 test/models/provider/snaptrade_oauth_test.rb create mode 100644 test/models/snaptrade_item_oauth_test.rb diff --git a/.env.local.example b/.env.local.example index fea16335c..afc38f338 100644 --- a/.env.local.example +++ b/.env.local.example @@ -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 diff --git a/.env.test.example b/.env.test.example index 02d7fea24..e62ad7ecf 100644 --- a/.env.test.example +++ b/.env.test.example @@ -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 diff --git a/app/controllers/snaptrade_items_controller.rb b/app/controllers/snaptrade_items_controller.rb index 6062c4dfb..2406f2768 100644 --- a/app/controllers/snaptrade_items_controller.rb +++ b/app/controllers/snaptrade_items_controller.rb @@ -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) diff --git a/app/models/provider/snaptrade.rb b/app/models/provider/snaptrade.rb index 5f4e84998..c01be5cf1 100644 --- a/app/models/provider/snaptrade.rb +++ b/app/models/provider/snaptrade.rb @@ -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 diff --git a/app/models/snaptrade_item.rb b/app/models/snaptrade_item.rb index 367836e42..e3e7e0249 100644 --- a/app/models/snaptrade_item.rb +++ b/app/models/snaptrade_item.rb @@ -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 diff --git a/app/models/snaptrade_item/provided.rb b/app/models/snaptrade_item/provided.rb index c8d104f43..7382a2ccf 100644 --- a/app/models/snaptrade_item/provided.rb +++ b/app/models/snaptrade_item/provided.rb @@ -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 diff --git a/app/views/trades/_form.html.erb b/app/views/trades/_form.html.erb index 9bcf2d392..85c307e11 100644 --- a/app/views/trades/_form.html.erb +++ b/app/views/trades/_form.html.erb @@ -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| %>
<% if model.errors.any? %> <%= render "shared/form_errors", model: model %> diff --git a/config/initializers/snaptrade.rb b/config/initializers/snaptrade.rb new file mode 100644 index 000000000..990f8e9b3 --- /dev/null +++ b/config/initializers/snaptrade.rb @@ -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 diff --git a/config/routes.rb b/config/routes.rb index 8dbef6d5a..07a277bf2 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/db/migrate/20260625230639_add_oauth_device_flow_to_snaptrade_items.rb b/db/migrate/20260625230639_add_oauth_device_flow_to_snaptrade_items.rb new file mode 100644 index 000000000..19bf5c3d1 --- /dev/null +++ b/db/migrate/20260625230639_add_oauth_device_flow_to_snaptrade_items.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 240ec6790..9ad65af97 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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" diff --git a/test/controllers/snaptrade_items_controller_test.rb b/test/controllers/snaptrade_items_controller_test.rb index 06c4d54e9..a334bb800 100644 --- a/test/controllers/snaptrade_items_controller_test.rb +++ b/test/controllers/snaptrade_items_controller_test.rb @@ -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) diff --git a/test/models/provider/snaptrade_oauth_test.rb b/test/models/provider/snaptrade_oauth_test.rb new file mode 100644 index 000000000..76deb3776 --- /dev/null +++ b/test/models/provider/snaptrade_oauth_test.rb @@ -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 diff --git a/test/models/snaptrade_item_oauth_test.rb b/test/models/snaptrade_item_oauth_test.rb new file mode 100644 index 000000000..8a151ce07 --- /dev/null +++ b/test/models/snaptrade_item_oauth_test.rb @@ -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 diff --git a/test/system/trades_test.rb b/test/system/trades_test.rb index f8e94fdc5..46d1c2146 100644 --- a/test/system/trades_test.rb +++ b/test/system/trades_test.rb @@ -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