diff --git a/src/OverteAuth.cpp b/src/OverteAuth.cpp index b508a3b..ff4f026 100644 --- a/src/OverteAuth.cpp +++ b/src/OverteAuth.cpp @@ -1,22 +1,45 @@ -// OverteAuth.cpp +// OverteAuth.cpp - Enhanced OAuth implementation with browser flow #include "OverteAuth.hpp" #include #include +#include #include #include +#include +#include #include +// For HTTP callback server +#include +#include +#include +#include +#include + +// For file paths +#include +#include +#include + using namespace std::chrono; OverteAuth::OverteAuth() { curl_global_init(CURL_GLOBAL_DEFAULT); + + // Try to load saved token + loadTokenFromFile(); } OverteAuth::~OverteAuth() { + stopCallbackServer(); curl_global_cleanup(); } +// ============================================================================ +// HTTP Helpers +// ============================================================================ + size_t OverteAuth::writeCallback(void* contents, size_t size, size_t nmemb, void* userp) { size_t totalSize = size * nmemb; std::string* response = static_cast(userp); @@ -25,77 +48,103 @@ size_t OverteAuth::writeCallback(void* contents, size_t size, size_t nmemb, void } std::string OverteAuth::extractJsonString(const std::string& json, const std::string& key) { - // Simple JSON string extraction (not a full parser, but works for our needs) + // Simple JSON string extraction std::string searchKey = "\"" + key + "\""; size_t keyPos = json.find(searchKey); - if (keyPos == std::string::npos) { - return ""; - } + if (keyPos == std::string::npos) return ""; - // Find the opening quote after the colon size_t colonPos = json.find(':', keyPos); - if (colonPos == std::string::npos) { - return ""; - } + if (colonPos == std::string::npos) return ""; size_t quoteStart = json.find('"', colonPos); - if (quoteStart == std::string::npos) { - return ""; - } + if (quoteStart == std::string::npos) return ""; - // Find the closing quote size_t quoteEnd = json.find('"', quoteStart + 1); - if (quoteEnd == std::string::npos) { - return ""; - } + if (quoteEnd == std::string::npos) return ""; return json.substr(quoteStart + 1, quoteEnd - quoteStart - 1); } -bool OverteAuth::login(const std::string& username, const std::string& password, const std::string& metaverseUrl) { - m_metaverseUrl = metaverseUrl; - m_username = username; +std::uint64_t OverteAuth::extractJsonInt(const std::string& json, const std::string& key) { + std::string searchKey = "\"" + key + "\""; + size_t keyPos = json.find(searchKey); + if (keyPos == std::string::npos) return 0; + size_t colonPos = json.find(':', keyPos); + if (colonPos == std::string::npos) return 0; + + size_t numStart = colonPos + 1; + while (numStart < json.size() && (json[numStart] == ' ' || json[numStart] == '\t')) { + numStart++; + } + + size_t numEnd = numStart; + while (numEnd < json.size() && (json[numEnd] >= '0' && json[numEnd] <= '9')) { + numEnd++; + } + + if (numEnd == numStart) return 0; + + try { + return std::stoull(json.substr(numStart, numEnd - numStart)); + } catch (...) { + return 0; + } +} + +std::string OverteAuth::urlEncode(const std::string& value) { + std::ostringstream escaped; + escaped.fill('0'); + escaped << std::hex; + + for (char c : value) { + if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') { + escaped << c; + } else { + escaped << '%' << std::setw(2) << int((unsigned char) c); + } + } + + return escaped.str(); +} + +std::string OverteAuth::generateRandomState() { + std::random_device rd; + std::mt19937 gen(rd()); + std::uniform_int_distribution<> dis(0, 15); + + const char* hex_chars = "0123456789abcdef"; + std::string state; + for (int i = 0; i < 32; i++) { + state += hex_chars[dis(gen)]; + } + return state; +} + +bool OverteAuth::httpPost(const std::string& url, const std::string& postData, std::string& response) { CURL* curl = curl_easy_init(); if (!curl) { - std::cerr << "[OverteAuth] Failed to initialize CURL" << std::endl; + m_lastError = "Failed to initialize CURL"; return false; } - // Construct OAuth token endpoint - // From Overte AccountManager: /oauth/token path under metaverse server - std::string tokenUrl = m_metaverseUrl; - if (tokenUrl.back() == '/') { - tokenUrl.pop_back(); - } - tokenUrl += "/api/v1/token"; // Try API v1 endpoint + response.clear(); - std::cout << "[OverteAuth] Token URL: " << tokenUrl << std::endl; - - // Construct POST data - std::ostringstream postData; - postData << "grant_type=password"; - postData << "&username=" << username; // Should URL-encode but simple for now - postData << "&password=" << password; - postData << "&scope=owner"; - - std::string responseData; - - curl_easy_setopt(curl, CURLOPT_URL, tokenUrl.c_str()); - curl_easy_setopt(curl, CURLOPT_POSTFIELDS, postData.str().c_str()); + curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); + curl_easy_setopt(curl, CURLOPT_POSTFIELDS, postData.c_str()); curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeCallback); - curl_easy_setopt(curl, CURLOPT_WRITEDATA, &responseData); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); curl_easy_setopt(curl, CURLOPT_USERAGENT, "Starworld/1.0"); - curl_easy_setopt(curl, CURLOPT_TIMEOUT, 10L); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L); curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYPEER, 1L); + curl_easy_setopt(curl, CURLOPT_SSL_VERIFYHOST, 2L); - // Set Content-Type header struct curl_slist* headers = nullptr; headers = curl_slist_append(headers, "Content-Type: application/x-www-form-urlencoded"); curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); CURLcode res = curl_easy_perform(curl); - long httpCode = 0; curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &httpCode); @@ -103,32 +152,264 @@ bool OverteAuth::login(const std::string& username, const std::string& password, curl_easy_cleanup(curl); if (res != CURLE_OK) { - std::cerr << "[OverteAuth] Login failed: " << curl_easy_strerror(res) << std::endl; + m_lastError = std::string("CURL error: ") + curl_easy_strerror(res); return false; } - if (httpCode != 200) { - std::cerr << "[OverteAuth] Login failed with HTTP " << httpCode << std::endl; - std::cerr << "[OverteAuth] Response: " << responseData << std::endl; + if (httpCode < 200 || httpCode >= 300) { + m_lastError = "HTTP error " + std::to_string(httpCode) + ": " + response; return false; } - // Parse JSON response - m_accessToken = extractJsonString(responseData, "access_token"); - m_refreshToken = extractJsonString(responseData, "refresh_token"); + return true; +} + +bool OverteAuth::openBrowser(const std::string& url) { + std::cout << "[OverteAuth] Opening browser to: " << url << std::endl; + + // Try xdg-open (Linux standard) + std::string command = "xdg-open '" + url + "' >/dev/null 2>&1 &"; + int result = system(command.c_str()); + + if (result != 0) { + // Fallback attempts + command = "x-www-browser '" + url + "' >/dev/null 2>&1 &"; + result = system(command.c_str()); + } + + if (result != 0) { + m_lastError = "Failed to open browser. Please manually navigate to: " + url; + std::cerr << "[OverteAuth] " << m_lastError << std::endl; + return false; + } + + return true; +} + +// ============================================================================ +// Token Management +// ============================================================================ + +bool OverteAuth::isTokenExpired() const { + auto now = duration_cast(system_clock::now().time_since_epoch()).count(); + return now >= m_tokenExpiresAt; +} + +bool OverteAuth::needsRefresh() const { + auto now = duration_cast(system_clock::now().time_since_epoch()).count(); + return (m_tokenExpiresAt - now) < 3600; // Less than 1 hour remaining +} + +bool OverteAuth::parseTokenResponse(const std::string& jsonResponse) { + m_accessToken = extractJsonString(jsonResponse, "access_token"); + m_refreshToken = extractJsonString(jsonResponse, "refresh_token"); if (m_accessToken.empty()) { - std::cerr << "[OverteAuth] No access token in response" << std::endl; + m_lastError = "No access token in response"; + std::string error = extractJsonString(jsonResponse, "error"); + if (!error.empty()) { + std::string errorDesc = extractJsonString(jsonResponse, "error_description"); + m_lastError = error + ": " + errorDesc; + } return false; } - // Set expiration time (default to 1 hour if not specified) - auto now = duration_cast(system_clock::now().time_since_epoch()).count(); - m_tokenExpiresAt = now + 3600; + // Extract expiration + std::uint64_t expiresIn = extractJsonInt(jsonResponse, "expires_in"); + if (expiresIn == 0) { + expiresIn = 3600; // Default to 1 hour + } - std::cout << "[OverteAuth] Successfully authenticated as " << username << std::endl; + auto now = duration_cast(system_clock::now().time_since_epoch()).count(); + m_tokenExpiresAt = now + expiresIn; + + std::cout << "[OverteAuth] Token received, expires in " << expiresIn << " seconds" << std::endl; std::cout << "[OverteAuth] Access token: " << m_accessToken.substr(0, 20) << "..." << std::endl; + // Save to file + saveTokenToFile(); + + return true; +} + +std::string OverteAuth::getConfigDir() { + const char* home = getenv("HOME"); + if (!home) { + struct passwd* pw = getpwuid(getuid()); + home = pw->pw_dir; + } + + std::string configDir = std::string(home) + "/.config/starworld"; + + // Create directory if it doesn't exist + mkdir(configDir.c_str(), 0700); + + return configDir; +} + +std::string OverteAuth::getTokenFilePath() { + return getConfigDir() + "/overte_token.txt"; +} + +bool OverteAuth::loadTokenFromFile() { + std::string filepath = getTokenFilePath(); + std::ifstream file(filepath); + + if (!file.is_open()) { + return false; // File doesn't exist yet + } + + std::string line; + std::getline(file, m_metaverseUrl); + std::getline(file, m_username); + std::getline(file, m_accessToken); + std::getline(file, m_refreshToken); + + if (std::getline(file, line)) { + try { + m_tokenExpiresAt = std::stoull(line); + } catch (...) { + m_tokenExpiresAt = 0; + } + } + + file.close(); + + if (m_accessToken.empty()) { + return false; + } + + std::cout << "[OverteAuth] Loaded saved token for " << m_username << std::endl; + + // Check if token needs refresh + if (isTokenExpired()) { + std::cout << "[OverteAuth] Token expired, attempting refresh..." << std::endl; + return refreshAccessToken(); + } else if (needsRefresh()) { + std::cout << "[OverteAuth] Token expiring soon, refreshing..." << std::endl; + refreshAccessToken(); // Best effort, don't fail if this doesn't work + } + + return !m_accessToken.empty(); +} + +bool OverteAuth::saveTokenToFile() { + std::string filepath = getTokenFilePath(); + std::ofstream file(filepath); + + if (!file.is_open()) { + m_lastError = "Failed to save token to " + filepath; + return false; + } + + file << m_metaverseUrl << "\n"; + file << m_username << "\n"; + file << m_accessToken << "\n"; + file << m_refreshToken << "\n"; + file << m_tokenExpiresAt << "\n"; + + file.close(); + + // Set file permissions to user-only + chmod(filepath.c_str(), 0600); + + std::cout << "[OverteAuth] Token saved to " << filepath << std::endl; + + return true; +} + +// ============================================================================ +// Authentication Methods +// ============================================================================ + +bool OverteAuth::login(const std::string& username, const std::string& password, const std::string& metaverseUrl) { + m_metaverseUrl = metaverseUrl; + m_username = username; + + std::string tokenUrl = m_metaverseUrl; + if (tokenUrl.back() == '/') tokenUrl.pop_back(); + tokenUrl += "/oauth/token"; + + std::ostringstream postData; + postData << "grant_type=password"; + postData << "&username=" << urlEncode(username); + postData << "&password=" << urlEncode(password); + postData << "&scope=owner"; + + std::string response; + if (!httpPost(tokenUrl, postData.str(), response)) { + std::cerr << "[OverteAuth] Login failed: " << m_lastError << std::endl; + return false; + } + + if (!parseTokenResponse(response)) { + std::cerr << "[OverteAuth] Failed to parse token: " << m_lastError << std::endl; + return false; + } + + std::cout << "[OverteAuth] Successfully authenticated as " << username << std::endl; + return true; +} + +bool OverteAuth::loginWithAuthCode(const std::string& authCode, const std::string& redirectUri) { + std::string tokenUrl = m_metaverseUrl; + if (tokenUrl.back() == '/') tokenUrl.pop_back(); + tokenUrl += "/oauth/token"; + + std::ostringstream postData; + postData << "grant_type=authorization_code"; + postData << "&code=" << urlEncode(authCode); + postData << "&redirect_uri=" << urlEncode(redirectUri); + postData << "&client_id=" << m_clientId; + if (!m_clientSecret.empty()) { + postData << "&client_secret=" << urlEncode(m_clientSecret); + } + + std::string response; + if (!httpPost(tokenUrl, postData.str(), response)) { + std::cerr << "[OverteAuth] Token exchange failed: " << m_lastError << std::endl; + return false; + } + + if (!parseTokenResponse(response)) { + std::cerr << "[OverteAuth] Failed to parse token: " << m_lastError << std::endl; + return false; + } + + std::cout << "[OverteAuth] Successfully exchanged authorization code for token" << std::endl; + return true; +} + +bool OverteAuth::refreshAccessToken() { + if (m_refreshToken.empty()) { + m_lastError = "No refresh token available"; + return false; + } + + std::string tokenUrl = m_metaverseUrl; + if (tokenUrl.back() == '/') tokenUrl.pop_back(); + tokenUrl += "/oauth/token"; + + std::ostringstream postData; + postData << "grant_type=refresh_token"; + postData << "&refresh_token=" << urlEncode(m_refreshToken); + postData << "&scope=owner"; + + std::string response; + if (!httpPost(tokenUrl, postData.str(), response)) { + std::cerr << "[OverteAuth] Token refresh failed: " << m_lastError << std::endl; + // Clear tokens on refresh failure + logout(); + return false; + } + + if (!parseTokenResponse(response)) { + std::cerr << "[OverteAuth] Failed to parse refreshed token: " << m_lastError << std::endl; + logout(); + return false; + } + + std::cout << "[OverteAuth] Successfully refreshed access token" << std::endl; return true; } @@ -138,5 +419,260 @@ void OverteAuth::logout() { m_username.clear(); m_tokenExpiresAt = 0; + // Delete token file + std::string filepath = getTokenFilePath(); + unlink(filepath.c_str()); + std::cout << "[OverteAuth] Logged out" << std::endl; } + +// ============================================================================ +// OAuth Callback Server (for browser flow) +// ============================================================================ + +std::string OverteAuth::getCallbackURL() const { + return "http://localhost:" + std::to_string(m_callbackPort) + "/callback"; +} + +bool OverteAuth::startCallbackServer() { + if (m_callbackRunning) { + return true; // Already running + } + + m_callbackServerFd = socket(AF_INET, SOCK_STREAM, 0); + if (m_callbackServerFd < 0) { + m_lastError = "Failed to create socket"; + return false; + } + + // Allow address reuse + int opt = 1; + setsockopt(m_callbackServerFd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); + + struct sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = INADDR_ANY; + addr.sin_port = htons(8765); // Try port 8765 first + + // Try to bind to port 8765, or let OS choose + if (bind(m_callbackServerFd, (struct sockaddr*)&addr, sizeof(addr)) < 0) { + // Let OS choose port + addr.sin_port = 0; + if (bind(m_callbackServerFd, (struct sockaddr*)&addr, sizeof(addr)) < 0) { + m_lastError = "Failed to bind socket"; + close(m_callbackServerFd); + m_callbackServerFd = -1; + return false; + } + } + + // Get the actual port assigned + socklen_t addrLen = sizeof(addr); + if (getsockname(m_callbackServerFd, (struct sockaddr*)&addr, &addrLen) < 0) { + m_lastError = "Failed to get socket name"; + close(m_callbackServerFd); + m_callbackServerFd = -1; + return false; + } + m_callbackPort = ntohs(addr.sin_port); + + if (listen(m_callbackServerFd, 5) < 0) { + m_lastError = "Failed to listen on socket"; + close(m_callbackServerFd); + m_callbackServerFd = -1; + return false; + } + + m_callbackRunning = true; + m_callbackThread = std::make_unique(&OverteAuth::callbackServerThread, this); + + std::cout << "[OverteAuth] Callback server listening on port " << m_callbackPort << std::endl; + return true; +} + +void OverteAuth::stopCallbackServer() { + if (!m_callbackRunning) { + return; + } + + m_callbackRunning = false; + + if (m_callbackServerFd >= 0) { + close(m_callbackServerFd); + m_callbackServerFd = -1; + } + + if (m_callbackThread && m_callbackThread->joinable()) { + m_callbackThread->join(); + } + m_callbackThread.reset(); +} + +void OverteAuth::callbackServerThread() { + std::cout << "[OverteAuth] Callback server thread started" << std::endl; + + while (m_callbackRunning) { + fd_set readfds; + FD_ZERO(&readfds); + FD_SET(m_callbackServerFd, &readfds); + + struct timeval timeout{}; + timeout.tv_sec = 1; + timeout.tv_usec = 0; + + int activity = select(m_callbackServerFd + 1, &readfds, nullptr, nullptr, &timeout); + + if (activity < 0 || !m_callbackRunning) { + break; + } + + if (activity == 0) { + continue; // Timeout, check if still running + } + + struct sockaddr_in clientAddr{}; + socklen_t clientLen = sizeof(clientAddr); + int clientFd = accept(m_callbackServerFd, (struct sockaddr*)&clientAddr, &clientLen); + + if (clientFd < 0) { + if (m_callbackRunning) { + std::cerr << "[OverteAuth] Failed to accept connection" << std::endl; + } + continue; + } + + handleCallbackRequest(clientFd); + close(clientFd); + + // After handling one callback, we can stop + m_callbackRunning = false; + } + + std::cout << "[OverteAuth] Callback server thread stopped" << std::endl; +} + +void OverteAuth::handleCallbackRequest(int clientFd) { + char buffer[4096]; + ssize_t bytesRead = recv(clientFd, buffer, sizeof(buffer) - 1, 0); + + if (bytesRead <= 0) { + return; + } + + buffer[bytesRead] = '\0'; + std::string request(buffer); + + // Parse HTTP GET request + // Format: GET /callback?code=ABC&state=XYZ HTTP/1.1 + size_t getPos = request.find("GET /callback"); + if (getPos == std::string::npos) { + const char* response = "HTTP/1.1 404 Not Found\r\n\r\n"; + send(clientFd, response, strlen(response), 0); + return; + } + + // Extract code and state + std::string code, state; + size_t codePos = request.find("code="); + if (codePos != std::string::npos) { + size_t codeStart = codePos + 5; + size_t codeEnd = request.find_first_of("& \r\n", codeStart); + code = request.substr(codeStart, codeEnd - codeStart); + } + + size_t statePos = request.find("state="); + if (statePos != std::string::npos) { + size_t stateStart = statePos + 6; + size_t stateEnd = request.find_first_of("& \r\n", stateStart); + state = request.substr(stateStart, stateEnd - stateStart); + } + + // Verify state matches (CSRF protection) + if (state != m_authState) { + std::cerr << "[OverteAuth] State mismatch - possible CSRF attack!" << std::endl; + const char* response = "HTTP/1.1 400 Bad Request\r\nContent-Type: text/html\r\n\r\n" + "

