diff --git a/include/optionx_cpp/platforms/IntradeBarPlatform/RequestManager.hpp b/include/optionx_cpp/platforms/IntradeBarPlatform/RequestManager.hpp index b507692..1f682d2 100644 --- a/include/optionx_cpp/platforms/IntradeBarPlatform/RequestManager.hpp +++ b/include/optionx_cpp/platforms/IntradeBarPlatform/RequestManager.hpp @@ -570,8 +570,117 @@ namespace optionx::platforms::intrade_bar { return; } - auto login_result = parse_login(response->content); - if (!login_result) { + // Newer legacy-broker responses contain an opaque token in a + // JavaScript redirect. Follow it so the broker can establish the + // user_id/user_hash cookies before continuing with /auth. + const auto redirect_url = parse_login_redirect_url(response->content); + const bool has_legacy_credentials = redirect_url && + redirect_url->find("id=") != std::string::npos && + redirect_url->find("hash=") != std::string::npos; + + std::string redirect_path; + if (redirect_url) { + const auto redirect_target = resolve_login_redirect_target(*redirect_url); + if (!redirect_target) { + const std::string reason("Malformed login redirect URL."); + LOGIT_ERROR(reason); + result_callback( + false, + std::string(), + std::string(), + std::string(), + reason); + return; + } + + redirect_path = redirect_target->path; + if (redirect_target->origin) { + if (!is_allowed_intrade_redirect_origin(*redirect_target->origin)) { + const std::string reason("Rejected login redirect origin."); + LOGIT_ERROR(reason, " origin=", *redirect_target->origin); + result_callback( + false, + std::string(), + std::string(), + std::string(), + reason); + return; + } + + // The landing page may issue the one-time token on a + // different legacy-broker origin (for example, intrade.bar + // -> intrade35.bar). Keep the follow-up request and the + // subsequent /auth call on the origin selected by the broker. + auto& client = get_http_client(); + client.set_host(*redirect_target->origin); + client.set_origin(*redirect_target->origin); + client.set_referer(*redirect_target->origin + "/"); + } + } + + if (!redirect_url || has_legacy_credentials) { + auto login_result = parse_login(response->content); + if (login_result) { + const auto [user_id, user_hash] = *login_result; + result_callback(true, user_id, user_hash, cookies, std::string()); + return; + } + } + + if (redirect_url) { + const std::string login_response_cookies = merge_set_cookies( + cookies, + response->headers); + auto redirect_future = get_http_client().get( + redirect_path, + kurlyk::QueryParams(), + { + {"Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9"}, + {"Upgrade-Insecure-Requests", "1"}, + {"Connection", "keep-alive"}, + {"Cookie", login_response_cookies} + }, + get_rate_limit(RateLimitType::ACCOUNT_INFO) + ); + + auto redirect_callback = [cookies = login_response_cookies, result_callback]( + kurlyk::HttpResponsePtr redirect_response) { + if (!validate_response(redirect_response, [&result_callback](const std::string& error_text){ + result_callback(false, std::string(), std::string(), std::string(), error_text); + })) { + return; + } + + const std::string merged_cookies = merge_set_cookies( + cookies, + redirect_response->headers); + const auto parsed_cookies = parse_cookies(merged_cookies); + if (!parsed_cookies) { + LOGIT_PRINT_ERROR("Failed to parse cookies after login redirect."); + result_callback( + false, + std::string(), + std::string(), + std::string(), + "Failed to parse login cookies."); + return; + } + + const auto& [user_id, user_hash] = *parsed_cookies; + result_callback( + true, + user_id, + user_hash, + merged_cookies, + std::string()); + }; + + add_http_request_task( + std::move(redirect_future), + std::move(redirect_callback)); + return; + } + # ifdef OPTIONX_LOG_UNIQUE_FILE_INDEX const int log_index = OPTIONX_LOG_UNIQUE_FILE_INDEX; LOGIT_STREAM_ERROR_TO(log_index) << response->content; @@ -580,11 +689,6 @@ namespace optionx::platforms::intrade_bar { LOGIT_PRINT_ERROR("Failed to parse login."); # endif result_callback(false, std::string(), std::string(), std::string(), "Failed to parse login."); - return; - } - - const auto [user_id, user_hash] = *login_result; - result_callback(true, user_id, user_hash, cookies, std::string()); }; // Add the task to handle the HTTP request diff --git a/include/optionx_cpp/platforms/IntradeBarPlatform/http_parsers.hpp b/include/optionx_cpp/platforms/IntradeBarPlatform/http_parsers.hpp index 97ea154..6daaa03 100644 --- a/include/optionx_cpp/platforms/IntradeBarPlatform/http_parsers.hpp +++ b/include/optionx_cpp/platforms/IntradeBarPlatform/http_parsers.hpp @@ -42,7 +42,11 @@ namespace optionx::platforms::intrade_bar { try { std::string user_id, user_hash, fragment; // Extract "/auth/" fragment - if (utils::extract_between(content, "/auth/", "'", fragment) == std::string::npos || fragment.empty()) { + if (utils::extract_between(content, "/auth/", "'", fragment) == std::string::npos) { + // Some deployments quote the redirect with double quotes. + utils::extract_between(content, "/auth/", "\"", fragment); + } + if (fragment.empty()) { LOGIT_ERROR("Failed to extract auth fragment."); return std::nullopt; } @@ -65,6 +69,117 @@ namespace optionx::platforms::intrade_bar { } } + /// \brief Extracts the redirect URL emitted by the current legacy login page. + /// + /// Older responses embedded `id` and `hash` directly in an `/auth/` URL. + /// The broker now returns an opaque one-time token from JavaScript instead, + /// for example `window.location.replace('https://intrade35.bar/auth/token')`. + /// \param content The HTML/JavaScript response body. + /// \return The redirect URL or path when one is present. + inline std::optional parse_login_redirect_url(const std::string& content) { + static const std::regex redirect_regex( + R"(window\s*\.\s*location\s*\.\s*replace\s*\(\s*(['"])((?:https?://[^'"]+|/auth/[^'"]+))\1\s*\))", + std::regex::icase); + + std::smatch match; + if (!std::regex_search(content, match, redirect_regex) || match.size() < 3) { + return std::nullopt; + } + + const std::string redirect_url = match[2].str(); + if (redirect_url.find("/auth/") == std::string::npos) { + return std::nullopt; + } + return redirect_url; + } + + /// \brief Extracts the origin from an absolute login redirect URL. + /// \param redirect_url Absolute or relative redirect URL. + /// \return The `scheme://authority` origin for an absolute URL; empty for a relative path. + inline std::optional parse_login_redirect_origin( + const std::string& redirect_url) { + const auto scheme_end = redirect_url.find("://"); + if (scheme_end == std::string::npos || scheme_end == 0) { + return std::nullopt; + } + + const auto authority_start = scheme_end + 3; + if (authority_start >= redirect_url.size()) { + return std::nullopt; + } + + const auto path_start = redirect_url.find_first_of("/?#", authority_start); + const std::string origin = path_start == std::string::npos + ? redirect_url + : redirect_url.substr(0, path_start); + if (origin.size() <= authority_start) { + return std::nullopt; + } + return origin; + } + + /// \brief Validates the trusted origin family used by legacy Intrade login. + /// \param origin The `scheme://authority` origin to validate. + /// \return True only for HTTPS `intrade.bar` or `intrade.bar` origins. + inline bool is_allowed_intrade_redirect_origin(const std::string& origin) { + static const std::regex allowed_origin_regex( + R"(^https://intrade(?:[0-9]+)?\.bar$)", + std::regex::icase); + return std::regex_match(origin, allowed_origin_regex); + } + + /// \brief Resolves a login redirect into a request path and optional origin. + /// \param redirect_url Absolute or relative redirect URL. + /// \return The path and origin, or empty when an absolute URL is malformed. + struct LoginRedirectTarget { + std::string path; + std::optional origin; + }; + + inline std::optional resolve_login_redirect_target( + const std::string& redirect_url) { + LoginRedirectTarget target; + target.path = redirect_url; + target.origin = parse_login_redirect_origin(redirect_url); + + if (!target.origin) { + if (redirect_url.find("://") != std::string::npos) { + return std::nullopt; + } + return target; + } + + const auto scheme_end = redirect_url.find("://"); + const auto path_start = redirect_url.find('/', scheme_end + 3); + target.path = path_start == std::string::npos + ? "/" + : redirect_url.substr(path_start); + return target; + } + + /// \brief Merges response `Set-Cookie` headers into an existing cookie string. + /// \param cookies Cookies collected before the response. + /// \param headers HTTP response headers that may contain `Set-Cookie` values. + /// \return Cookie header value containing the existing and newly received cookies. + inline std::string merge_set_cookies( + const std::string& cookies, + const kurlyk::Headers& headers) { + kurlyk::Cookies merged = kurlyk::utils::parse_cookie(cookies); + bool changed = false; + + const auto range = headers.equal_range("set-cookie"); + for (auto it = range.first; it != range.second; ++it) { + const kurlyk::Cookies response_cookies = kurlyk::utils::parse_cookie(it->second); + for (const auto& cookie : response_cookies) { + merged.erase(cookie.first); + merged.emplace(cookie.first, cookie.second); + changed = true; + } + } + + return changed ? kurlyk::utils::to_cookie_string(merged) : cookies; + } + /// \brief Parses balance information and detects the currency. /// \param content Raw HTML fragment containing the balance value and currency symbol. /// \return Optional pair of balance amount and detected currency type. Returns diff --git a/tests/intrade_bar_api/intrade_bar_api_response_test.cpp b/tests/intrade_bar_api/intrade_bar_api_response_test.cpp index 9a35427..1c3dc77 100644 --- a/tests/intrade_bar_api/intrade_bar_api_response_test.cpp +++ b/tests/intrade_bar_api/intrade_bar_api_response_test.cpp @@ -168,6 +168,104 @@ class BoundPortHttpServer : public TradeHistoryHttpServer { std::atomic m_bound_port{0}; }; +struct LocalLoginServer { + explicit LocalLoginServer( + std::string login_redirect = "/auth/opaque-login-token") + : login_redirect(std::move(login_redirect)) {} + + ~LocalLoginServer() { + stop(); + } + + bool start() { + server.config.address = "127.0.0.1"; + server.config.port = 0; + + server.resource["^/health$"]["GET"] = []( + std::shared_ptr response, + std::shared_ptr) { + response->write(SimpleWeb::StatusCode::success_ok, "ok"); + }; + + server.resource["^/login$"]["POST"] = [this]( + std::shared_ptr response, + std::shared_ptr) { + ++login_requests; + response->write( + SimpleWeb::StatusCode::success_ok, + ""); + }; + + server.resource["^/auth/opaque-login-token$"]["GET"] = [this]( + std::shared_ptr response, + std::shared_ptr) { + ++auth_redirect_requests; + SimpleWeb::CaseInsensitiveMultimap headers; + headers.emplace("Location", "/profile"); + headers.emplace("Set-Cookie", "user_id=866188; Path=/; HttpOnly"); + headers.emplace("Set-Cookie", "user_hash=fake_user_hash; Path=/; HttpOnly"); + response->write(SimpleWeb::StatusCode::redirection_found, headers); + }; + + server.resource["^/profile$"]["GET"] = [this]( + std::shared_ptr response, + std::shared_ptr) { + ++profile_requests; + response->write(SimpleWeb::StatusCode::success_ok, "profile"); + }; + + thread = std::thread([this]() { + server.start(); + }); + + if (!wait_until_ready()) { + stop(); + return false; + } + return true; + } + + void stop() { + server.stop(); + if (thread.joinable()) thread.join(); + } + + std::string host() const { + return "http://127.0.0.1:" + std::to_string(server.bound_port()); + } + + bool wait_until_ready() const { + for (int i = 0; i < 100; ++i) { + if (server.bound_port() == 0) { + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + continue; + } + + try { + kurlyk::HttpClient client(host()); + client.set_timeout(1); + client.set_connect_timeout(1); + auto future = client.get("/health", {}, {}); + if (future.wait_for(std::chrono::seconds(1)) == std::future_status::ready) { + auto response = future.get(); + if (response && response->status_code == 200) return true; + } + } catch (...) { + } + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + } + return false; + } + + BoundPortHttpServer server; + std::thread thread; + std::atomic login_requests{0}; + std::atomic auth_redirect_requests{0}; + std::atomic profile_requests{0}; + std::string login_redirect; +}; + struct LocalTradeHistoryServer { explicit LocalTradeHistoryServer( long csv_status_code, @@ -658,6 +756,183 @@ std::int64_t valid_future_aligned_expiry_time() { } // namespace +TEST(IntradeBarLogin, ParsesLegacyCredentialsFromAuthUrl) { + const auto result = parse_login( + ""); + const auto double_quoted = parse_login( + R"()"); + + ASSERT_TRUE(result); + EXPECT_EQ(result->first, "866188"); + EXPECT_EQ(result->second, "fake_user_hash"); + ASSERT_TRUE(double_quoted); + EXPECT_EQ(double_quoted->first, "866188"); + EXPECT_EQ(double_quoted->second, "fake_user_hash"); +} + +TEST(IntradeBarLogin, ParsesOpaqueJavaScriptRedirectWithEitherQuoteStyle) { + const auto single_quoted = parse_login_redirect_url( + ""); + const auto double_quoted = parse_login_redirect_url( + R"()"); + + ASSERT_TRUE(single_quoted); + EXPECT_EQ(*single_quoted, "https://intrade35.bar/auth/opaque-token"); + ASSERT_TRUE(double_quoted); + EXPECT_EQ(*double_quoted, "https://intrade35.bar/auth/other-token"); + EXPECT_FALSE(parse_login_redirect_url("")); +} + +TEST(IntradeBarLogin, ValidatesAndResolvesRedirectOrigins) { + EXPECT_TRUE(is_allowed_intrade_redirect_origin("https://intrade.bar")); + EXPECT_TRUE(is_allowed_intrade_redirect_origin("https://intrade35.bar")); + EXPECT_TRUE(is_allowed_intrade_redirect_origin("https://intrade1000.bar")); + + EXPECT_FALSE(is_allowed_intrade_redirect_origin("http://intrade35.bar")); + EXPECT_FALSE(is_allowed_intrade_redirect_origin("https://evil.example")); + EXPECT_FALSE(is_allowed_intrade_redirect_origin("https://intrade35.bar.evil")); + EXPECT_FALSE(is_allowed_intrade_redirect_origin("https://127.0.0.1")); + EXPECT_FALSE(is_allowed_intrade_redirect_origin("https://localhost")); + EXPECT_FALSE(is_allowed_intrade_redirect_origin("https://intrade35.bar:443")); + + const auto absolute = resolve_login_redirect_target( + "https://intrade35.bar/auth/id=866188&hash=fake_user_hash"); + ASSERT_TRUE(absolute); + ASSERT_TRUE(absolute->origin); + EXPECT_EQ(*absolute->origin, "https://intrade35.bar"); + EXPECT_EQ(absolute->path, "/auth/id=866188&hash=fake_user_hash"); + + const auto relative = resolve_login_redirect_target("/auth/opaque-token"); + ASSERT_TRUE(relative); + EXPECT_FALSE(relative->origin); + EXPECT_EQ(relative->path, "/auth/opaque-token"); + EXPECT_FALSE(resolve_login_redirect_target("https:///auth/opaque-token")); +} + +TEST(IntradeBarLogin, MergesLoginCookiesCaseInsensitively) { + kurlyk::Headers headers; + headers.emplace("Set-Cookie", "user_id=866188; Path=/; HttpOnly"); + headers.emplace("set-cookie", "user_hash=fake_user_hash; Path=/; HttpOnly"); + + const std::string cookies = merge_set_cookies("challenge=fake", headers); + const auto parsed = parse_cookies(cookies); + + ASSERT_TRUE(parsed); + EXPECT_EQ(std::get<0>(*parsed), "866188"); + EXPECT_EQ(std::get<1>(*parsed), "fake_user_hash"); + EXPECT_NE(cookies.find("challenge=fake"), std::string::npos); +} + +TEST(IntradeBarLogin, FollowsOpaqueRedirectAndReturnsIssuedCookies) { + LocalLoginServer server; + ASSERT_TRUE(server.start()); + + TestPlatform platform; + HttpClientComponent http_client(platform); + RequestManager request_manager(platform, http_client); + + auto auth_data = std::make_shared(); + auth_data->host = server.host(); + auth_data->email = "user@example.test"; + auth_data->password = "fake_password"; + + events::AuthDataEvent auth_event(auth_data); + request_manager.on_event(&auth_event); + http_client.get_http_client().set_retry_attempts(0, 0); + + bool callback_received = false; + bool success = false; + std::string user_id; + std::string user_hash; + std::string cookies; + std::string reason; + + request_manager.request_login( + "challenge_name", + "challenge_value", + "challenge=fake", + auth_data, + [&](bool login_success, + const std::string& login_user_id, + const std::string& login_user_hash, + const std::string& login_cookies, + const std::string& login_reason) { + callback_received = true; + success = login_success; + user_id = login_user_id; + user_hash = login_user_hash; + cookies = login_cookies; + reason = login_reason; + }); + + for (int i = 0; i < 500 && !callback_received; ++i) { + http_client.process(); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + + EXPECT_TRUE(callback_received); + EXPECT_TRUE(success) << reason; + EXPECT_EQ(user_id, "866188"); + EXPECT_EQ(user_hash, "fake_user_hash"); + EXPECT_TRUE(parse_cookies(cookies).has_value()); + EXPECT_NE(cookies.find("challenge=fake"), std::string::npos); + EXPECT_EQ(server.login_requests.load(), 1); + EXPECT_EQ(server.auth_redirect_requests.load(), 1); + EXPECT_EQ(server.profile_requests.load(), 1); + + platform.shutdown(); +} + +TEST(IntradeBarLogin, RejectsUntrustedOpaqueRedirectOrigin) { + LocalLoginServer server("https://evil.example/auth/opaque-login-token"); + ASSERT_TRUE(server.start()); + + TestPlatform platform; + HttpClientComponent http_client(platform); + RequestManager request_manager(platform, http_client); + + auto auth_data = std::make_shared(); + auth_data->host = server.host(); + auth_data->email = "user@example.test"; + auth_data->password = "fake_password"; + + events::AuthDataEvent auth_event(auth_data); + request_manager.on_event(&auth_event); + http_client.get_http_client().set_retry_attempts(0, 0); + + bool callback_received = false; + bool success = true; + std::string reason; + request_manager.request_login( + "challenge_name", + "challenge_value", + "challenge=fake", + auth_data, + [&](bool login_success, + const std::string&, + const std::string&, + const std::string&, + const std::string& login_reason) { + callback_received = true; + success = login_success; + reason = login_reason; + }); + + for (int i = 0; i < 500 && !callback_received; ++i) { + http_client.process(); + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + + EXPECT_TRUE(callback_received); + EXPECT_FALSE(success); + EXPECT_EQ(reason, "Rejected login redirect origin."); + EXPECT_EQ(server.login_requests.load(), 1); + EXPECT_EQ(server.auth_redirect_requests.load(), 0); + EXPECT_EQ(server.profile_requests.load(), 0); + + platform.shutdown(); +} + TEST(BaseHttpClientComponent, ShutdownClearsRateLimitsAndIsRepeatable) { optionx::utils::EventBus bus; TestHttpClientComponent component(bus);