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