Authentication Failed

Invalid state parameter

"; + send(clientFd, response, strlen(response), 0); + return; + } + + if (code.empty()) { + std::cerr << "[OverteAuth] No authorization code received" << std::endl; + const char* response = "HTTP/1.1 400 Bad Request\r\nContent-Type: text/html\r\n\r\n" + "

Authentication Failed

No authorization code received

"; + send(clientFd, response, strlen(response), 0); + return; + } + + m_receivedAuthCode = code; + std::cout << "[OverteAuth] Received authorization code: " << code.substr(0, 10) << "..." << std::endl; + + // Send success response + const char* response = "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n" + "

Authentication Successful!

" + "

You can now close this window and return to Starworld.

" + ""; + send(clientFd, response, strlen(response), 0); +} + +bool OverteAuth::loginWithBrowser(const std::string& metaverseUrl) { + m_metaverseUrl = metaverseUrl; + + // Start callback server + if (!startCallbackServer()) { + std::cerr << "[OverteAuth] Failed to start callback server: " << m_lastError << std::endl; + return false; + } + + // Generate CSRF protection state + m_authState = generateRandomState(); + m_receivedAuthCode.clear(); + + // Construct authorization URL + std::string authUrl = m_metaverseUrl; + if (authUrl.back() == '/') authUrl.pop_back(); + authUrl += "/oauth/authorize?"; + authUrl += "response_type=code"; + authUrl += "&client_id=" + urlEncode(m_clientId); + authUrl += "&redirect_uri=" + urlEncode(getCallbackURL()); + authUrl += "&scope=owner"; + authUrl += "&state=" + m_authState; + + std::cout << "[OverteAuth] ===================================" << std::endl; + std::cout << "[OverteAuth] Opening browser for authentication" << std::endl; + std::cout << "[OverteAuth] If browser doesn't open automatically, navigate to:" << std::endl; + std::cout << "[OverteAuth] " << authUrl << std::endl; + std::cout << "[OverteAuth] ===================================" << std::endl; + + if (!openBrowser(authUrl)) { + std::cerr << "[OverteAuth] Failed to open browser" << std::endl; + // Continue anyway - user can manually open URL + } + + // Wait for callback (max 5 minutes) + std::cout << "[OverteAuth] Waiting for authentication callback..." << std::endl; + auto startTime = std::chrono::steady_clock::now(); + while (m_callbackRunning && m_receivedAuthCode.empty()) { + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + + auto elapsed = std::chrono::duration_cast( + std::chrono::steady_clock::now() - startTime).count(); + + if (elapsed > 300) { // 5 minutes timeout + std::cerr << "[OverteAuth] Authentication timeout" << std::endl; + stopCallbackServer(); + return false; + } + } + + stopCallbackServer(); + + if (m_receivedAuthCode.empty()) { + m_lastError = "No authorization code received"; + return false; + } + + // Exchange authorization code for access token + std::cout << "[OverteAuth] Exchanging authorization code for access token..." << std::endl; + return loginWithAuthCode(m_receivedAuthCode, getCallbackURL()); +}