mirror of
https://github.com/we-promise/sure.git
synced 2026-08-05 08:32:15 +00:00
* feat(snaptrade): replace device-flow OAuth with authorization-code + PKCE flow Squashed from 16 commits on snaptrade-oauth-apps for a clean rebase onto current upstream/main ahead of opening a PR. * fix(snaptrade): address PR #2747 review feedback on OAuth PKCE flow - Remove unreachable dead-code guard in import_latest_snaptrade_data - Guard apply_oauth_tokens! against a malformed payload missing access_token - Wrap token endpoint network errors in ApiError and retry like data calls - Remove unused Provider::Snaptrade#revoke_token! instance method - Preserve return_to/accountable_type through the SnapTrade portal callback so the account-linking flow no longer drops users back to accounts_path - Show the real absolute OAuth callback URL in self-hosted setup instructions - Refresh brakeman.ignore fingerprint for the connect redirect after the return_to/accountable_type params were added Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y8SCCmKX6RphB5E73WSUQQ * fix(snaptrade): don't retry non-idempotent OAuth/API requests CodeRabbit flagged that Provider::Snaptrade retried OAuth token exchanges/refreshes and all API POST/DELETE calls (get_connection_url, delete_connection) after timeouts/connection failures. If the response is lost after SnapTrade already consumed a single-use auth code, rotated the refresh token, or applied a POST/DELETE, replaying the request either fails with invalid_grant on a token that actually succeeded, or risks duplicate side effects. Retries are now limited to GET requests; OAuth token requests and non-GET API calls translate a network failure straight into an ApiError without replay. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NrrGkgSBEqhjjBmmH1fcXL * fix(snaptrade): stop querying non-deterministically encrypted token via empty-string compare CodeRabbit flagged that the syncable scope's where.not(oauth_access_token: [nil, ""]) re-encrypts "" with a random IV on every query, so the "" comparison can never match a stored ciphertext and is a silent no-op. No code path ever persists oauth_access_token as "" (only nil or a real token via apply_oauth_tokens!), so the exclusion is unnecessary -- narrowed the scope to a plain NULL check, which encryption handles transparently since nil is never encrypted. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NrrGkgSBEqhjjBmmH1fcXL --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
418 lines
16 KiB
Ruby
418 lines
16 KiB
Ruby
# SnapTrade API client using SnapTrade OAuth apps (pre-release feature).
|
|
#
|
|
# Auth model:
|
|
# - Instance admin registers an OAuth app on dashboard.snaptrade.com and sets
|
|
# SNAPTRADE_OAUTH_CLIENT_ID / SNAPTRADE_OAUTH_CLIENT_SECRET.
|
|
# - Users authorize via authorization-code + PKCE; per-item access/refresh
|
|
# tokens are stored (encrypted) on SnaptradeItem.
|
|
# - Data calls send Authorization: Bearer <access_token>. The SnapTrade user
|
|
# is implicit in the token; there is no userId/userSecret.
|
|
class Provider::Snaptrade
|
|
class Error < StandardError; end
|
|
class AuthenticationError < Error; end
|
|
class ConfigurationError < Error; end
|
|
class ApiError < Error
|
|
attr_reader :status_code, :response_body
|
|
|
|
def initialize(message, status_code: nil, response_body: nil)
|
|
super(message)
|
|
@status_code = status_code
|
|
@response_body = response_body
|
|
end
|
|
end
|
|
|
|
MAX_RETRIES = 3
|
|
INITIAL_RETRY_DELAY = 2 # seconds
|
|
MAX_RETRY_DELAY = 30 # seconds
|
|
|
|
API_BASE_URL = "https://api.snaptrade.com".freeze
|
|
AUTHORIZE_URL = "https://dashboard.snaptrade.com/oauth/authorize".freeze
|
|
TOKEN_URL = "https://api.snaptrade.com/oauth/token/".freeze
|
|
REVOKE_URL = "https://api.snaptrade.com/oauth/revoke_token/".freeze
|
|
DASHBOARD_URL = "https://dashboard.snaptrade.com".freeze
|
|
TOKEN_EXPIRY_LEEWAY = 60 # seconds; refresh this long before actual expiry
|
|
|
|
class << self
|
|
def oauth_configured?
|
|
oauth_client_id.present? && oauth_client_secret.present?
|
|
end
|
|
|
|
def oauth_client_id
|
|
Rails.configuration.x.snaptrade&.oauth_client_id
|
|
end
|
|
|
|
def oauth_client_secret
|
|
Rails.configuration.x.snaptrade&.oauth_client_secret
|
|
end
|
|
|
|
# PKCE pair per RFC 7636 (S256)
|
|
def generate_pkce
|
|
verifier = SecureRandom.urlsafe_base64(64).delete("=")[0, 128]
|
|
challenge = Base64.urlsafe_encode64(OpenSSL::Digest::SHA256.digest(verifier), padding: false)
|
|
{ verifier: verifier, challenge: challenge }
|
|
end
|
|
|
|
def authorize_url(redirect_uri:, state:, code_challenge:, scope: "read")
|
|
raise ConfigurationError, "SnapTrade OAuth is not configured" unless oauth_configured?
|
|
|
|
params = {
|
|
response_type: "code",
|
|
client_id: oauth_client_id,
|
|
redirect_uri: redirect_uri,
|
|
scope: scope,
|
|
state: state,
|
|
code_challenge: code_challenge,
|
|
code_challenge_method: "S256"
|
|
}
|
|
"#{AUTHORIZE_URL}?#{params.to_query}"
|
|
end
|
|
|
|
def exchange_code(code:, redirect_uri:, code_verifier:)
|
|
token_request(
|
|
grant_type: "authorization_code",
|
|
code: code,
|
|
redirect_uri: redirect_uri,
|
|
code_verifier: code_verifier
|
|
)
|
|
end
|
|
|
|
def refresh_tokens(refresh_token:)
|
|
token_request(grant_type: "refresh_token", refresh_token: refresh_token)
|
|
end
|
|
|
|
# Best-effort revocation (RFC 7009). Returns true on success.
|
|
def revoke_token(token:)
|
|
return false if token.blank?
|
|
raise ConfigurationError, "SnapTrade OAuth is not configured" unless oauth_configured?
|
|
|
|
response = oauth_connection.post(REVOKE_URL) do |request|
|
|
request.headers["Authorization"] = basic_auth_header
|
|
request.headers["Content-Type"] = "application/x-www-form-urlencoded"
|
|
request.body = URI.encode_www_form(token: token)
|
|
end
|
|
response.success?
|
|
rescue Faraday::Error => e
|
|
Rails.logger.warn("SnapTrade token revocation failed: #{e.class} - #{e.message}")
|
|
false
|
|
end
|
|
|
|
private
|
|
|
|
def token_request(params)
|
|
raise ConfigurationError, "SnapTrade OAuth is not configured" unless oauth_configured?
|
|
|
|
# Not retried: a token request consumes a single-use authorization code
|
|
# or rotates the refresh token. If the response is lost after SnapTrade
|
|
# processed it, replaying the same params would fail with invalid_grant
|
|
# even though the original request actually succeeded.
|
|
response = without_retry("POST #{TOKEN_URL}") do
|
|
oauth_connection.post(TOKEN_URL) do |request|
|
|
request.headers["Authorization"] = basic_auth_header
|
|
request.headers["Content-Type"] = "application/x-www-form-urlencoded"
|
|
request.body = URI.encode_www_form(params)
|
|
end
|
|
end
|
|
|
|
payload = parse_json(response.body)
|
|
return payload if response.success?
|
|
|
|
error = payload["error_description"].presence || payload["error"].presence || "HTTP #{response.status}"
|
|
if (400..499).cover?(response.status)
|
|
raise AuthenticationError, "SnapTrade OAuth token request failed: #{error}"
|
|
end
|
|
|
|
raise ApiError.new(
|
|
"SnapTrade OAuth token request failed: #{error}",
|
|
status_code: response.status, response_body: response.body
|
|
)
|
|
end
|
|
|
|
def basic_auth_header
|
|
"Basic #{Base64.strict_encode64("#{oauth_client_id}:#{oauth_client_secret}")}"
|
|
end
|
|
|
|
def oauth_connection
|
|
Faraday.new do |faraday|
|
|
faraday.options.timeout = 30
|
|
faraday.options.open_timeout = 10
|
|
end
|
|
end
|
|
|
|
def parse_json(body)
|
|
body.present? ? JSON.parse(body) : {}
|
|
rescue JSON::ParserError
|
|
{}
|
|
end
|
|
|
|
def with_retries(operation_name, max_retries: MAX_RETRIES)
|
|
retries = 0
|
|
|
|
begin
|
|
yield
|
|
rescue Faraday::TimeoutError, Faraday::ConnectionFailed, Errno::ECONNRESET, Errno::ETIMEDOUT => e
|
|
retries += 1
|
|
|
|
if retries <= max_retries
|
|
delay = calculate_retry_delay(retries)
|
|
Rails.logger.warn(
|
|
"SnapTrade OAuth: #{operation_name} failed (attempt #{retries}/#{max_retries}): " \
|
|
"#{e.class}: #{e.message}. Retrying in #{delay}s..."
|
|
)
|
|
sleep(delay)
|
|
retry
|
|
else
|
|
Rails.logger.error(
|
|
"SnapTrade OAuth: #{operation_name} failed after #{max_retries} retries: " \
|
|
"#{e.class}: #{e.message}"
|
|
)
|
|
raise ApiError.new("Network error after #{max_retries} retries: #{e.message}")
|
|
end
|
|
end
|
|
end
|
|
|
|
# For requests that must not be replayed (single-use codes, token rotation):
|
|
# translate a network failure into an ApiError without retrying.
|
|
def without_retry(operation_name)
|
|
yield
|
|
rescue Faraday::TimeoutError, Faraday::ConnectionFailed, Errno::ECONNRESET, Errno::ETIMEDOUT => e
|
|
Rails.logger.error("SnapTrade OAuth: #{operation_name} failed (not retried, non-idempotent): #{e.class}: #{e.message}")
|
|
raise ApiError.new("Network error (not retried, non-idempotent request): #{e.message}")
|
|
end
|
|
|
|
def calculate_retry_delay(retry_count)
|
|
base_delay = INITIAL_RETRY_DELAY * (2 ** (retry_count - 1))
|
|
jitter = base_delay * rand * 0.25
|
|
[ base_delay + jitter, MAX_RETRY_DELAY ].min
|
|
end
|
|
end
|
|
|
|
attr_reader :snaptrade_item
|
|
|
|
def initialize(snaptrade_item)
|
|
raise ConfigurationError, "snaptrade_item is required" if snaptrade_item.nil?
|
|
@snaptrade_item = snaptrade_item
|
|
end
|
|
|
|
# --- Data methods. The SnapTrade user is implicit in the Bearer token. ---
|
|
|
|
# Returns Array<Hash> of brokerage accounts
|
|
def list_accounts
|
|
get_json("/api/v1/accounts")
|
|
end
|
|
|
|
# Returns Array<Hash> of balance entries
|
|
def get_balances(account_id:)
|
|
get_json("/api/v1/accounts/#{account_id}/balances")
|
|
end
|
|
|
|
# Returns Array<Hash> of positions
|
|
def get_positions(account_id:)
|
|
get_json("/api/v1/accounts/#{account_id}/positions")
|
|
end
|
|
|
|
# Returns raw JSON: paginated form is {"data" => [...]}, may also be a plain Array
|
|
def get_account_activities(account_id:, start_date: nil, end_date: nil)
|
|
params = {}
|
|
params[:startDate] = start_date.to_date.to_s if start_date
|
|
params[:endDate] = end_date.to_date.to_s if end_date
|
|
get_json("/api/v1/accounts/#{account_id}/activities", params)
|
|
end
|
|
|
|
# Cross-account activities endpoint. Returns Array<Hash>.
|
|
def get_activities(start_date: nil, end_date: nil, accounts: nil, brokerage_authorizations: nil, type: nil)
|
|
params = {}
|
|
params[:startDate] = start_date.to_date.to_s if start_date
|
|
params[:endDate] = end_date.to_date.to_s if end_date
|
|
params[:accounts] = accounts if accounts
|
|
params[:brokerageAuthorizations] = brokerage_authorizations if brokerage_authorizations
|
|
params[:type] = type if type
|
|
get_json("/api/v1/activities", params)
|
|
end
|
|
|
|
# Returns Array<Hash> of brokerage authorizations (connections)
|
|
def list_connections
|
|
get_json("/api/v1/authorizations")
|
|
end
|
|
|
|
def delete_connection(authorization_id:)
|
|
request_json(:delete, "/api/v1/authorizations/#{authorization_id}")
|
|
end
|
|
|
|
# Connection portal URL (loginUser). Returns the redirect URL string.
|
|
def get_connection_url(redirect_url:, broker: nil)
|
|
body = { customRedirect: redirect_url, connectionType: "read" }
|
|
body[:broker] = broker if broker
|
|
response = request_json(:post, "/api/v1/snapTrade/login", body: body)
|
|
response["redirectURI"] || response["redirectUri"]
|
|
end
|
|
|
|
private
|
|
|
|
def get_json(path, params = {})
|
|
request_json(:get, path, params: params)
|
|
end
|
|
|
|
def request_json(method, path, params: {}, body: nil, retry_on_auth_failure: true)
|
|
ensure_fresh_token!
|
|
operation = "#{method.to_s.upcase} #{path}"
|
|
used_access_token = snaptrade_item.oauth_access_token
|
|
|
|
# Only GET is safe to retry: a lost response to a POST/DELETE (e.g.
|
|
# get_connection_url, delete_connection) may already have been applied by
|
|
# SnapTrade, so replaying it risks duplicate side effects.
|
|
retrier = method == :get ? method(:with_retries) : method(:without_retry)
|
|
response = retrier.call(operation) do
|
|
api_connection.public_send(method, "#{API_BASE_URL}#{path}") do |request|
|
|
request.headers["Authorization"] = "Bearer #{used_access_token}"
|
|
request.headers["Accept"] = "application/json"
|
|
request.params.update(params) if params.present?
|
|
if body
|
|
request.headers["Content-Type"] = "application/json"
|
|
request.body = body.to_json
|
|
end
|
|
end
|
|
end
|
|
|
|
if response.status == 401 && retry_on_auth_failure
|
|
refresh_access_token!(previous_access_token: used_access_token)
|
|
return request_json(method, path, params: params, body: body, retry_on_auth_failure: false)
|
|
end
|
|
|
|
handle_response(response, operation)
|
|
end
|
|
|
|
def ensure_fresh_token!
|
|
raise AuthenticationError, "SnapTrade item has no access token" if snaptrade_item.oauth_access_token.blank?
|
|
|
|
expires_at = snaptrade_item.oauth_token_expires_at
|
|
return if expires_at.blank? || expires_at > TOKEN_EXPIRY_LEEWAY.seconds.from_now
|
|
|
|
refresh_access_token!
|
|
end
|
|
|
|
# Guards against a concurrent refresh-token rotation race: multiple threads/processes
|
|
# (e.g. per-account jobs sharing one SnapTrade item) may all observe an expiring/rejected
|
|
# token and attempt to refresh at once. If SnapTrade rotates refresh tokens as single-use,
|
|
# every refresh after the first would fail with invalid_grant and needlessly brick the
|
|
# item. Taking a DB row lock and re-checking freshness after reload ensures only one
|
|
# caller actually performs the HTTP refresh; the rest observe the winner's fresh token.
|
|
#
|
|
# `previous_access_token`, when present, means we're refreshing reactively after a 401 on
|
|
# that specific token (called from request_json). In that case we skip the HTTP refresh
|
|
# only if the DB row's access token has already changed since we made the failed request
|
|
# (i.e. another caller already won the race) -- an expiry-based freshness check would be
|
|
# wrong here since the server rejected a token we believed was still time-valid.
|
|
# When `previous_access_token` is absent, we're refreshing proactively (from
|
|
# ensure_fresh_token!) and skip only if the reloaded row is still time-fresh.
|
|
def refresh_access_token!(previous_access_token: nil)
|
|
snaptrade_item.with_lock do
|
|
snaptrade_item.reload
|
|
|
|
if previous_access_token.present?
|
|
next if snaptrade_item.oauth_access_token != previous_access_token
|
|
else
|
|
expires_at = snaptrade_item.oauth_token_expires_at
|
|
next if expires_at.present? && expires_at > TOKEN_EXPIRY_LEEWAY.seconds.from_now
|
|
end
|
|
|
|
refresh_token = snaptrade_item.oauth_refresh_token
|
|
raise AuthenticationError, "SnapTrade item has no refresh token" if refresh_token.blank?
|
|
|
|
payload = self.class.refresh_tokens(refresh_token: refresh_token)
|
|
snaptrade_item.apply_oauth_tokens!(payload)
|
|
end
|
|
rescue AuthenticationError => e
|
|
mark_requires_update!
|
|
DebugLogEntry.capture(
|
|
category: "provider_sync",
|
|
level: :error,
|
|
message: "SnapTrade token refresh failed: #{e.message}",
|
|
source: "Provider::Snaptrade",
|
|
provider_key: "snaptrade",
|
|
family: snaptrade_item.try(:family),
|
|
metadata: { snaptrade_item_id: snaptrade_item.try(:id) }
|
|
)
|
|
raise
|
|
end
|
|
|
|
def mark_requires_update!
|
|
snaptrade_item.update!(status: :requires_update)
|
|
rescue StandardError => e
|
|
Rails.logger.warn("SnapTrade: could not mark item requires_update: #{e.message}")
|
|
end
|
|
|
|
def handle_response(response, operation)
|
|
if response.success?
|
|
return {} if response.body.blank?
|
|
begin
|
|
JSON.parse(response.body)
|
|
rescue JSON::ParserError
|
|
raise ApiError.new("SnapTrade API error (#{operation}): invalid JSON response",
|
|
status_code: response.status, response_body: response.body)
|
|
end
|
|
else
|
|
Rails.logger.error("SnapTrade API error (#{operation}): #{response.status}")
|
|
case response.status
|
|
when 401, 403
|
|
mark_requires_update!
|
|
raise AuthenticationError, "Authentication failed (#{operation}): HTTP #{response.status}"
|
|
when 429
|
|
raise ApiError.new("Rate limit exceeded. Please try again later.",
|
|
status_code: response.status, response_body: response.body)
|
|
when 500..599
|
|
raise ApiError.new("SnapTrade server error (#{response.status}). Please try again later.",
|
|
status_code: response.status, response_body: response.body)
|
|
else
|
|
raise ApiError.new("SnapTrade API error (#{operation}): HTTP #{response.status}",
|
|
status_code: response.status, response_body: response.body)
|
|
end
|
|
end
|
|
end
|
|
|
|
def api_connection
|
|
@api_connection ||= Faraday.new do |faraday|
|
|
faraday.options.timeout = 30
|
|
faraday.options.open_timeout = 10
|
|
end
|
|
end
|
|
|
|
def with_retries(operation_name, max_retries: MAX_RETRIES)
|
|
retries = 0
|
|
|
|
begin
|
|
yield
|
|
rescue Faraday::TimeoutError, Faraday::ConnectionFailed, Errno::ECONNRESET, Errno::ETIMEDOUT => e
|
|
retries += 1
|
|
|
|
if retries <= max_retries
|
|
delay = calculate_retry_delay(retries)
|
|
Rails.logger.warn(
|
|
"SnapTrade API: #{operation_name} failed (attempt #{retries}/#{max_retries}): " \
|
|
"#{e.class}: #{e.message}. Retrying in #{delay}s..."
|
|
)
|
|
sleep(delay)
|
|
retry
|
|
else
|
|
Rails.logger.error(
|
|
"SnapTrade API: #{operation_name} failed after #{max_retries} retries: " \
|
|
"#{e.class}: #{e.message}"
|
|
)
|
|
raise ApiError.new("Network error after #{max_retries} retries: #{e.message}")
|
|
end
|
|
end
|
|
end
|
|
|
|
def without_retry(operation_name)
|
|
yield
|
|
rescue Faraday::TimeoutError, Faraday::ConnectionFailed, Errno::ECONNRESET, Errno::ETIMEDOUT => e
|
|
Rails.logger.error("SnapTrade API: #{operation_name} failed (not retried, non-idempotent): #{e.class}: #{e.message}")
|
|
raise ApiError.new("Network error (not retried, non-idempotent request): #{e.message}")
|
|
end
|
|
|
|
def calculate_retry_delay(retry_count)
|
|
base_delay = INITIAL_RETRY_DELAY * (2 ** (retry_count - 1))
|
|
jitter = base_delay * rand * 0.25
|
|
[ base_delay + jitter, MAX_RETRY_DELAY ].min
|
|
end
|
|
end
|