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