diff --git a/code/framework/CMakeLists.txt b/code/framework/CMakeLists.txt index 92f751eb6..80b42bbab 100644 --- a/code/framework/CMakeLists.txt +++ b/code/framework/CMakeLists.txt @@ -200,6 +200,7 @@ if(WIN32) src/external/epic/manifest.cpp src/external/microsoft_store/package.cpp src/external/rockstar/library.cpp + src/external/epic/auth.cpp src/utils/hooking/hook_function.cpp src/utils/hooking/hooking_patterns.cpp src/utils/hooking/pattern_table.cpp @@ -420,7 +421,8 @@ if(WIN32) ) link_shared_deps(FrameworkLoader) - target_link_libraries(FrameworkLoader ${CLIENT_SHARED_LIBS} MafiaHubServices FrameworkLoaderData ntdll sfd) + # winhttp + crypt32: Epic account auth (external/epic/auth.cpp) — OAuth over HTTPS + DPAPI. + target_link_libraries(FrameworkLoader ${CLIENT_SHARED_LIBS} MafiaHubServices FrameworkLoaderData ntdll sfd winhttp crypt32) target_link_libraries(FrameworkLoaderData udis86 minhook::minhook) target_compile_definitions(FrameworkLoaderData PRIVATE _USE_MATH_DEFINES NOMINMAX) target_link_options(FrameworkLoaderData PUBLIC /EXPORT:CoreRT_SetupSEHHandler /EXPORT:GetThreadLocalStorage /EXPORT:CoreSetExceptionOverride) @@ -474,6 +476,26 @@ if(WIN32) # clients need the subprocess exe at runtime; build it with every FrameworkClient consumer add_dependencies(FrameworkClient cef_subprocess) + # Epic sign-in window, run by a launcher only when no stored sign-in refreshes, so launchers stay + # CEF-free. It compiles auth.cpp itself rather than taking FrameworkLoader's PE-loader link setup. + add_executable(epic_sign_in + src/external/epic/sign_in_main.cpp + src/external/epic/sign_in_view.cpp + src/external/epic/auth.cpp + ) + target_link_libraries(epic_sign_in CEF Framework winhttp crypt32) + target_compile_definitions(epic_sign_in PRIVATE _USE_MATH_DEFINES NOMINMAX) + set_target_properties(epic_sign_in PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin" + LINK_FLAGS "/SUBSYSTEM:WINDOWS /ENTRY:mainCRTStartup" + ) + fw_set_binary_identity(epic_sign_in PRODUCT "MafiaHub Framework" VERSION ${FW_VERSION} COMPANY "MafiaHub" DESCRIPTION "MafiaHub Framework Epic Sign-in") + if(TARGET CEFCopyDlls) + add_dependencies(epic_sign_in CEFCopyDlls) + endif() + # launchers spawn it from their own directory; build it with every FrameworkLoader consumer + add_dependencies(FrameworkLoader epic_sign_in) + # Ensure runtime DLLs are copied to bin directory for all client/server targets if(TARGET libnode_copy_dll) add_dependencies(FrameworkClient libnode_copy_dll) diff --git a/code/framework/src/external/epic/auth.cpp b/code/framework/src/external/epic/auth.cpp new file mode 100644 index 000000000..fb8085f16 --- /dev/null +++ b/code/framework/src/external/epic/auth.cpp @@ -0,0 +1,528 @@ +/* + * MafiaHub OSS license + * Copyright (c) 2021-2023, MafiaHub. All rights reserved. + * + * This file comes from MafiaHub, hosted at https://github.com/MafiaHub/Framework. + * See LICENSE file in the source repository for information regarding licensing. + */ + +#include "auth.h" + +#include "logging/logger.h" +#include "utils/string_utils.h" + +// Its own block so include sorting can't move it: wincrypt.h and winhttp.h need it first. +#include + +#include +#include + +#include + +#include +#include +#include +#include +#include +#include + +namespace Framework::External::Epic { + namespace { + // Public "Epic Games Launcher" client credentials — the same ones the launcher itself and + // tools like Legendary use to mint exchange codes for games the account owns. Not secret. + constexpr const char *kClientId = "34a02cf8f4414e29b15921876da36f9a"; + constexpr const char *kClientSecret = "daafbccc737745039dffe53d94fc76cf"; + + constexpr const wchar_t *kAuthHost = L"account-public-service-prod.ol.epicgames.com"; + constexpr const wchar_t *kTokenPath = L"/account/api/oauth/token"; + constexpr const wchar_t *kExchPath = L"/account/api/oauth/exchange"; + constexpr const wchar_t *kEcomHost = L"ecommerceintegration-public-service-ecomprod02.ol.epicgames.com"; + + // Browser sign-in: lands on a page whose JSON body contains "authorizationCode". + constexpr const wchar_t *kLoginUrl = L"https://www.epicgames.com/id/login?redirectUrl=" + L"https%3A%2F%2Fwww.epicgames.com%2Fid%2Fapi%2Fredirect%3FclientId%3D" + L"34a02cf8f4414e29b15921876da36f9a%26responseType%3Dcode"; + + // Diagnostics only — never log tokens, codes or the ownership token. + auto Logger() { + return Logging::GetLogger(FRAMEWORK_INNER_LAUNCHER); + } + + std::filesystem::path ExeDir() { + wchar_t buf[MAX_PATH] = {}; + GetModuleFileNameW(nullptr, buf, MAX_PATH); + return std::filesystem::path(buf).parent_path(); + } + + // Per-user secrets -> %LOCALAPPDATA%\MafiaHub: survives launcher reinstalls, works under a + // read-only install, shared across Framework Epic games. Fall back to the exe dir. + std::filesystem::path DataDir() { + wchar_t localAppData[MAX_PATH] = {}; + if (GetEnvironmentVariableW(L"LOCALAPPDATA", localAppData, MAX_PATH)) { + std::error_code ec; + const std::filesystem::path dir = std::filesystem::path(localAppData) / L"MafiaHub"; + std::filesystem::create_directories(dir, ec); + if (!ec) { + return dir; + } + } + return ExeDir(); + } + + std::filesystem::path AuthFile() { + return DataDir() / L"epic_auth.bin"; + } + + bool IsAsciiAlnum(unsigned char c) { + return (c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'); + } + + // An application/x-www-form-urlencoded value: everything but RFC 3986 unreserved bytes escaped. + std::string FormEncode(std::string_view value) { + constexpr char kHex[] = "0123456789ABCDEF"; + std::string out; + out.reserve(value.size() * 3); + for (const unsigned char c : value) { + if (IsAsciiAlnum(c) || c == '-' || c == '.' || c == '_' || c == '~') { + out += static_cast(c); + } + else { + out += '%'; + out += kHex[c >> 4]; + out += kHex[c & 0xF]; + } + } + return out; + } + + // Epic catalog and account ids are alphanumeric; they also name files and URL paths here. + bool IsEpicId(std::string_view value) { + return !value.empty() && value.size() <= 64 && std::ranges::all_of(value, [](char c) { + return IsAsciiAlnum(static_cast(c)); + }); + } + + // Quoted on the game's command line: a '"' would end the argument early, and trailing + // backslashes would escape the closing quote. + std::string QuotableArgument(std::string value) { + std::erase(value, '"'); + while (!value.empty() && value.back() == '\\') { + value.pop_back(); + } + return value; + } + + // One session for the process: WinHTTP sessions are thread-safe and pool connections. + // Never closed, so no request can outlive it during shutdown. + HINTERNET Session() { + static const HINTERNET session = [] { + HINTERNET handle = WinHttpOpen(L"MafiaHubLauncher/1.0", WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0); + if (handle) { + WinHttpSetTimeouts(handle, 5000, 5000, 15000, 30000); + } + return handle; + }(); + return session; + } + + struct ScopedHandle { + HINTERNET handle; + ~ScopedHandle() { + if (handle) { + WinHttpCloseHandle(handle); + } + } + }; + + struct HttpResponse { + DWORD status = 0; + std::string body; + }; + + // One HTTPS request to an Epic service. Headers are CRLF-joined. nullopt on transport failure, + // including a body cut short; an HTTP error status is still a response. + std::optional HttpsRequest(const wchar_t *host, const wchar_t *path, const wchar_t *method, const std::wstring &headers, const std::string &body) { + const HINTERNET session = Session(); + if (!session) { + return std::nullopt; + } + const ScopedHandle connect {WinHttpConnect(session, host, INTERNET_DEFAULT_HTTPS_PORT, 0)}; + if (!connect.handle) { + return std::nullopt; + } + const ScopedHandle request {WinHttpOpenRequest(connect.handle, method, path, nullptr, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, WINHTTP_FLAG_SECURE)}; + if (!request.handle) { + return std::nullopt; + } + if (!headers.empty()) { + WinHttpAddRequestHeaders(request.handle, headers.c_str(), static_cast(-1), WINHTTP_ADDREQ_FLAG_ADD | WINHTTP_ADDREQ_FLAG_REPLACE); + } + const LPVOID payload = body.empty() ? WINHTTP_NO_REQUEST_DATA : const_cast(body.data()); + if (!WinHttpSendRequest(request.handle, WINHTTP_NO_ADDITIONAL_HEADERS, 0, payload, static_cast(body.size()), static_cast(body.size()), 0) || !WinHttpReceiveResponse(request.handle, nullptr)) { + return std::nullopt; + } + + HttpResponse response; + DWORD len = sizeof(response.status); + WinHttpQueryHeaders(request.handle, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, WINHTTP_HEADER_NAME_BY_INDEX, &response.status, &len, WINHTTP_NO_HEADER_INDEX); + + for (;;) { + DWORD avail = 0; + if (!WinHttpQueryDataAvailable(request.handle, &avail)) { + return std::nullopt; + } + if (avail == 0) { + return response; // end of the response + } + std::string chunk(avail, '\0'); + DWORD read = 0; + if (!WinHttpReadData(request.handle, chunk.data(), avail, &read)) { + return std::nullopt; + } + chunk.resize(read); + response.body += chunk; + } + } + + using HttpResult = Utils::Result; + + // The body of a 200 response. Anything else is logged under `what`: Rejected when Epic refused + // what was sent (HTTP 400/401), Unreachable for a transport failure or any other status. + HttpResult HttpsOk(const char *what, const wchar_t *host, const wchar_t *path, const wchar_t *method, const std::wstring &headers, const std::string &body = {}) { + auto response = HttpsRequest(host, path, method, headers, body); + if (!response) { + Logger()->warn("Epic {} request failed in transport", what); + return HttpResult::Err(AuthError::Unreachable); + } + if (response->status != 200) { + Logger()->warn("Epic {} request returned HTTP {}", what, response->status); + const bool rejected = response->status == 400 || response->status == 401; + return HttpResult::Err(rejected ? AuthError::Rejected : AuthError::Unreachable); + } + return HttpResult::Ok(std::move(response->body)); + } + + std::wstring BasicAuthHeader() { + const std::string cred = std::string(kClientId) + ":" + kClientSecret; + return L"Authorization: Basic " + Utils::StringUtils::Utf8ToWide(Utils::StringUtils::Base64Encode(cred)) + L"\r\nContent-Type: application/x-www-form-urlencoded"; + } + + // Tokens from an oauth/token response body; nullopt if it isn't a token grant. + std::optional ParseTokens(const std::string &body) { + try { + const auto j = nlohmann::json::parse(body); + Tokens tokens; + tokens.accessToken = j.value("access_token", std::string {}); + tokens.refreshToken = j.value("refresh_token", std::string {}); + tokens.accountId = j.value("account_id", std::string {}); + tokens.displayName = j.value("displayName", std::string {}); + return tokens.Valid() ? std::optional(std::move(tokens)) : std::nullopt; + } + catch (const std::exception &) { + return std::nullopt; + } + } + + AuthResult TokenGrant(const std::string &formBody) { + const auto body = HttpsOk("token", kAuthHost, kTokenPath, L"POST", BasicAuthHeader(), formBody); + if (!body) { + return AuthResult::Err(body.GetError()); + } + auto tokens = ParseTokens(body.GetValue()); + return tokens ? AuthResult::Ok(std::move(*tokens)) : AuthResult::Err(AuthError::Unreachable); + } + + AuthResult RefreshGrant(const std::string &refreshToken) { + return TokenGrant("grant_type=refresh_token&token_type=eg1&refresh_token=" + FormEncode(refreshToken)); + } + + AuthResult AuthCodeGrant(const std::string &authCode) { + return TokenGrant("grant_type=authorization_code&token_type=eg1&code=" + FormEncode(authCode)); + } + + // --- refresh-token persistence (DPAPI, tied to the Windows user) --- + + bool SaveRefreshToken(const std::string &rt) { + if (rt.empty()) { + return false; + } + DATA_BLOB in {static_cast(rt.size()), reinterpret_cast(const_cast(rt.data()))}; + DATA_BLOB out {}; + if (!CryptProtectData(&in, L"MafiaHub-Epic", nullptr, nullptr, nullptr, 0, &out)) { + return false; + } + std::ofstream f(AuthFile(), std::ios::binary | std::ios::trunc); + const bool ok = f && f.write(reinterpret_cast(out.pbData), out.cbData).good(); + LocalFree(out.pbData); + return ok; + } + + // Epic rotates the refresh token on every grant, so a failed save costs the next launch its + // silent sign-in. + void PersistRotatedRefreshToken(const std::string &rt) { + if (!SaveRefreshToken(rt)) { + Logger()->warn("Could not persist the rotated Epic refresh token; the next launch will ask to sign in again"); + } + } + + std::optional LoadRefreshToken() { + std::ifstream f(AuthFile(), std::ios::binary); + if (!f) { + return std::nullopt; + } + const std::string blob((std::istreambuf_iterator(f)), std::istreambuf_iterator()); + if (blob.empty()) { + return std::nullopt; + } + DATA_BLOB in {static_cast(blob.size()), reinterpret_cast(const_cast(blob.data()))}; + DATA_BLOB out {}; + if (!CryptUnprotectData(&in, nullptr, nullptr, nullptr, nullptr, 0, &out)) { + return std::nullopt; + } + std::string rt(reinterpret_cast(out.pbData), out.cbData); + LocalFree(out.pbData); + return rt.empty() ? std::nullopt : std::optional(std::move(rt)); + } + + // --- interactive sign-in --- + + std::string ReadClipboardText() { + std::string result; + if (!OpenClipboard(nullptr)) { + return result; + } + if (HANDLE h = GetClipboardData(CF_UNICODETEXT)) { + if (const auto *w = static_cast(GlobalLock(h))) { + const int need = WideCharToMultiByte(CP_UTF8, 0, w, -1, nullptr, 0, nullptr, nullptr); + if (need > 0) { + result.resize(need - 1); + WideCharToMultiByte(CP_UTF8, 0, w, -1, result.data(), need, nullptr, nullptr); + } + GlobalUnlock(h); + } + } + CloseClipboard(); + return result; + } + + // Accept either the bare authorizationCode or the whole JSON blob the redirect page shows. + std::string ExtractAuthCode(std::string s) { + const auto trim = [](std::string &v) { + const char *ws = " \t\r\n\"'"; + const auto a = v.find_first_not_of(ws); + const auto b = v.find_last_not_of(ws); + v = (a == std::string::npos) ? "" : v.substr(a, b - a + 1); + }; + if (s.find("authorizationCode") != std::string::npos) { + try { + const auto j = nlohmann::json::parse(s); + if (j.contains("authorizationCode")) { + std::string c = j.value("authorizationCode", std::string {}); + trim(c); + return c; + } + } + catch (const std::exception &) { + // not clean JSON (e.g. browser-rendered) — scan for the value directly + } + const size_t k = s.find("authorizationCode"); + const size_t q1 = s.find('"', s.find(':', k) + 1); + const size_t q2 = q1 == std::string::npos ? q1 : s.find('"', q1 + 1); + if (q1 != std::string::npos && q2 != std::string::npos) { + return s.substr(q1 + 1, q2 - q1 - 1); + } + } + trim(s); + return s; + } + + std::optional InteractiveSignIn(const std::wstring &productName) { + const std::wstring title = productName.empty() ? L"Epic sign-in" : productName + L" — Epic sign-in"; + + ShellExecuteW(nullptr, L"open", kLoginUrl, nullptr, nullptr, SW_SHOWNORMAL); + MessageBoxW(nullptr, + L"A browser window was opened to sign in to Epic Games.\n\n" + L"After you sign in, a page shows text containing an \"authorizationCode\".\n" + L"Select and copy that code (Ctrl+C), then click OK here.", + title.c_str(), MB_OK | MB_ICONINFORMATION); + + const std::string code = ExtractAuthCode(ReadClipboardText()); + if (code.empty()) { + Logger()->warn("Epic sign-in: the clipboard held no authorization code"); + return std::nullopt; + } + auto tokens = AuthCodeGrant(code); + if (!tokens) { + Logger()->warn("Epic sign-in: the authorization code grant failed"); + return std::nullopt; + } + Logger()->info("Epic sign-in succeeded"); + return std::move(tokens).GetValue(); + } + + // Ownership-verification token the Epic launcher drops under \.egstore. We take + // the first .ovt found; empty if the user hasn't launched the game through Epic yet (which + // is what seeds it). Non-throwing iteration so an unreadable subdir can't abort the launch. + std::wstring FindOwnershipToken(const std::string &installDir) { + if (installDir.empty()) { + return {}; + } + std::error_code ec; + const std::filesystem::path egstore = std::filesystem::u8path(installDir) / ".egstore"; + std::filesystem::recursive_directory_iterator it(egstore, ec), end; + for (; !ec && it != end; it.increment(ec)) { + std::error_code entryEc; + if (it->is_regular_file(entryEc) && it->path().extension() == ".ovt") { + return it->path().wstring(); + } + } + return {}; + } + + // Mint a fresh ownership token, as the Epic launcher does per launch: the .egstore copy is + // only as fresh as the last launch from Epic, and a stale one is refused. Written to DataDir. + std::wstring MintOwnershipToken(const Tokens &tokens, const std::string &catalogNamespace, const std::string &catalogItemId) { + if (!tokens.Valid()) { + return {}; + } + if (!IsEpicId(tokens.accountId) || !IsEpicId(catalogNamespace) || !IsEpicId(catalogItemId)) { + Logger()->warn("Epic account or catalog ids are not alphanumeric; not minting an ownership token"); + return {}; + } + const std::wstring path = L"/ecommerceintegration/api/public/platforms/EPIC/identities/" + Utils::StringUtils::Utf8ToWide(tokens.accountId) + L"/ownershipToken"; + const std::wstring headers = L"Authorization: Bearer " + Utils::StringUtils::Utf8ToWide(tokens.accessToken) + L"\r\nContent-Type: application/x-www-form-urlencoded"; + const std::string body = "nsCatalogItemId=" + FormEncode(catalogNamespace + ":" + catalogItemId); + + // Opaque bytes, written as returned: the game reads them, we don't (Legendary does the same). + const auto token = HttpsOk("ownership token", kEcomHost, path.c_str(), L"POST", headers, body); + if (!token) { + return {}; + } + if (token.GetValue().empty()) { + Logger()->warn("Epic ownership token response was empty"); + return {}; + } + + const std::filesystem::path file = DataDir() / Utils::StringUtils::Utf8ToWide(catalogNamespace + catalogItemId + ".ovt"); + std::ofstream f(file, std::ios::binary | std::ios::trunc); + if (!f.write(token.GetValue().data(), static_cast(token.GetValue().size()))) { + Logger()->warn("Could not write the minted Epic ownership token"); + return {}; + } + return file.wstring(); + } + } // namespace + + AuthResult TryRefreshStoredAuth() { + const auto rt = LoadRefreshToken(); + if (!rt) { + return AuthResult::Err(AuthError::NoStoredSignIn); + } + auto tokens = RefreshGrant(*rt); + if (!tokens) { + if (tokens.GetError() == AuthError::Rejected) { + Logger()->info("Epic refused the stored sign-in; forgetting it"); + ClearStoredAuth(); + } + return tokens; + } + Logger()->info("Refreshed the stored Epic sign-in"); + PersistRotatedRefreshToken(tokens.GetValue().refreshToken); + return tokens; + } + + AuthResult EnsureAuthenticated(const std::wstring &productName) { + auto stored = TryRefreshStoredAuth(); + if (stored || !NeedsSignIn(stored.GetError())) { + return stored; + } + auto tokens = InteractiveSignIn(productName); + if (!tokens) { + return stored; + } + PersistRotatedRefreshToken(tokens->refreshToken); + return AuthResult::Ok(std::move(*tokens)); + } + + std::wstring GetLoginUrl() { + return kLoginUrl; + } + + bool SignInWithAuthorizationCode(const std::string &pageTextOrCode) { + const std::string code = ExtractAuthCode(pageTextOrCode); + if (code.empty()) { + Logger()->warn("Epic sign-in: the page held no authorization code"); + return false; + } + const auto tokens = AuthCodeGrant(code); + if (!tokens) { + Logger()->warn("Epic sign-in: the authorization code grant failed"); + return false; + } + // The tokens are dropped here, so the stored refresh token is the only result there is. + if (!SaveRefreshToken(tokens.GetValue().refreshToken)) { + Logger()->error("Epic sign-in succeeded, but the refresh token could not be persisted"); + return false; + } + Logger()->info("Epic sign-in succeeded"); + return true; + } + + std::optional GetExchangeCode(const Tokens &tokens) { + if (!tokens.Valid()) { + return std::nullopt; + } + const auto body = HttpsOk("exchange code", kAuthHost, kExchPath, L"GET", L"Authorization: Bearer " + Utils::StringUtils::Utf8ToWide(tokens.accessToken)); + if (!body) { + return std::nullopt; + } + try { + std::string code = nlohmann::json::parse(body.GetValue()).value("code", std::string {}); + return code.empty() ? std::nullopt : std::optional(std::move(code)); + } + catch (const std::exception &) { + return std::nullopt; + } + } + + std::wstring BuildLaunchArgs(const Tokens &tokens, const std::string &exchangeCode, const std::string &appName, const std::string &sandboxId, const std::string &catalogItemId, const std::string &installDir) { + using Utils::StringUtils::Utf8ToWide; + std::wstring a = L" -AUTH_LOGIN=unused -AUTH_PASSWORD=" + Utf8ToWide(exchangeCode) + L" -AUTH_TYPE=exchangecode"; + if (!appName.empty()) { + a += L" -epicapp=" + Utf8ToWide(appName); + } + a += L" -epicenv=Prod"; + + // Ownership proof — the piece the "use the Epic launcher" gate actually checks. + if (!sandboxId.empty()) { + a += L" -epicsandboxid=" + Utf8ToWide(sandboxId); + } + std::wstring ovt = MintOwnershipToken(tokens, sandboxId, catalogItemId); + if (!ovt.empty()) { + Logger()->info("Minted a fresh Epic ownership token"); + } + else if (ovt = FindOwnershipToken(installDir); !ovt.empty()) { + Logger()->info("Falling back to the Epic launcher's .egstore ownership token"); + } + else { + Logger()->warn("No Epic ownership token was minted or found under .egstore"); + } + if (!ovt.empty()) { + a += L" -epicovt=\"" + ovt + L"\""; + } + + if (!tokens.accountId.empty()) { + a += L" -epicuserid=" + Utf8ToWide(tokens.accountId); + } + if (const std::string name = QuotableArgument(tokens.displayName); !name.empty()) { + a += L" -epicusername=\"" + Utf8ToWide(name) + L"\""; + } + a += L" -epiclocale=en -EpicPortal"; + return a; + } + + void ClearStoredAuth() { + std::error_code ec; + std::filesystem::remove(AuthFile(), ec); + } +} // namespace Framework::External::Epic diff --git a/code/framework/src/external/epic/auth.h b/code/framework/src/external/epic/auth.h new file mode 100644 index 000000000..2c15e9821 --- /dev/null +++ b/code/framework/src/external/epic/auth.h @@ -0,0 +1,72 @@ +/* + * MafiaHub OSS license + * Copyright (c) 2021-2023, MafiaHub. All rights reserved. + * + * This file comes from MafiaHub, hosted at https://github.com/MafiaHub/Framework. + * See LICENSE file in the source repository for information regarding licensing. + */ + +#pragma once + +#include "utils/result.h" + +#include +#include + +// Epic Games account auth for launching an EOS (Epic Store) game outside the Epic launcher. +// Mimics what the Epic launcher itself does: authenticate the user once (browser sign-in -> +// authorizationCode), keep a refresh token, and mint a short-lived single-use "exchange code" +// per launch that goes on the game's command line so its EOS ownership/entitlement check passes. +// Same approach as the open-source "Legendary" launcher. Store-generic — not tied to any game. +namespace Framework::External::Epic { + struct Tokens { + std::string accessToken; + std::string refreshToken; + std::string accountId; + std::string displayName; + + bool Valid() const { + return !accessToken.empty(); + } + }; + + enum class AuthError { + None, + NoStoredSignIn, + Rejected, // Epic refused the stored sign-in (HTTP 400/401): signed out, expired or revoked + Unreachable, // no usable answer from Epic + }; + using AuthResult = Utils::Result; + + // Only a missing or rejected sign-in calls for a new one. When Epic can't be reached a new + // sign-in fails the same way, and the stored one still works once it can. + inline bool NeedsSignIn(AuthError error) { + return error == AuthError::NoStoredSignIn || error == AuthError::Rejected; + } + + // A usable access token: the stored refresh token first, else, when NeedsSignIn, a browser + + // clipboard sign-in labelled with productName. The refresh token is persisted, DPAPI-encrypted, + // under %LOCALAPPDATA%\MafiaHub. + AuthResult EnsureAuthenticated(const std::wstring &productName = {}); + + // The silent half of EnsureAuthenticated: refresh the stored token, never show any UI. A rejected + // stored sign-in is forgotten; one Epic could not be asked about is kept. + AuthResult TryRefreshStoredAuth(); + + // Mint a fresh single-use exchange code from a valid access token (expires in ~5 min). + std::optional GetExchangeCode(const Tokens &tokens); + + // Embedded-webview sign-in: navigate to GetLoginUrl(), then hand the resulting redirect-page + // text (or a bare authorizationCode) to SignInWithAuthorizationCode to mint + persist tokens. + // It blocks on the network, so call it off any UI thread. False when nothing was persisted. + std::wstring GetLoginUrl(); + bool SignInWithAuthorizationCode(const std::string &pageTextOrCode); + + // The "-AUTH_TYPE=exchangecode ..." fragment (leading space) for the game's EOS init. The ids + // come from the Epic manifest and mint a fresh ownership token (.ovt), without which the game + // shows the "use the Epic launcher" gate; installDir's .egstore copy is the fallback. + std::wstring BuildLaunchArgs(const Tokens &tokens, const std::string &exchangeCode, const std::string &appName, const std::string &sandboxId, const std::string &catalogItemId, const std::string &installDir); + + // Forget the stored credentials (e.g. after a hard auth failure so the next launch re-prompts). + void ClearStoredAuth(); +} // namespace Framework::External::Epic diff --git a/code/framework/src/external/epic/sign_in_main.cpp b/code/framework/src/external/epic/sign_in_main.cpp new file mode 100644 index 000000000..2a5f48fb3 --- /dev/null +++ b/code/framework/src/external/epic/sign_in_main.cpp @@ -0,0 +1,95 @@ +/* + * MafiaHub OSS license + * Copyright (c) 2021-2026, MafiaHub. All rights reserved. + * + * This file comes from MafiaHub, hosted at https://github.com/MafiaHub/Framework. + * See LICENSE file in the source repository for information regarding licensing. + */ + +#include "sign_in_view.h" + +#include "logging/logger.h" + +#include "include/cef_app.h" + +#include + +#include +#include + +// epic_sign_in.exe: the launcher runs it when no stored Epic sign-in refreshes. Exits 0 once a refresh +// token is persisted, 1 on cancel or failure. Its own process so launchers never host CEF. +namespace { + class SignInApp final + : public CefApp + , public CefBrowserProcessHandler { + public: + CefRefPtr GetBrowserProcessHandler() override { + return this; + } + + void OnBeforeCommandLineProcessing(const CefString &, CefRefPtr commandLine) override { + commandLine->AppendSwitch("disable-spell-checking"); + commandLine->AppendSwitch("disable-pdf-extension"); + commandLine->AppendSwitch("disable-component-update"); + } + + void OnContextInitialized() override { + Framework::External::Epic::ShowSignInWindow([this](bool signedIn) { + _signedIn = signedIn; + CefQuitMessageLoop(); + }); + } + + bool SignedIn() const { + return _signedIn; + } + + private: + bool _signedIn = false; + IMPLEMENT_REFCOUNTING(SignInApp); + }; + + // A private profile keeps the Epic session cookie (signing in again stays one click) out of the + // shared %LOCALAPPDATA%\CEF\User Data, which other CEF apps also read. + std::wstring ProfileDirectory() { + wchar_t localAppData[MAX_PATH] = {}; + if (!GetEnvironmentVariableW(L"LOCALAPPDATA", localAppData, MAX_PATH)) { + return {}; + } + const auto dir = std::filesystem::path(localAppData) / L"MafiaHub" / L"epic_auth_cef"; + std::error_code ec; + std::filesystem::create_directories(dir, ec); + return ec ? std::wstring {} : dir.wstring(); + } +} // namespace + +int main() { + CefMainArgs mainArgs(::GetModuleHandleW(nullptr)); + CefRefPtr app(new SignInApp()); + + // CEF re-launches this exe for its renderer and GPU subprocesses; those return here. + const int subprocessExit = CefExecuteProcess(mainArgs, app, nullptr); + if (subprocessExit >= 0) { + return subprocessExit; + } + + // The waiting launcher still writes its own log in the same folder. + Framework::Logging::GetInstance()->SetLogName("epic_sign_in"); + + CefSettings settings; + settings.no_sandbox = true; + settings.multi_threaded_message_loop = false; + settings.log_severity = LOGSEVERITY_WARNING; + if (const std::wstring profile = ProfileDirectory(); !profile.empty()) { + CefString(&settings.root_cache_path) = profile; + CefString(&settings.cache_path) = profile; + } + + if (!CefInitialize(mainArgs, settings, app, nullptr)) { + return 1; + } + CefRunMessageLoop(); + CefShutdown(); + return app->SignedIn() ? 0 : 1; +} diff --git a/code/framework/src/external/epic/sign_in_view.cpp b/code/framework/src/external/epic/sign_in_view.cpp new file mode 100644 index 000000000..91c3a0c12 --- /dev/null +++ b/code/framework/src/external/epic/sign_in_view.cpp @@ -0,0 +1,161 @@ +/* + * MafiaHub OSS license + * Copyright (c) 2021-2026, MafiaHub. All rights reserved. + * + * This file comes from MafiaHub, hosted at https://github.com/MafiaHub/Framework. + * See LICENSE file in the source repository for information regarding licensing. + */ + +#include "sign_in_view.h" + +#include "auth.h" + +#include "include/base/cef_callback.h" +#include "include/cef_browser.h" +#include "include/cef_client.h" +#include "include/cef_request_handler.h" +#include "include/cef_string_visitor.h" +#include "include/views/cef_browser_view.h" +#include "include/views/cef_window.h" +#include "include/views/cef_window_delegate.h" +#include "include/wrapper/cef_closure_task.h" + +#include +#include + +namespace Framework::External::Epic { + namespace { + constexpr int kWindowWidth = 520; + constexpr int kWindowHeight = 760; + + // The redirect page whose body carries the single-use authorizationCode. + constexpr const char *kRedirectMarker = "/id/api/redirect"; + + class SignInClient; + + // Reads the redirect page's decoded text on the UI thread, then completes the sign-in. + class PageTextVisitor final: public CefStringVisitor { + public: + PageTextVisitor(CefRefPtr client, CefRefPtr browser): _client(client), _browser(browser) {} + void Visit(const CefString &text) override; + + private: + CefRefPtr _client; + CefRefPtr _browser; + IMPLEMENT_REFCOUNTING(PageTextVisitor); + }; + + class SignInClient final + : public CefClient + , public CefLifeSpanHandler + , public CefLoadHandler + , public CefRequestHandler { + public: + explicit SignInClient(std::function onDone): _onDone(std::move(onDone)) {} + + CefRefPtr GetLifeSpanHandler() override { + return this; + } + CefRefPtr GetLoadHandler() override { + return this; + } + CefRefPtr GetRequestHandler() override { + return this; + } + + // The redirect page shows the authorization code, so hide the window as navigation to it + // starts, before it can paint. The page still has to load for the code to be read. + bool OnBeforeBrowse(CefRefPtr browser, CefRefPtr, CefRefPtr request, bool, bool) override { + if (request->GetURL().ToString().find(kRedirectMarker) != std::string::npos) { + if (CefRefPtr view = CefBrowserView::GetForBrowser(browser)) { + if (CefRefPtr window = view->GetWindow()) { + window->Hide(); + } + } + } + return false; + } + + void OnLoadEnd(CefRefPtr browser, CefRefPtr frame, int) override { + if (_handled || !frame->IsMain()) { + return; + } + if (frame->GetURL().ToString().find(kRedirectMarker) == std::string::npos) { + return; + } + _handled = true; + frame->GetText(new PageTextVisitor(this, browser)); + } + + // The grant blocks on the network, so it runs off the UI thread; the window closes back on it. + void Complete(CefRefPtr browser, const std::string &pageText) { + CefPostTask(TID_FILE_USER_BLOCKING, base::BindOnce(&SignInClient::SignIn, this, browser, pageText)); + } + + void SignIn(CefRefPtr browser, const std::string &pageText) { + const bool signedIn = SignInWithAuthorizationCode(pageText); + CefPostTask(TID_UI, base::BindOnce(&SignInClient::Finish, this, browser, signedIn)); + } + + void Finish(CefRefPtr browser, bool signedIn) { + _result = signedIn; + if (CefRefPtr view = CefBrowserView::GetForBrowser(browser)) { + if (CefRefPtr window = view->GetWindow()) { + window->Close(); + } + } + } + + void OnBeforeClose(CefRefPtr) override { + if (_onDone) { + _onDone(_result); + _onDone = nullptr; + } + } + + private: + std::function _onDone; + bool _handled = false; + bool _result = false; + IMPLEMENT_REFCOUNTING(SignInClient); + }; + + void PageTextVisitor::Visit(const CefString &text) { + _client->Complete(_browser, text.ToString()); + } + + class SignInWindowDelegate final: public CefWindowDelegate { + public: + explicit SignInWindowDelegate(CefRefPtr view): _view(view) {} + + void OnWindowCreated(CefRefPtr window) override { + window->AddChildView(_view); + window->CenterWindow(CefSize(kWindowWidth, kWindowHeight)); + window->SetTitle("Sign in to Epic Games"); + window->Show(); + _view->RequestFocus(); + } + void OnWindowDestroyed(CefRefPtr) override { + _view = nullptr; + } + CefSize GetPreferredSize(CefRefPtr) override { + return CefSize(kWindowWidth, kWindowHeight); + } + bool CanResize(CefRefPtr) override { + return true; + } + + private: + CefRefPtr _view; + IMPLEMENT_REFCOUNTING(SignInWindowDelegate); + }; + } // namespace + + void ShowSignInWindow(std::function onDone) { + CefRefPtr client(new SignInClient(std::move(onDone))); + CefBrowserSettings settings; + const CefString url = GetLoginUrl(); + CefRefPtr view = CefBrowserView::CreateBrowserView(client, url, settings, nullptr, nullptr, nullptr); + CefWindow::CreateTopLevelWindow(new SignInWindowDelegate(view)); + } +} // namespace Framework::External::Epic diff --git a/code/framework/src/external/epic/sign_in_view.h b/code/framework/src/external/epic/sign_in_view.h new file mode 100644 index 000000000..2966b5510 --- /dev/null +++ b/code/framework/src/external/epic/sign_in_view.h @@ -0,0 +1,18 @@ +/* + * MafiaHub OSS license + * Copyright (c) 2021-2026, MafiaHub. All rights reserved. + * + * This file comes from MafiaHub, hosted at https://github.com/MafiaHub/Framework. + * See LICENSE file in the source repository for information regarding licensing. + */ + +#pragma once + +#include + +namespace Framework::External::Epic { + // Opens Epic's sign-in page in a CEF window, reads the authorization code off the redirect page + // and persists the session. onDone(true) once signed in, false on failure or cancel. Call on the + // CEF UI thread while the message loop runs. + void ShowSignInWindow(std::function onDone); +} // namespace Framework::External::Epic diff --git a/code/framework/src/integrations/client/instance.cpp b/code/framework/src/integrations/client/instance.cpp index f2305e2f0..5d0317e70 100644 --- a/code/framework/src/integrations/client/instance.cpp +++ b/code/framework/src/integrations/client/instance.cpp @@ -943,15 +943,18 @@ namespace Framework::Integrations::Client { // Who is asking, carried in the connection request itself: the server's admission gate // decides on it before either side reports a connection. // - // The Steam id is launcher-set when the game was located through Steam; Win32 read, the CRT's - // getenv copy predates it. + // The Steam id is launcher-set when the game was located through Steam, the Epic id when it + // authenticated through Epic; Win32 read, the CRT's getenv copy predates them. char steamId[32] = {}; + char epicId[33] = {}; // EOS account id: 32 hex chars + null #ifdef _WIN32 GetEnvironmentVariableA("MafiaHubSteamId", steamId, sizeof(steamId)); + GetEnvironmentVariableA("MafiaHubEpicId", epicId, sizeof(epicId)); #endif Framework::Networking::RPC::ClientIdentity identity; identity.name = _currentState.nickname; identity.steamId = steamId; + identity.epicId = epicId; identity.discordId = _presence ? _presence->GetUserId() : ""; identity.hardwareId = Framework::Utils::GetHardwareId(); identity.ticket = _currentState.ticket; diff --git a/code/framework/src/integrations/server/instance.cpp b/code/framework/src/integrations/server/instance.cpp index 4a9c1c793..29dcba377 100644 --- a/code/framework/src/integrations/server/instance.cpp +++ b/code/framework/src/integrations/server/instance.cpp @@ -617,6 +617,7 @@ namespace Framework::Integrations::Server { data.nickname = identity.name; data.hardwareID = identity.hardwareId; data.steamId = identity.steamId; + data.epicId = identity.epicId; data.discordId = identity.discordId; OnPlayerConnect(data); @@ -722,6 +723,10 @@ namespace Framework::Integrations::Server { digits(identity.steamId, 32); digits(identity.discordId, 32); digits(identity.hardwareId, 128); + // The Epic account id is hex, not decimal, so it needs its own charset check. + if (identity.epicId.size() > 32 || identity.epicId.find_first_not_of("0123456789abcdefABCDEF") != std::string::npos) { + identity.epicId.clear(); + } if (identity.ticket.size() > Framework::Networking::RPC::ClientIdentity::kMaxTicketLength) { Logging::GetLogger(FRAMEWORK_INNER_SERVER)->warn("Dropping a {} byte ticket from {}", identity.ticket.size(), guid.g); identity.ticket.clear(); diff --git a/code/framework/src/integrations/server/instance.h b/code/framework/src/integrations/server/instance.h index 6cb79b0dd..ac35765f6 100644 --- a/code/framework/src/integrations/server/instance.h +++ b/code/framework/src/integrations/server/instance.h @@ -168,6 +168,7 @@ namespace Framework::Integrations::Server { std::string nickname; std::string hardwareID; std::string steamId; + std::string epicId; std::string discordId; }; diff --git a/code/framework/src/integrations/server/scripting/builtins/pending_connection.cpp b/code/framework/src/integrations/server/scripting/builtins/pending_connection.cpp index 23d6a2a02..5c75bf71b 100644 --- a/code/framework/src/integrations/server/scripting/builtins/pending_connection.cpp +++ b/code/framework/src/integrations/server/scripting/builtins/pending_connection.cpp @@ -44,6 +44,7 @@ namespace Framework::Integrations::Server::Scripting::Builtins { DefineValue(isolate, context, connection, "nickname", v8pp::to_v8(isolate, identity.name)); DefineValue(isolate, context, connection, "steamId", v8pp::to_v8(isolate, identity.steamId)); + DefineValue(isolate, context, connection, "epicId", v8pp::to_v8(isolate, identity.epicId)); DefineValue(isolate, context, connection, "discordId", v8pp::to_v8(isolate, identity.discordId)); DefineValue(isolate, context, connection, "hardwareId", v8pp::to_v8(isolate, identity.hardwareId)); DefineValue(isolate, context, connection, "ticket", v8pp::to_v8(isolate, identity.ticket)); @@ -109,6 +110,7 @@ namespace Framework::Integrations::Server::Scripting::Builtins { auto &type = catalog.data_type("PendingConnection", "A player asking to join, handed to `playerConnecting`. Every identifier is reported by the player's own client and is not verified by the server."); type.add_property("nickname", "string", "The name the player asked to join under.", true); type.add_property("steamId", "string", "Steam identifier the client reported, or an empty string when it had none.", true); + type.add_property("epicId", "string", "Epic Games account identifier the client reported, or an empty string when it had none.", true); type.add_property("discordId", "string", "Discord identifier the client reported, or an empty string when it had none.", true); type.add_property("hardwareId", "string", "Framework hardware identifier the client reported, or an empty string when it had none.", true); type.add_property("ticket", "string", diff --git a/code/framework/src/launcher/platforms/platform.h b/code/framework/src/launcher/platforms/platform.h index 27fe3de74..215a25b8f 100644 --- a/code/framework/src/launcher/platforms/platform.h +++ b/code/framework/src/launcher/platforms/platform.h @@ -84,6 +84,11 @@ namespace Framework::Launcher { return true; } + // Appended to the game's command line once PrepareLaunch succeeded, with a leading space + virtual std::wstring GetLaunchArguments() const { + return {}; + } + // PE loading: the executable's file bytes before mapping, its sections once mapped (code // that is not on disk can be put back there), and the entry point to enter it at virtual bool PrepareImage(const PlatformHost &host, const std::wstring &executablePath, std::span image) { diff --git a/code/framework/src/launcher/platforms/platforms.cpp b/code/framework/src/launcher/platforms/platforms.cpp index 1f21d690d..d452d35c4 100644 --- a/code/framework/src/launcher/platforms/platforms.cpp +++ b/code/framework/src/launcher/platforms/platforms.cpp @@ -8,6 +8,7 @@ #include "platforms.h" +#include "external/epic/auth.h" #include "external/epic/manifest.h" #include "external/microsoft_store/package.h" #include "external/rockstar/library.h" @@ -38,6 +39,35 @@ namespace Framework::Launcher::Platforms { std::ranges::replace(root, L'\\', L'/'); return root; } + + enum class EpicSignInWindow { + Missing, + SignedIn, + Failed + }; + + // The Framework's epic_sign_in.exe, shipped beside the launcher. It persists the refresh token + // itself; Missing also covers a helper that would not start, so the caller can still fall back. + EpicSignInWindow RunEpicSignInWindow(const std::filesystem::path &projectPath) { + const std::filesystem::path helper = projectPath / L"epic_sign_in.exe"; + std::error_code ec; + if (!std::filesystem::exists(helper, ec)) { + return EpicSignInWindow::Missing; + } + std::wstring commandLine = L"\"" + helper.wstring() + L"\""; + STARTUPINFOW startupInfo {}; + startupInfo.cb = sizeof(startupInfo); + PROCESS_INFORMATION processInfo {}; + if (!CreateProcessW(helper.c_str(), commandLine.data(), nullptr, nullptr, FALSE, 0, nullptr, projectPath.c_str(), &startupInfo, &processInfo)) { + return EpicSignInWindow::Missing; + } + CloseHandle(processInfo.hThread); + WaitForSingleObject(processInfo.hProcess, INFINITE); + DWORD exitCode = 1; + GetExitCodeProcess(processInfo.hProcess, &exitCode); + CloseHandle(processInfo.hProcess); + return exitCode == 0 ? EpicSignInWindow::SignedIn : EpicSignInWindow::Failed; + } } // namespace PlatformCheckStatus Steam::Resolve(const PlatformHost &host, PlatformResolution &resolution, bool reportErrors) { @@ -184,12 +214,58 @@ namespace Framework::Launcher::Platforms { return unavailable(fmt::format("Epic points at {}, but the game executable is not there", Utils::StringUtils::WideToNormal(installPath))); } - // Unlike Steam there's no runtime DLL to inject or app-id file to drop; any Epic launch - // args go through ProjectConfiguration::additionalLaunchArguments. + // Unlike Steam there's no runtime DLL to inject or app-id file to drop; the launch arguments + // come from PrepareLaunch, once this is the platform the game starts with. + _app = app; resolution.gameRoot = installPath; return PlatformCheckStatus::OK; } + bool Epic::PrepareLaunch(const PlatformHost &host) { + // A sign-in UI persists a refresh token that we then use like a stored one. Without the + // sign-in window shipped beside the launcher, fall back to the browser + clipboard flow. + const auto signIn = [&]() -> External::Epic::AuthResult { + auto stored = External::Epic::TryRefreshStoredAuth(); + if (stored || !External::Epic::NeedsSignIn(stored.GetError())) { + return stored; + } + if (_options.signIn) { + return _options.signIn() ? External::Epic::TryRefreshStoredAuth() : stored; + } + switch (RunEpicSignInWindow(host.GetProjectPath())) { + case EpicSignInWindow::SignedIn: return External::Epic::TryRefreshStoredAuth(); + case EpicSignInWindow::Failed: return stored; + case EpicSignInWindow::Missing: break; + } + return External::Epic::EnsureAuthenticated(Utils::StringUtils::Utf8ToWide(host.GetConfig().name)); + }; + const auto auth = signIn(); + if (!auth) { + if (auth.GetError() == External::Epic::AuthError::Unreachable) { + host.ReportError("Could not reach Epic Games to sign in, please check your connection and try again"); + } + else { + host.ReportError("Epic sign-in is required to play the Epic version of the game"); + } + return false; + } + const auto &tokens = auth.GetValue(); + + // The refresh above just succeeded, so a failure here says nothing against the stored sign-in. + const auto exchangeCode = External::Epic::GetExchangeCode(tokens); + if (!exchangeCode) { + host.ReportError("Could not obtain an Epic launch code, please try again"); + return false; + } + + // Hand the account id to the in-process client (ClientIdentity), as Steam does + if (!tokens.accountId.empty()) { + host.SetProcessVariable(L"MafiaHubEpicId", Utils::StringUtils::Utf8ToWide(tokens.accountId)); + } + _launchArguments = External::Epic::BuildLaunchArgs(tokens, *exchangeCode, _app.appName, _app.catalogNamespace, _app.catalogItemId, _app.installLocation); + return true; + } + PlatformCheckStatus Rockstar::Resolve(const PlatformHost &host, PlatformResolution &resolution, bool reportErrors) { const auto unavailable = [&](const std::string &reason) { return host.ReportUnavailable(GetName(), reason, reportErrors); diff --git a/code/framework/src/launcher/platforms/platforms.h b/code/framework/src/launcher/platforms/platforms.h index 905d31860..5927d531a 100644 --- a/code/framework/src/launcher/platforms/platforms.h +++ b/code/framework/src/launcher/platforms/platforms.h @@ -12,6 +12,7 @@ #include "launcher/loaders/image_snapshot.h" +#include #include #include @@ -48,11 +49,19 @@ namespace Framework::Launcher::Platforms { }; // The Epic launcher's manifests, matched by `appName` (the catalog's "AppName"), else by the - // launch executable's file name + // launch executable's file name. Outside that launcher the game's EOS ownership check wants what + // it would pass, so the player is signed in to Epic before the game starts. class Epic final: public Platform { public: + using SignInProc = fu2::function; + struct Options { std::wstring appName; + + // signs the player in when no stored Epic sign-in refreshes, persisting it through + // External::Epic::SignInWithAuthorizationCode; unset, the Framework's epic_sign_in.exe + // window beside the launcher, else a browser + clipboard sign-in + SignInProc signIn; }; explicit Epic(Options options = {}): _options(std::move(options)) {} @@ -62,9 +71,16 @@ namespace Framework::Launcher::Platforms { } PlatformCheckStatus Resolve(const PlatformHost &host, PlatformResolution &resolution, bool reportErrors) override; + bool PrepareLaunch(const PlatformHost &host) override; + + std::wstring GetLaunchArguments() const override { + return _launchArguments; + } private: Options _options; + External::Epic::InstalledApp _app; + std::wstring _launchArguments; }; // The Rockstar Games Launcher's registry entries, matched by `titleKey` (e.g. L"GTA: San diff --git a/code/framework/src/launcher/project.cpp b/code/framework/src/launcher/project.cpp index 99e4af4a5..e442874db 100644 --- a/code/framework/src/launcher/project.cpp +++ b/code/framework/src/launcher/project.cpp @@ -119,6 +119,35 @@ LPSTR BuildGameCommandLineA() { return buffer; } +// The launch arguments carry live credentials (store exchange code, account id, ownership-token +// path). This line reaches the dev console and log files, so log the shape and never the values. +// Markers match in any case: the game parses them that way, and additionalLaunchArguments is free text. +std::string RedactGameCommandLine(std::string commandLine) { + for (const char *key : {"-auth_password=", "-epicuserid=", "-epicovt="}) { + const std::string marker(key); + std::string lowered = Framework::Utils::StringUtils::ToLower(commandLine); + for (size_t pos = lowered.find(marker); pos != std::string::npos; pos = lowered.find(marker, pos)) { + const size_t valueStart = pos + marker.size(); + size_t valueEnd = std::string::npos; + if (valueStart < commandLine.size() && commandLine[valueStart] == '"') { + const size_t closing = commandLine.find('"', valueStart + 1); + valueEnd = (closing == std::string::npos) ? commandLine.size() : closing + 1; + } + else { + valueEnd = commandLine.find(' ', valueStart); + if (valueEnd == std::string::npos) { + valueEnd = commandLine.size(); + } + } + const std::string placeholder = ""; + commandLine.replace(valueStart, valueEnd - valueStart, placeholder); + lowered.replace(valueStart, valueEnd - valueStart, placeholder); + pos = valueStart + placeholder.size(); + } + } + return commandLine; +} + bool SynchronizeUCRTCommandLine() { const auto ucrt = GetModuleHandleW(L"ucrtbase.dll"); if (!ucrt) { @@ -380,6 +409,7 @@ namespace Framework::Launcher { if (!_platform->PrepareLaunch(*this)) { return false; } + _config.additionalLaunchArguments += _platform->GetLaunchArguments(); // Use real scaling const auto shcore = LoadLibraryW(L"shcore.dll"); @@ -895,8 +925,7 @@ namespace Framework::Launcher { Loaders::ApplyMappedImageIdentity(_gamePath); if (SynchronizeUCRTCommandLine()) { - Logging::GetLogger(FRAMEWORK_INNER_LAUNCHER)->info( - "Mapped game command line: {}", BuildGameCommandLineA()); + Logging::GetLogger(FRAMEWORK_INNER_LAUNCHER)->info("Mapped game command line: {}", RedactGameCommandLine(BuildGameCommandLineA())); } // The OS loader normally dispatches executable TLS callbacks before the entry diff --git a/code/framework/src/networking/rpc/client_identity.h b/code/framework/src/networking/rpc/client_identity.h index 86b5d7bb0..72eeb2f63 100644 --- a/code/framework/src/networking/rpc/client_identity.h +++ b/code/framework/src/networking/rpc/client_identity.h @@ -34,6 +34,9 @@ namespace Framework::Networking::RPC { std::string steamId; std::string discordId; std::string hardwareId; + // Authenticated Epic account id; empty when the game was not launched through Epic. Ordered + // to match Decode(), which is strict: a payload missing any field is refused outright. + std::string epicId; // Opaque string the client was launched with (a launcher-issued join ticket, typically). // The framework neither reads nor verifies it; it is handed to playerConnecting as is. @@ -44,6 +47,7 @@ namespace Framework::Networking::RPC { bs->Serialize(write, steamId); bs->Serialize(write, discordId); bs->Serialize(write, hardwareId); + bs->Serialize(write, epicId); bs->Serialize(write, ticket); } @@ -62,7 +66,7 @@ namespace Framework::Networking::RPC { } MafiaNet::BitStream bs(reinterpret_cast(const_cast(payload.data())), static_cast(payload.size()), false); ClientIdentity identity; - if (!bs.Read(identity.name) || !bs.Read(identity.steamId) || !bs.Read(identity.discordId) || !bs.Read(identity.hardwareId) || !bs.Read(identity.ticket)) { + if (!bs.Read(identity.name) || !bs.Read(identity.steamId) || !bs.Read(identity.discordId) || !bs.Read(identity.hardwareId) || !bs.Read(identity.epicId) || !bs.Read(identity.ticket)) { return std::nullopt; } return identity; diff --git a/code/framework/src/scripting/builtins/player.cpp b/code/framework/src/scripting/builtins/player.cpp index fd4db68af..6702b36cc 100644 --- a/code/framework/src/scripting/builtins/player.cpp +++ b/code/framework/src/scripting/builtins/player.cpp @@ -53,6 +53,11 @@ namespace Framework::Scripting::Builtins { return identity ? identity->steamId : ""; } + std::string Player::GetEpicId() const { + const auto *identity = ResolveIdentity(); + return identity ? identity->epicId : ""; + } + std::string Player::GetDiscordId() const { const auto *identity = ResolveIdentity(); return identity ? identity->discordId : ""; @@ -192,6 +197,7 @@ namespace Framework::Scripting::Builtins { v8pp::metadata::docs("void", {v8pp::metadata::param("color", "number", false, "Packed 0xAARRGGBB color.")}, "Tints the text on this player's nametag.")); cls->property("steamId", &Player::GetSteamId, v8pp::metadata::property_docs("string", "Authenticated Steam identifier, or an empty string when unavailable.")); + cls->property("epicId", &Player::GetEpicId, v8pp::metadata::property_docs("string", "Epic Games account identifier the client reported, not verified by the server, or an empty string when unavailable.")); cls->property("discordId", &Player::GetDiscordId, v8pp::metadata::property_docs("string", "Authenticated Discord identifier, or an empty string when unavailable.")); cls->property("hardwareId", &Player::GetHardwareId, v8pp::metadata::property_docs("string", "Framework hardware identifier, or an empty string when unavailable.")); cls->property("ping", &Player::GetPing, v8pp::metadata::property_docs("number", "Current round-trip latency in milliseconds, or -1 when unavailable.")); diff --git a/code/framework/src/scripting/builtins/player.h b/code/framework/src/scripting/builtins/player.h index 2b2e1a09f..14986dea7 100644 --- a/code/framework/src/scripting/builtins/player.h +++ b/code/framework/src/scripting/builtins/player.h @@ -44,6 +44,7 @@ namespace Framework::Scripting::Builtins { // Client-announced identity (RPC::ClientIdentity). Server-only, unverified; empty when absent. std::string GetSteamId() const; + std::string GetEpicId() const; std::string GetDiscordId() const; std::string GetHardwareId() const; diff --git a/code/framework/src/utils/string_utils.h b/code/framework/src/utils/string_utils.h index 2891db685..0c9e6edbb 100644 --- a/code/framework/src/utils/string_utils.h +++ b/code/framework/src/utils/string_utils.h @@ -145,4 +145,27 @@ namespace Framework::Utils::StringUtils { } text.resize(end); } + + // Standard base64 (RFC 4648, '+'/'/' alphabet, '=' padded). + inline std::string Base64Encode(const std::string &in) { + static const char *T = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + std::string out; + unsigned val = 0; + int bits = -6; + for (unsigned char c : in) { + val = (val << 8) + c; + bits += 8; + while (bits >= 0) { + out.push_back(T[(val >> bits) & 0x3F]); + bits -= 6; + } + } + if (bits > -6) { + out.push_back(T[((val << 8) >> (bits + 8)) & 0x3F]); + } + while (out.size() % 4) { + out.push_back('='); + } + return out; + } } // namespace Framework::Utils::StringUtils diff --git a/code/tests/modules/network_packets_ut.h b/code/tests/modules/network_packets_ut.h index d2eb1f3d6..e93117266 100644 --- a/code/tests/modules/network_packets_ut.h +++ b/code/tests/modules/network_packets_ut.h @@ -81,6 +81,7 @@ MODULE(network_packets, { out.steamId = "steam-1"; out.discordId = "discord-2"; out.hardwareId = "hw-3"; + out.epicId = "epic-4"; out.ticket = "one-time-ticket"; MafiaNet::BitStream bs; @@ -90,6 +91,7 @@ MODULE(network_packets, { STREQUALS(in.steamId.c_str(), "steam-1"); STREQUALS(in.discordId.c_str(), "discord-2"); STREQUALS(in.hardwareId.c_str(), "hw-3"); + STREQUALS(in.epicId.c_str(), "epic-4"); STREQUALS(in.ticket.c_str(), "one-time-ticket"); }); @@ -101,6 +103,7 @@ MODULE(network_packets, { out.steamId = "76561198000000000"; out.discordId = "123"; out.hardwareId = "456"; + out.epicId = "abc123def456"; out.ticket = "tkt-abc+123"; const auto in = RPC::ClientIdentity::Decode(out.Encode()); @@ -109,6 +112,7 @@ MODULE(network_packets, { STREQUALS(in->steamId.c_str(), "76561198000000000"); STREQUALS(in->discordId.c_str(), "123"); STREQUALS(in->hardwareId.c_str(), "456"); + STREQUALS(in->epicId.c_str(), "abc123def456"); STREQUALS(in->ticket.c_str(), "tkt-abc+123"); });