From 7d395167eaf0753efc8d2851cb65513cb7da9f30 Mon Sep 17 00:00:00 2001 From: KHeartz Date: Wed, 8 Jul 2026 23:54:37 -0400 Subject: [PATCH 01/11] Launcher: add Epic account auth for exchange-code launch Authenticate an Epic account (browser sign-in once, DPAPI-stored refresh token) and mint a single-use exchange code plus locate the ownership token, so an EOS game can be spawned outside the Epic launcher and clear its ownership check. Store-generic (Framework::External::Epic); pairs with the Epic manifest discovery in the platform commit. --- code/framework/CMakeLists.txt | 4 +- code/framework/src/external/epic/auth.cpp | 391 ++++++++++++++++++++++ code/framework/src/external/epic/auth.h | 50 +++ code/framework/src/utils/string_utils.h | 23 ++ 4 files changed, 467 insertions(+), 1 deletion(-) create mode 100644 code/framework/src/external/epic/auth.cpp create mode 100644 code/framework/src/external/epic/auth.h diff --git a/code/framework/CMakeLists.txt b/code/framework/CMakeLists.txt index 92f751eb6..07cfa14eb 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) diff --git a/code/framework/src/external/epic/auth.cpp b/code/framework/src/external/epic/auth.cpp new file mode 100644 index 000000000..683938297 --- /dev/null +++ b/code/framework/src/external/epic/auth.cpp @@ -0,0 +1,391 @@ +/* + * 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 "utils/string_utils.h" + +#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"; + + // 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"; + + std::filesystem::path ExeDir() { + wchar_t buf[MAX_PATH] = {}; + GetModuleFileNameW(nullptr, buf, MAX_PATH); + return std::filesystem::path(buf).parent_path(); + } + + std::filesystem::path AuthFile() { + // Per-user secret -> %LOCALAPPDATA%\MafiaHub: survives launcher reinstalls, works under a + // read-only install, shared across Framework Epic games. Fall back to the exe dir. + 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 / L"epic_auth.bin"; + } + } + return ExeDir() / L"epic_auth.bin"; + } + + std::filesystem::path LogFile() { + return ExeDir() / L"epic_auth.log"; // diagnostics stay with the other launcher logs + } + + // Diagnostics only — never logs tokens or the exchange code. + void Log(const std::string &msg) { + std::ofstream f(LogFile(), std::ios::app); + if (f) { + SYSTEMTIME t; + GetLocalTime(&t); + char ts[32]; + std::snprintf(ts, sizeof(ts), "[%02d:%02d:%02d] ", t.wHour, t.wMinute, t.wSecond); + f << ts << msg << "\n"; + } + } + + std::wstring Widen(const std::string &s) { + if (s.empty()) { + return {}; + } + const int need = MultiByteToWideChar(CP_UTF8, 0, s.data(), (int)s.size(), nullptr, 0); + std::wstring w(need, L'\0'); + MultiByteToWideChar(CP_UTF8, 0, s.data(), (int)s.size(), w.data(), need); + return w; + } + + // One HTTPS request to the Epic account service. Headers are CRLF-joined. Returns false on + // transport failure; an HTTP error still returns true with outStatus set for the caller. + bool HttpsRequest(const wchar_t *path, const wchar_t *method, const std::wstring &headers, + const std::string &body, std::string &outResp, DWORD &outStatus) { + outResp.clear(); + outStatus = 0; + + HINTERNET hSession = WinHttpOpen(L"MafiaHubLauncher/1.0", WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, + WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0); + if (!hSession) { + return false; + } + WinHttpSetTimeouts(hSession, 5000, 5000, 15000, 30000); + + bool ok = false; + HINTERNET hConnect = WinHttpConnect(hSession, kAuthHost, INTERNET_DEFAULT_HTTPS_PORT, 0); + if (hConnect) { + HINTERNET hReq = WinHttpOpenRequest(hConnect, method, path, nullptr, WINHTTP_NO_REFERER, + WINHTTP_DEFAULT_ACCEPT_TYPES, WINHTTP_FLAG_SECURE); + if (hReq) { + if (!headers.empty()) { + WinHttpAddRequestHeaders(hReq, headers.c_str(), (DWORD)-1, + WINHTTP_ADDREQ_FLAG_ADD | WINHTTP_ADDREQ_FLAG_REPLACE); + } + const BOOL sent = WinHttpSendRequest( + hReq, WINHTTP_NO_ADDITIONAL_HEADERS, 0, + body.empty() ? WINHTTP_NO_REQUEST_DATA : (LPVOID)body.data(), + (DWORD)body.size(), (DWORD)body.size(), 0); + if (sent && WinHttpReceiveResponse(hReq, nullptr)) { + DWORD status = 0, len = sizeof(status); + WinHttpQueryHeaders(hReq, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, + WINHTTP_HEADER_NAME_BY_INDEX, &status, &len, WINHTTP_NO_HEADER_INDEX); + outStatus = status; + + for (;;) { + DWORD avail = 0; + if (!WinHttpQueryDataAvailable(hReq, &avail) || avail == 0) { + break; + } + std::string chunk(avail, '\0'); + DWORD read = 0; + if (!WinHttpReadData(hReq, chunk.data(), avail, &read)) { + break; + } + chunk.resize(read); + outResp += chunk; + } + ok = true; + } + WinHttpCloseHandle(hReq); + } + WinHttpCloseHandle(hConnect); + } + WinHttpCloseHandle(hSession); + return ok; + } + + std::wstring BasicAuthHeader() { + const std::string cred = std::string(kClientId) + ":" + kClientSecret; + return L"Authorization: Basic " + Widen(Utils::StringUtils::Base64Encode(cred)) + + L"\r\nContent-Type: application/x-www-form-urlencoded"; + } + + // Fill Tokens from an oauth/token response body. Returns false if it isn't a token grant. + bool ParseTokens(const std::string &body, Tokens &out) { + try { + const auto j = nlohmann::json::parse(body); + if (!j.contains("access_token")) { + return false; + } + out.accessToken = j.value("access_token", std::string {}); + out.refreshToken = j.value("refresh_token", std::string {}); + out.accountId = j.value("account_id", std::string {}); + out.displayName = j.value("displayName", std::string {}); + return out.Valid(); + } + catch (const std::exception &) { + return false; + } + } + + bool TokenGrant(const std::string &formBody, Tokens &out) { + std::string resp; + DWORD status = 0; + if (!HttpsRequest(kTokenPath, L"POST", BasicAuthHeader(), formBody, resp, status)) { + Log("token: transport failure"); + return false; + } + if (status != 200) { + Log("token: HTTP " + std::to_string(status)); + return false; + } + return ParseTokens(resp, out); + } + + bool RefreshGrant(const std::string &refreshToken, Tokens &out) { + return TokenGrant("grant_type=refresh_token&token_type=eg1&refresh_token=" + refreshToken, out); + } + + bool AuthCodeGrant(const std::string &authCode, Tokens &out) { + return TokenGrant("grant_type=authorization_code&token_type=eg1&code=" + authCode, out); + } + + // --- refresh-token persistence (DPAPI, tied to the Windows user) --- + + bool SaveRefreshToken(const std::string &rt) { + if (rt.empty()) { + return false; + } + DATA_BLOB in {(DWORD)rt.size(), (BYTE *)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((const char *)out.pbData, out.cbData).good(); + LocalFree(out.pbData); + return ok; + } + + bool LoadRefreshToken(std::string &rt) { + std::ifstream f(AuthFile(), std::ios::binary); + if (!f) { + return false; + } + const std::string blob((std::istreambuf_iterator(f)), std::istreambuf_iterator()); + if (blob.empty()) { + return false; + } + DATA_BLOB in {(DWORD)blob.size(), (BYTE *)blob.data()}; + DATA_BLOB out {}; + if (!CryptUnprotectData(&in, nullptr, nullptr, nullptr, nullptr, 0, &out)) { + return false; + } + rt.assign((const char *)out.pbData, out.cbData); + LocalFree(out.pbData); + return !rt.empty(); + } + + // --- interactive sign-in --- + + std::string ReadClipboardText() { + std::string result; + if (!OpenClipboard(nullptr)) { + return result; + } + if (HANDLE h = GetClipboardData(CF_UNICODETEXT)) { + if (const wchar_t *w = (const wchar_t *)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 &) { + // fall through to plain trim + } + } + trim(s); + return s; + } + + bool InteractiveSignIn(Tokens &out, 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()) { + Log("signin: empty authorization code from clipboard"); + return false; + } + if (!AuthCodeGrant(code, out)) { + Log("signin: authorization_code grant failed"); + return false; + } + Log("signin: success"); + return true; + } + + // 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 {}; + } + } // namespace + + bool EnsureAuthenticated(Tokens &out, const std::wstring &productName) { + std::string rt; + if (LoadRefreshToken(rt) && RefreshGrant(rt, out)) { + Log("auth: refreshed stored token"); + SaveRefreshToken(out.refreshToken); // Epic rotates the refresh token + return true; + } + + if (!InteractiveSignIn(out, productName)) { + return false; + } + SaveRefreshToken(out.refreshToken); + return true; + } + + bool GetExchangeCode(const Tokens &tokens, std::string &outCode) { + outCode.clear(); + if (!tokens.Valid()) { + return false; + } + std::string resp; + DWORD status = 0; + if (!HttpsRequest(kExchPath, L"GET", L"Authorization: Bearer " + Widen(tokens.accessToken), {}, resp, status)) { + Log("exchange: transport failure"); + return false; + } + if (status != 200) { + Log("exchange: HTTP " + std::to_string(status)); + return false; + } + try { + const auto j = nlohmann::json::parse(resp); + outCode = j.value("code", std::string {}); + } + catch (const std::exception &) { + return false; + } + return !outCode.empty(); + } + + std::wstring BuildLaunchArgs(const Tokens &tokens, const std::string &exchangeCode, + const std::string &appName, const std::string &sandboxId, + const std::string &installDir) { + std::wstring a = L" -AUTH_LOGIN=unused -AUTH_PASSWORD=" + Widen(exchangeCode) + L" -AUTH_TYPE=exchangecode"; + if (!appName.empty()) { + a += L" -epicapp=" + Widen(appName); + } + a += L" -epicenv=Prod"; + + // Ownership proof — the piece the "use the Epic launcher" gate actually checks. + const std::wstring ovt = FindOwnershipToken(installDir); + if (!sandboxId.empty()) { + a += L" -epicsandboxid=" + Widen(sandboxId); + } + if (!ovt.empty()) { + a += L" -epicovt=\"" + ovt + L"\""; + Log("ovt: found ownership token"); + } + else { + Log("ovt: NONE found under .egstore (launch once via Epic to seed it)"); + } + + if (!tokens.accountId.empty()) { + a += L" -epicuserid=" + Widen(tokens.accountId); + } + if (!tokens.displayName.empty()) { + a += L" -epicusername=\"" + Widen(tokens.displayName) + 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..252d04cf3 --- /dev/null +++ b/code/framework/src/external/epic/auth.h @@ -0,0 +1,50 @@ +/* + * 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 + +// 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(); + } + }; + + // Ensure we hold a usable access token: try the stored refresh token first, and only fall + // back to an interactive browser sign-in when that's missing/expired. productName labels the + // sign-in dialog (e.g. the project name). Returns false if the user cancels or auth fails. + // On success the refresh token is (re)persisted, DPAPI-encrypted, next to the launcher exe. + bool EnsureAuthenticated(Tokens &out, const std::wstring &productName = {}); + + // Mint a fresh single-use exchange code from a valid access token (expires in ~5 min). + bool GetExchangeCode(const Tokens &tokens, std::string &outCode); + + // Build the "-AUTH_TYPE=exchangecode ..." command-line fragment (leading space) the game's + // EOS init consumes. appName/sandboxId come from the game's Epic manifest (AppName / + // CatalogNamespace); installDir is the game root, used to locate the ownership-verification + // token (.ovt) the game checks — without it the Epic build shows the "use the Epic launcher" + // gate. See Framework::External::Epic::FindInstalledApp for the manifest fields. + std::wstring BuildLaunchArgs(const Tokens &tokens, const std::string &exchangeCode, + const std::string &appName, const std::string &sandboxId, + 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/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 From 9281e242166480c62ec607cf225a56f35ae1be93 Mon Sep 17 00:00:00 2001 From: KHeartz Date: Thu, 9 Jul 2026 00:04:29 -0400 Subject: [PATCH 02/11] Networking: add epicId to client identity, expose player.epicId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirror the steamId path: carry an Epic account id in ClientIdentity (hex-validated server-side, since EOS ids aren't numeric), read from the MafiaHubEpicId env var the launcher sets, and surface it as the readonly player.epicId script property. Adds a field to the ClientIdentity RPC — wire-format change, so MAJOR (client + server update together). --- code/framework/src/integrations/client/instance.cpp | 7 +++++-- code/framework/src/integrations/server/instance.cpp | 5 +++++ code/framework/src/integrations/server/instance.h | 1 + code/framework/src/networking/rpc/client_identity.h | 7 ++++++- code/framework/src/scripting/builtins/player.cpp | 6 ++++++ code/framework/src/scripting/builtins/player.h | 1 + code/tests/modules/network_packets_ut.h | 4 ++++ 7 files changed, 28 insertions(+), 3 deletions(-) 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/networking/rpc/client_identity.h b/code/framework/src/networking/rpc/client_identity.h index 86b5d7bb0..29b23db5f 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,8 @@ 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..2be6b1ff2 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", "Authenticated Epic Games account identifier, 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/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"); }); From 2a563ee3f67e2567989be6d1b7428c3f16c83e0d Mon Sep 17 00:00:00 2001 From: KHeartz Date: Wed, 12 Aug 2026 18:00:23 -0400 Subject: [PATCH 03/11] Launcher: add webview Epic sign-in helpers GetLoginUrl + SignInWithAuthorizationCode let an embedded browser scrape the authorizationCode off the redirect page and mint/persist tokens, replacing the manual copy-paste. --- code/framework/src/external/epic/auth.cpp | 28 ++++++++++++++++++++++- code/framework/src/external/epic/auth.h | 5 ++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/code/framework/src/external/epic/auth.cpp b/code/framework/src/external/epic/auth.cpp index 683938297..be5ebf59c 100644 --- a/code/framework/src/external/epic/auth.cpp +++ b/code/framework/src/external/epic/auth.cpp @@ -262,7 +262,13 @@ namespace Framework::External::Epic { } } catch (const std::exception &) { - // fall through to plain trim + // 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); @@ -327,6 +333,26 @@ namespace Framework::External::Epic { return true; } + std::wstring GetLoginUrl() { + return kLoginUrl; + } + + bool SignInWithAuthorizationCode(const std::string &pageTextOrCode) { + const std::string code = ExtractAuthCode(pageTextOrCode); + if (code.empty()) { + Log("webauth: no authorization code in page"); + return false; + } + Tokens out; + if (!AuthCodeGrant(code, out)) { + Log("webauth: authorization_code grant failed"); + return false; + } + SaveRefreshToken(out.refreshToken); + Log("webauth: success"); + return true; + } + bool GetExchangeCode(const Tokens &tokens, std::string &outCode) { outCode.clear(); if (!tokens.Valid()) { diff --git a/code/framework/src/external/epic/auth.h b/code/framework/src/external/epic/auth.h index 252d04cf3..fb6850a63 100644 --- a/code/framework/src/external/epic/auth.h +++ b/code/framework/src/external/epic/auth.h @@ -36,6 +36,11 @@ namespace Framework::External::Epic { // Mint a fresh single-use exchange code from a valid access token (expires in ~5 min). bool GetExchangeCode(const Tokens &tokens, std::string &outCode); + // Embedded-webview sign-in: navigate to GetLoginUrl(), then hand the resulting redirect-page + // text (or a bare authorizationCode) to SignInWithAuthorizationCode to mint + persist tokens. + std::wstring GetLoginUrl(); + bool SignInWithAuthorizationCode(const std::string &pageTextOrCode); + // Build the "-AUTH_TYPE=exchangecode ..." command-line fragment (leading space) the game's // EOS init consumes. appName/sandboxId come from the game's Epic manifest (AppName / // CatalogNamespace); installDir is the game root, used to locate the ownership-verification From a834efa3824ba52d9458e807b6911c388e429fbe Mon Sep 17 00:00:00 2001 From: KHeartz Date: Sun, 13 Sep 2026 15:04:04 -0400 Subject: [PATCH 04/11] Launcher: redact credentials from the logged command line The mapped command line carries a live store exchange code (-AUTH_PASSWORD), the account id (-epicuserid) and the ownership-token path (which embeds it). That line goes to the dev console and log files, so redact those values when logging; what the game receives is unchanged. --- code/framework/src/launcher/project.cpp | 28 ++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/code/framework/src/launcher/project.cpp b/code/framework/src/launcher/project.cpp index 99e4af4a5..69c62f792 100644 --- a/code/framework/src/launcher/project.cpp +++ b/code/framework/src/launcher/project.cpp @@ -119,6 +119,32 @@ 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. +std::string RedactGameCommandLine(std::string commandLine) { + for (const char *key : {"-AUTH_PASSWORD=", "-epicuserid=", "-epicovt="}) { + const std::string marker(key); + for (size_t pos = commandLine.find(marker); pos != std::string::npos; pos = commandLine.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); + pos = valueStart + placeholder.size(); + } + } + return commandLine; +} + bool SynchronizeUCRTCommandLine() { const auto ucrt = GetModuleHandleW(L"ucrtbase.dll"); if (!ucrt) { @@ -896,7 +922,7 @@ namespace Framework::Launcher { if (SynchronizeUCRTCommandLine()) { Logging::GetLogger(FRAMEWORK_INNER_LAUNCHER)->info( - "Mapped game command line: {}", BuildGameCommandLineA()); + "Mapped game command line: {}", RedactGameCommandLine(BuildGameCommandLineA())); } // The OS loader normally dispatches executable TLS callbacks before the entry From c5f49d4da3da14cf8eb645c10cd33ff66baed234 Mon Sep 17 00:00:00 2001 From: KHeartz Date: Wed, 30 Sep 2026 00:45:39 -0400 Subject: [PATCH 05/11] Launcher: add a refresh-only Epic auth entry point TryRefreshStoredAuth refreshes the stored token without any UI, so a launcher can run its own sign-in when it fails. --- code/framework/src/external/epic/auth.cpp | 15 +++++++++++---- code/framework/src/external/epic/auth.h | 3 +++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/code/framework/src/external/epic/auth.cpp b/code/framework/src/external/epic/auth.cpp index be5ebf59c..2353839b2 100644 --- a/code/framework/src/external/epic/auth.cpp +++ b/code/framework/src/external/epic/auth.cpp @@ -318,11 +318,18 @@ namespace Framework::External::Epic { } } // namespace - bool EnsureAuthenticated(Tokens &out, const std::wstring &productName) { + bool TryRefreshStoredAuth(Tokens &out) { std::string rt; - if (LoadRefreshToken(rt) && RefreshGrant(rt, out)) { - Log("auth: refreshed stored token"); - SaveRefreshToken(out.refreshToken); // Epic rotates the refresh token + if (!LoadRefreshToken(rt) || !RefreshGrant(rt, out)) { + return false; + } + Log("auth: refreshed stored token"); + SaveRefreshToken(out.refreshToken); // Epic rotates the refresh token + return true; + } + + bool EnsureAuthenticated(Tokens &out, const std::wstring &productName) { + if (TryRefreshStoredAuth(out)) { return true; } diff --git a/code/framework/src/external/epic/auth.h b/code/framework/src/external/epic/auth.h index fb6850a63..47bf82a5e 100644 --- a/code/framework/src/external/epic/auth.h +++ b/code/framework/src/external/epic/auth.h @@ -33,6 +33,9 @@ namespace Framework::External::Epic { // On success the refresh token is (re)persisted, DPAPI-encrypted, next to the launcher exe. bool EnsureAuthenticated(Tokens &out, const std::wstring &productName = {}); + // The silent half of EnsureAuthenticated: refresh the stored token, never show any UI. + bool TryRefreshStoredAuth(Tokens &out); + // Mint a fresh single-use exchange code from a valid access token (expires in ~5 min). bool GetExchangeCode(const Tokens &tokens, std::string &outCode); From 71a43ca32e3bb891e25acf5f6f597b866e3ad38f Mon Sep 17 00:00:00 2001 From: KHeartz Date: Wed, 30 Sep 2026 00:58:49 -0400 Subject: [PATCH 06/11] Launcher: mint a fresh Epic ownership token per launch The .egstore copy is only as fresh as the last launch from Epic and a stale one is refused (88500005). Falls back to it when minting fails. --- code/framework/src/external/epic/auth.cpp | 85 ++++++++++++++++++----- code/framework/src/external/epic/auth.h | 10 ++- 2 files changed, 73 insertions(+), 22 deletions(-) diff --git a/code/framework/src/external/epic/auth.cpp b/code/framework/src/external/epic/auth.cpp index 2353839b2..719d0e33b 100644 --- a/code/framework/src/external/epic/auth.cpp +++ b/code/framework/src/external/epic/auth.cpp @@ -30,6 +30,7 @@ namespace Framework::External::Epic { 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 = @@ -43,19 +44,23 @@ namespace Framework::External::Epic { return std::filesystem::path(buf).parent_path(); } - std::filesystem::path AuthFile() { - // Per-user secret -> %LOCALAPPDATA%\MafiaHub: survives launcher reinstalls, works under a - // read-only install, shared across Framework Epic games. Fall back to the exe dir. + // 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 / L"epic_auth.bin"; + return dir; } } - return ExeDir() / L"epic_auth.bin"; + return ExeDir(); + } + + std::filesystem::path AuthFile() { + return DataDir() / L"epic_auth.bin"; } std::filesystem::path LogFile() { @@ -84,9 +89,9 @@ namespace Framework::External::Epic { return w; } - // One HTTPS request to the Epic account service. Headers are CRLF-joined. Returns false on - // transport failure; an HTTP error still returns true with outStatus set for the caller. - bool HttpsRequest(const wchar_t *path, const wchar_t *method, const std::wstring &headers, + // One HTTPS request to an Epic service. Headers are CRLF-joined. Returns false on transport + // failure; an HTTP error still returns true with outStatus set for the caller. + bool HttpsRequest(const wchar_t *host, const wchar_t *path, const wchar_t *method, const std::wstring &headers, const std::string &body, std::string &outResp, DWORD &outStatus) { outResp.clear(); outStatus = 0; @@ -99,7 +104,7 @@ namespace Framework::External::Epic { WinHttpSetTimeouts(hSession, 5000, 5000, 15000, 30000); bool ok = false; - HINTERNET hConnect = WinHttpConnect(hSession, kAuthHost, INTERNET_DEFAULT_HTTPS_PORT, 0); + HINTERNET hConnect = WinHttpConnect(hSession, host, INTERNET_DEFAULT_HTTPS_PORT, 0); if (hConnect) { HINTERNET hReq = WinHttpOpenRequest(hConnect, method, path, nullptr, WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, WINHTTP_FLAG_SECURE); @@ -168,7 +173,7 @@ namespace Framework::External::Epic { bool TokenGrant(const std::string &formBody, Tokens &out) { std::string resp; DWORD status = 0; - if (!HttpsRequest(kTokenPath, L"POST", BasicAuthHeader(), formBody, resp, status)) { + if (!HttpsRequest(kAuthHost, kTokenPath, L"POST", BasicAuthHeader(), formBody, resp, status)) { Log("token: transport failure"); return false; } @@ -316,6 +321,49 @@ namespace Framework::External::Epic { } 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() || tokens.accountId.empty() || catalogNamespace.empty() || catalogItemId.empty()) { + return {}; + } + const std::wstring path = L"/ecommerceintegration/api/public/platforms/EPIC/identities/" + + Widen(tokens.accountId) + L"/ownershipToken"; + const std::wstring headers = L"Authorization: Bearer " + Widen(tokens.accessToken) + + L"\r\nContent-Type: application/x-www-form-urlencoded"; + const std::string body = "nsCatalogItemId=" + catalogNamespace + "%3A" + catalogItemId; + + std::string resp; + DWORD status = 0; + if (!HttpsRequest(kEcomHost, path.c_str(), L"POST", headers, body, resp, status)) { + Log("ovt: mint transport failure"); + return {}; + } + if (status != 200) { + Log("ovt: mint HTTP " + std::to_string(status)); + return {}; + } + try { + if (!nlohmann::json::parse(resp).contains("token")) { + Log("ovt: mint response has no token"); + return {}; + } + } + catch (const std::exception &) { + Log("ovt: mint response is not JSON"); + return {}; + } + + const std::filesystem::path file = DataDir() / Widen(catalogNamespace + catalogItemId + ".ovt"); + std::ofstream f(file, std::ios::binary | std::ios::trunc); + if (!f.write(resp.data(), static_cast(resp.size()))) { + Log("ovt: could not write minted token"); + return {}; + } + return file.wstring(); + } } // namespace bool TryRefreshStoredAuth(Tokens &out) { @@ -367,7 +415,7 @@ namespace Framework::External::Epic { } std::string resp; DWORD status = 0; - if (!HttpsRequest(kExchPath, L"GET", L"Authorization: Bearer " + Widen(tokens.accessToken), {}, resp, status)) { + if (!HttpsRequest(kAuthHost, kExchPath, L"GET", L"Authorization: Bearer " + Widen(tokens.accessToken), {}, resp, status)) { Log("exchange: transport failure"); return false; } @@ -387,7 +435,7 @@ namespace Framework::External::Epic { std::wstring BuildLaunchArgs(const Tokens &tokens, const std::string &exchangeCode, const std::string &appName, const std::string &sandboxId, - const std::string &installDir) { + const std::string &catalogItemId, const std::string &installDir) { std::wstring a = L" -AUTH_LOGIN=unused -AUTH_PASSWORD=" + Widen(exchangeCode) + L" -AUTH_TYPE=exchangecode"; if (!appName.empty()) { a += L" -epicapp=" + Widen(appName); @@ -395,16 +443,21 @@ namespace Framework::External::Epic { a += L" -epicenv=Prod"; // Ownership proof — the piece the "use the Epic launcher" gate actually checks. - const std::wstring ovt = FindOwnershipToken(installDir); if (!sandboxId.empty()) { a += L" -epicsandboxid=" + Widen(sandboxId); } + std::wstring ovt = MintOwnershipToken(tokens, sandboxId, catalogItemId); if (!ovt.empty()) { - a += L" -epicovt=\"" + ovt + L"\""; - Log("ovt: found ownership token"); + Log("ovt: minted fresh ownership token"); + } + else if (ovt = FindOwnershipToken(installDir); !ovt.empty()) { + Log("ovt: falling back to the .egstore token"); } else { - Log("ovt: NONE found under .egstore (launch once via Epic to seed it)"); + Log("ovt: NONE minted or found under .egstore"); + } + if (!ovt.empty()) { + a += L" -epicovt=\"" + ovt + L"\""; } if (!tokens.accountId.empty()) { diff --git a/code/framework/src/external/epic/auth.h b/code/framework/src/external/epic/auth.h index 47bf82a5e..3f9b7e72c 100644 --- a/code/framework/src/external/epic/auth.h +++ b/code/framework/src/external/epic/auth.h @@ -44,14 +44,12 @@ namespace Framework::External::Epic { std::wstring GetLoginUrl(); bool SignInWithAuthorizationCode(const std::string &pageTextOrCode); - // Build the "-AUTH_TYPE=exchangecode ..." command-line fragment (leading space) the game's - // EOS init consumes. appName/sandboxId come from the game's Epic manifest (AppName / - // CatalogNamespace); installDir is the game root, used to locate the ownership-verification - // token (.ovt) the game checks — without it the Epic build shows the "use the Epic launcher" - // gate. See Framework::External::Epic::FindInstalledApp for the manifest fields. + // 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 &installDir); + 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(); From 54cf404e05cffe3ba34bc7589748ccecb1e0314a Mon Sep 17 00:00:00 2001 From: KHeartz Date: Fri, 2 Oct 2026 09:55:08 -0400 Subject: [PATCH 07/11] Launcher: harden Epic auth per review Form-encode grant values, keep the minted ownership token as returned, validate the ids that name its file, strip quotes from the display name, and treat a response as complete only at EOF. Persistence failures now surface. Logging goes through the launcher logger over one shared WinHTTP session, and the public API returns std::optional instead of filling out-params. --- code/framework/src/external/epic/auth.cpp | 436 ++++++++++++---------- code/framework/src/external/epic/auth.h | 19 +- 2 files changed, 244 insertions(+), 211 deletions(-) diff --git a/code/framework/src/external/epic/auth.cpp b/code/framework/src/external/epic/auth.cpp index 719d0e33b..53d27dc37 100644 --- a/code/framework/src/external/epic/auth.cpp +++ b/code/framework/src/external/epic/auth.cpp @@ -8,17 +8,23 @@ #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 +#include namespace Framework::External::Epic { namespace { @@ -33,10 +39,14 @@ namespace Framework::External::Epic { 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"; + 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] = {}; @@ -63,133 +73,163 @@ namespace Framework::External::Epic { return DataDir() / L"epic_auth.bin"; } - std::filesystem::path LogFile() { - return ExeDir() / L"epic_auth.log"; // diagnostics stay with the other launcher logs + bool IsAsciiAlnum(unsigned char c) { + return (c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'); } - // Diagnostics only — never logs tokens or the exchange code. - void Log(const std::string &msg) { - std::ofstream f(LogFile(), std::ios::app); - if (f) { - SYSTEMTIME t; - GetLocalTime(&t); - char ts[32]; - std::snprintf(ts, sizeof(ts), "[%02d:%02d:%02d] ", t.wHour, t.wMinute, t.wSecond); - f << ts << msg << "\n"; + // 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; } - std::wstring Widen(const std::string &s) { - if (s.empty()) { - return {}; + // 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(); } - const int need = MultiByteToWideChar(CP_UTF8, 0, s.data(), (int)s.size(), nullptr, 0); - std::wstring w(need, L'\0'); - MultiByteToWideChar(CP_UTF8, 0, s.data(), (int)s.size(), w.data(), need); - return w; + return value; } - // One HTTPS request to an Epic service. Headers are CRLF-joined. Returns false on transport - // failure; an HTTP error still returns true with outStatus set for the caller. - bool HttpsRequest(const wchar_t *host, const wchar_t *path, const wchar_t *method, const std::wstring &headers, - const std::string &body, std::string &outResp, DWORD &outStatus) { - outResp.clear(); - outStatus = 0; + // 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; + } - HINTERNET hSession = WinHttpOpen(L"MafiaHubLauncher/1.0", WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, - WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0); - if (!hSession) { - return false; + struct ScopedHandle { + HINTERNET handle; + ~ScopedHandle() { + if (handle) { + WinHttpCloseHandle(handle); + } } - WinHttpSetTimeouts(hSession, 5000, 5000, 15000, 30000); - - bool ok = false; - HINTERNET hConnect = WinHttpConnect(hSession, host, INTERNET_DEFAULT_HTTPS_PORT, 0); - if (hConnect) { - HINTERNET hReq = WinHttpOpenRequest(hConnect, method, path, nullptr, WINHTTP_NO_REFERER, - WINHTTP_DEFAULT_ACCEPT_TYPES, WINHTTP_FLAG_SECURE); - if (hReq) { - if (!headers.empty()) { - WinHttpAddRequestHeaders(hReq, headers.c_str(), (DWORD)-1, - WINHTTP_ADDREQ_FLAG_ADD | WINHTTP_ADDREQ_FLAG_REPLACE); - } - const BOOL sent = WinHttpSendRequest( - hReq, WINHTTP_NO_ADDITIONAL_HEADERS, 0, - body.empty() ? WINHTTP_NO_REQUEST_DATA : (LPVOID)body.data(), - (DWORD)body.size(), (DWORD)body.size(), 0); - if (sent && WinHttpReceiveResponse(hReq, nullptr)) { - DWORD status = 0, len = sizeof(status); - WinHttpQueryHeaders(hReq, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, - WINHTTP_HEADER_NAME_BY_INDEX, &status, &len, WINHTTP_NO_HEADER_INDEX); - outStatus = status; - - for (;;) { - DWORD avail = 0; - if (!WinHttpQueryDataAvailable(hReq, &avail) || avail == 0) { - break; - } - std::string chunk(avail, '\0'); - DWORD read = 0; - if (!WinHttpReadData(hReq, chunk.data(), avail, &read)) { - break; - } - chunk.resize(read); - outResp += chunk; - } - ok = true; - } - WinHttpCloseHandle(hReq); + }; + + 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; } - WinHttpCloseHandle(hConnect); + 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; } - WinHttpCloseHandle(hSession); - return ok; + } + + // The body of a 200 response; anything else is logged under `what` and dropped. + std::optional 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 std::nullopt; + } + if (response->status != 200) { + Logger()->warn("Epic {} request returned HTTP {}", what, response->status); + return std::nullopt; + } + return std::move(response->body); } std::wstring BasicAuthHeader() { const std::string cred = std::string(kClientId) + ":" + kClientSecret; - return L"Authorization: Basic " + Widen(Utils::StringUtils::Base64Encode(cred)) + - L"\r\nContent-Type: application/x-www-form-urlencoded"; + return L"Authorization: Basic " + Utils::StringUtils::Utf8ToWide(Utils::StringUtils::Base64Encode(cred)) + L"\r\nContent-Type: application/x-www-form-urlencoded"; } - // Fill Tokens from an oauth/token response body. Returns false if it isn't a token grant. - bool ParseTokens(const std::string &body, Tokens &out) { + // 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); - if (!j.contains("access_token")) { - return false; - } - out.accessToken = j.value("access_token", std::string {}); - out.refreshToken = j.value("refresh_token", std::string {}); - out.accountId = j.value("account_id", std::string {}); - out.displayName = j.value("displayName", std::string {}); - return out.Valid(); + 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 false; + return std::nullopt; } } - bool TokenGrant(const std::string &formBody, Tokens &out) { - std::string resp; - DWORD status = 0; - if (!HttpsRequest(kAuthHost, kTokenPath, L"POST", BasicAuthHeader(), formBody, resp, status)) { - Log("token: transport failure"); - return false; - } - if (status != 200) { - Log("token: HTTP " + std::to_string(status)); - return false; - } - return ParseTokens(resp, out); + std::optional TokenGrant(const std::string &formBody) { + const auto body = HttpsOk("token", kAuthHost, kTokenPath, L"POST", BasicAuthHeader(), formBody); + return body ? ParseTokens(*body) : std::nullopt; } - bool RefreshGrant(const std::string &refreshToken, Tokens &out) { - return TokenGrant("grant_type=refresh_token&token_type=eg1&refresh_token=" + refreshToken, out); + std::optional RefreshGrant(const std::string &refreshToken) { + return TokenGrant("grant_type=refresh_token&token_type=eg1&refresh_token=" + FormEncode(refreshToken)); } - bool AuthCodeGrant(const std::string &authCode, Tokens &out) { - return TokenGrant("grant_type=authorization_code&token_type=eg1&code=" + authCode, out); + std::optional 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) --- @@ -198,34 +238,42 @@ namespace Framework::External::Epic { if (rt.empty()) { return false; } - DATA_BLOB in {(DWORD)rt.size(), (BYTE *)rt.data()}; + 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((const char *)out.pbData, out.cbData).good(); + const bool ok = f && f.write(reinterpret_cast(out.pbData), out.cbData).good(); LocalFree(out.pbData); return ok; } - bool LoadRefreshToken(std::string &rt) { + // 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 false; + return std::nullopt; } const std::string blob((std::istreambuf_iterator(f)), std::istreambuf_iterator()); if (blob.empty()) { - return false; + return std::nullopt; } - DATA_BLOB in {(DWORD)blob.size(), (BYTE *)blob.data()}; + 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 false; + return std::nullopt; } - rt.assign((const char *)out.pbData, out.cbData); + std::string rt(reinterpret_cast(out.pbData), out.cbData); LocalFree(out.pbData); - return !rt.empty(); + return rt.empty() ? std::nullopt : std::optional(std::move(rt)); } // --- interactive sign-in --- @@ -236,7 +284,7 @@ namespace Framework::External::Epic { return result; } if (HANDLE h = GetClipboardData(CF_UNICODETEXT)) { - if (const wchar_t *w = (const wchar_t *)GlobalLock(h)) { + 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); @@ -269,7 +317,7 @@ namespace Framework::External::Epic { 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 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) { @@ -280,27 +328,28 @@ namespace Framework::External::Epic { return s; } - bool InteractiveSignIn(Tokens &out, const std::wstring &productName) { + 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); + 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()) { - Log("signin: empty authorization code from clipboard"); - return false; + Logger()->warn("Epic sign-in: the clipboard held no authorization code"); + return std::nullopt; } - if (!AuthCodeGrant(code, out)) { - Log("signin: authorization_code grant failed"); - return false; + auto tokens = AuthCodeGrant(code); + if (!tokens) { + Logger()->warn("Epic sign-in: the authorization code grant failed"); + return std::nullopt; } - Log("signin: success"); - return true; + Logger()->info("Epic sign-in succeeded"); + return tokens; } // Ownership-verification token the Epic launcher drops under \.egstore. We take @@ -324,68 +373,58 @@ namespace Framework::External::Epic { // 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() || tokens.accountId.empty() || catalogNamespace.empty() || catalogItemId.empty()) { + std::wstring MintOwnershipToken(const Tokens &tokens, const std::string &catalogNamespace, const std::string &catalogItemId) { + if (!tokens.Valid()) { return {}; } - const std::wstring path = L"/ecommerceintegration/api/public/platforms/EPIC/identities/" + - Widen(tokens.accountId) + L"/ownershipToken"; - const std::wstring headers = L"Authorization: Bearer " + Widen(tokens.accessToken) + - L"\r\nContent-Type: application/x-www-form-urlencoded"; - const std::string body = "nsCatalogItemId=" + catalogNamespace + "%3A" + catalogItemId; - - std::string resp; - DWORD status = 0; - if (!HttpsRequest(kEcomHost, path.c_str(), L"POST", headers, body, resp, status)) { - Log("ovt: mint transport failure"); + if (!IsEpicId(tokens.accountId) || !IsEpicId(catalogNamespace) || !IsEpicId(catalogItemId)) { + Logger()->warn("Epic account or catalog ids are not alphanumeric; not minting an ownership token"); return {}; } - if (status != 200) { - Log("ovt: mint HTTP " + std::to_string(status)); + 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 {}; } - try { - if (!nlohmann::json::parse(resp).contains("token")) { - Log("ovt: mint response has no token"); - return {}; - } - } - catch (const std::exception &) { - Log("ovt: mint response is not JSON"); + if (token->empty()) { + Logger()->warn("Epic ownership token response was empty"); return {}; } - const std::filesystem::path file = DataDir() / Widen(catalogNamespace + catalogItemId + ".ovt"); + 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(resp.data(), static_cast(resp.size()))) { - Log("ovt: could not write minted token"); + if (!f.write(token->data(), static_cast(token->size()))) { + Logger()->warn("Could not write the minted Epic ownership token"); return {}; } return file.wstring(); } } // namespace - bool TryRefreshStoredAuth(Tokens &out) { - std::string rt; - if (!LoadRefreshToken(rt) || !RefreshGrant(rt, out)) { - return false; + std::optional TryRefreshStoredAuth() { + const auto rt = LoadRefreshToken(); + auto tokens = rt ? RefreshGrant(*rt) : std::nullopt; + if (!tokens) { + return std::nullopt; } - Log("auth: refreshed stored token"); - SaveRefreshToken(out.refreshToken); // Epic rotates the refresh token - return true; + Logger()->info("Refreshed the stored Epic sign-in"); + PersistRotatedRefreshToken(tokens->refreshToken); + return tokens; } - bool EnsureAuthenticated(Tokens &out, const std::wstring &productName) { - if (TryRefreshStoredAuth(out)) { - return true; + std::optional EnsureAuthenticated(const std::wstring &productName) { + if (auto tokens = TryRefreshStoredAuth()) { + return tokens; } - - if (!InteractiveSignIn(out, productName)) { - return false; + auto tokens = InteractiveSignIn(productName); + if (tokens) { + PersistRotatedRefreshToken(tokens->refreshToken); } - SaveRefreshToken(out.refreshToken); - return true; + return tokens; } std::wstring GetLoginUrl() { @@ -395,76 +434,71 @@ namespace Framework::External::Epic { bool SignInWithAuthorizationCode(const std::string &pageTextOrCode) { const std::string code = ExtractAuthCode(pageTextOrCode); if (code.empty()) { - Log("webauth: no authorization code in page"); + 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; } - Tokens out; - if (!AuthCodeGrant(code, out)) { - Log("webauth: authorization_code grant failed"); + // The tokens are dropped here, so the stored refresh token is the only result there is. + if (!SaveRefreshToken(tokens->refreshToken)) { + Logger()->error("Epic sign-in succeeded, but the refresh token could not be persisted"); return false; } - SaveRefreshToken(out.refreshToken); - Log("webauth: success"); + Logger()->info("Epic sign-in succeeded"); return true; } - bool GetExchangeCode(const Tokens &tokens, std::string &outCode) { - outCode.clear(); + std::optional GetExchangeCode(const Tokens &tokens) { if (!tokens.Valid()) { - return false; + return std::nullopt; } - std::string resp; - DWORD status = 0; - if (!HttpsRequest(kAuthHost, kExchPath, L"GET", L"Authorization: Bearer " + Widen(tokens.accessToken), {}, resp, status)) { - Log("exchange: transport failure"); - return false; - } - if (status != 200) { - Log("exchange: HTTP " + std::to_string(status)); - return false; + const auto body = HttpsOk("exchange code", kAuthHost, kExchPath, L"GET", L"Authorization: Bearer " + Utils::StringUtils::Utf8ToWide(tokens.accessToken)); + if (!body) { + return std::nullopt; } try { - const auto j = nlohmann::json::parse(resp); - outCode = j.value("code", std::string {}); + std::string code = nlohmann::json::parse(*body).value("code", std::string {}); + return code.empty() ? std::nullopt : std::optional(std::move(code)); } catch (const std::exception &) { - return false; + return std::nullopt; } - return !outCode.empty(); } - 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) { - std::wstring a = L" -AUTH_LOGIN=unused -AUTH_PASSWORD=" + Widen(exchangeCode) + L" -AUTH_TYPE=exchangecode"; + 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=" + Widen(appName); + 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=" + Widen(sandboxId); + a += L" -epicsandboxid=" + Utf8ToWide(sandboxId); } std::wstring ovt = MintOwnershipToken(tokens, sandboxId, catalogItemId); if (!ovt.empty()) { - Log("ovt: minted fresh ownership token"); + Logger()->info("Minted a fresh Epic ownership token"); } else if (ovt = FindOwnershipToken(installDir); !ovt.empty()) { - Log("ovt: falling back to the .egstore token"); + Logger()->info("Falling back to the Epic launcher's .egstore ownership token"); } else { - Log("ovt: NONE minted or found under .egstore"); + 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=" + Widen(tokens.accountId); + a += L" -epicuserid=" + Utf8ToWide(tokens.accountId); } - if (!tokens.displayName.empty()) { - a += L" -epicusername=\"" + Widen(tokens.displayName) + L"\""; + if (const std::string name = QuotableArgument(tokens.displayName); !name.empty()) { + a += L" -epicusername=\"" + Utf8ToWide(name) + L"\""; } a += L" -epiclocale=en -EpicPortal"; return a; diff --git a/code/framework/src/external/epic/auth.h b/code/framework/src/external/epic/auth.h index 3f9b7e72c..0933a73ea 100644 --- a/code/framework/src/external/epic/auth.h +++ b/code/framework/src/external/epic/auth.h @@ -8,6 +8,7 @@ #pragma once +#include #include // Epic Games account auth for launching an EOS (Epic Store) game outside the Epic launcher. @@ -27,29 +28,27 @@ namespace Framework::External::Epic { } }; - // Ensure we hold a usable access token: try the stored refresh token first, and only fall - // back to an interactive browser sign-in when that's missing/expired. productName labels the - // sign-in dialog (e.g. the project name). Returns false if the user cancels or auth fails. - // On success the refresh token is (re)persisted, DPAPI-encrypted, next to the launcher exe. - bool EnsureAuthenticated(Tokens &out, const std::wstring &productName = {}); + // A usable access token: the stored refresh token first, else a browser + clipboard sign-in + // labelled with productName. nullopt if the user cancels or auth fails. The refresh token is + // persisted, DPAPI-encrypted, under %LOCALAPPDATA%\MafiaHub. + std::optional EnsureAuthenticated(const std::wstring &productName = {}); // The silent half of EnsureAuthenticated: refresh the stored token, never show any UI. - bool TryRefreshStoredAuth(Tokens &out); + std::optional TryRefreshStoredAuth(); // Mint a fresh single-use exchange code from a valid access token (expires in ~5 min). - bool GetExchangeCode(const Tokens &tokens, std::string &outCode); + 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); + 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(); From 764c567adbd263eec1657ba4277072b828a969b1 Mon Sep 17 00:00:00 2001 From: KHeartz Date: Fri, 9 Oct 2026 09:37:14 -0400 Subject: [PATCH 08/11] Launcher: sign in to Epic on the Epic launch path Platforms::Epic::PrepareLaunch signs in, mints the exchange code and ownership token, and sets MafiaHubEpicId; the arguments reach the game through the new Platform::GetLaunchArguments. Epic::Options::signIn lets a project supply its own sign-in UI. Credential markers are redacted in any case. --- .../src/launcher/platforms/platform.h | 5 +++ .../src/launcher/platforms/platforms.cpp | 38 ++++++++++++++++++- .../src/launcher/platforms/platforms.h | 17 ++++++++- code/framework/src/launcher/project.cpp | 11 ++++-- 4 files changed, 64 insertions(+), 7 deletions(-) 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..5a80d819d 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" @@ -184,12 +185,45 @@ 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. + const auto signIn = [&]() -> std::optional { + if (!_options.signIn) { + return External::Epic::EnsureAuthenticated(Utils::StringUtils::Utf8ToWide(host.GetConfig().name)); + } + if (auto tokens = External::Epic::TryRefreshStoredAuth()) { + return tokens; + } + return _options.signIn() ? External::Epic::TryRefreshStoredAuth() : std::nullopt; + }; + const auto tokens = signIn(); + if (!tokens) { + host.ReportError("Epic sign-in is required to play the Epic version of the game"); + return false; + } + + const auto exchangeCode = External::Epic::GetExchangeCode(*tokens); + if (!exchangeCode) { + External::Epic::ClearStoredAuth(); // the next launch signs in afresh + 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..512ada8b3 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,18 @@ 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, a browser + clipboard sign-in + SignInProc signIn; }; explicit Epic(Options options = {}): _options(std::move(options)) {} @@ -62,9 +70,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 69c62f792..e442874db 100644 --- a/code/framework/src/launcher/project.cpp +++ b/code/framework/src/launcher/project.cpp @@ -121,10 +121,12 @@ LPSTR BuildGameCommandLineA() { // 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="}) { + for (const char *key : {"-auth_password=", "-epicuserid=", "-epicovt="}) { const std::string marker(key); - for (size_t pos = commandLine.find(marker); pos != std::string::npos; pos = commandLine.find(marker, pos)) { + 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] == '"') { @@ -139,6 +141,7 @@ std::string RedactGameCommandLine(std::string commandLine) { } const std::string placeholder = ""; commandLine.replace(valueStart, valueEnd - valueStart, placeholder); + lowered.replace(valueStart, valueEnd - valueStart, placeholder); pos = valueStart + placeholder.size(); } } @@ -406,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"); @@ -921,8 +925,7 @@ namespace Framework::Launcher { Loaders::ApplyMappedImageIdentity(_gamePath); if (SynchronizeUCRTCommandLine()) { - Logging::GetLogger(FRAMEWORK_INNER_LAUNCHER)->info( - "Mapped game command line: {}", RedactGameCommandLine(BuildGameCommandLineA())); + Logging::GetLogger(FRAMEWORK_INNER_LAUNCHER)->info("Mapped game command line: {}", RedactGameCommandLine(BuildGameCommandLineA())); } // The OS loader normally dispatches executable TLS callbacks before the entry From abd834640fad395932ccbf10ecb326fcd3d67c9e Mon Sep 17 00:00:00 2001 From: KHeartz Date: Fri, 2 Oct 2026 09:55:09 -0400 Subject: [PATCH 09/11] Scripting: expose epicId to playerConnecting Documented as client-reported and unverified, like the other ids. --- .../server/scripting/builtins/pending_connection.cpp | 2 ++ code/framework/src/networking/rpc/client_identity.h | 3 +-- code/framework/src/scripting/builtins/player.cpp | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) 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/networking/rpc/client_identity.h b/code/framework/src/networking/rpc/client_identity.h index 29b23db5f..72eeb2f63 100644 --- a/code/framework/src/networking/rpc/client_identity.h +++ b/code/framework/src/networking/rpc/client_identity.h @@ -66,8 +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.epicId) - || !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 2be6b1ff2..6702b36cc 100644 --- a/code/framework/src/scripting/builtins/player.cpp +++ b/code/framework/src/scripting/builtins/player.cpp @@ -197,7 +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", "Authenticated Epic Games account 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.")); From 3147852be3c2879b12bf290b9f5e3163b994c4a4 Mon Sep 17 00:00:00 2001 From: KHeartz Date: Fri, 9 Oct 2026 09:38:03 -0400 Subject: [PATCH 10/11] Launcher: ship the Epic sign-in window as epic_sign_in.exe A CEF window that reads the authorization code off a hidden redirect page, so the code is never shown. Platforms::Epic runs it from the launcher's folder when no stored sign-in refreshes and falls back to the browser + clipboard flow without it; Epic::Options::signIn still overrides. --- code/framework/CMakeLists.txt | 20 +++ .../src/external/epic/sign_in_main.cpp | 95 +++++++++++ .../src/external/epic/sign_in_view.cpp | 161 ++++++++++++++++++ .../src/external/epic/sign_in_view.h | 18 ++ .../src/launcher/platforms/platforms.cpp | 45 ++++- .../src/launcher/platforms/platforms.h | 3 +- 6 files changed, 336 insertions(+), 6 deletions(-) create mode 100644 code/framework/src/external/epic/sign_in_main.cpp create mode 100644 code/framework/src/external/epic/sign_in_view.cpp create mode 100644 code/framework/src/external/epic/sign_in_view.h diff --git a/code/framework/CMakeLists.txt b/code/framework/CMakeLists.txt index 07cfa14eb..80b42bbab 100644 --- a/code/framework/CMakeLists.txt +++ b/code/framework/CMakeLists.txt @@ -476,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/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/launcher/platforms/platforms.cpp b/code/framework/src/launcher/platforms/platforms.cpp index 5a80d819d..9d2a411b1 100644 --- a/code/framework/src/launcher/platforms/platforms.cpp +++ b/code/framework/src/launcher/platforms/platforms.cpp @@ -39,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) { @@ -193,15 +222,21 @@ namespace Framework::Launcher::Platforms { } bool Epic::PrepareLaunch(const PlatformHost &host) { - // A sign-in UI persists a refresh token that we then use like a stored one. + // 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 = [&]() -> std::optional { - if (!_options.signIn) { - return External::Epic::EnsureAuthenticated(Utils::StringUtils::Utf8ToWide(host.GetConfig().name)); - } if (auto tokens = External::Epic::TryRefreshStoredAuth()) { return tokens; } - return _options.signIn() ? External::Epic::TryRefreshStoredAuth() : std::nullopt; + if (_options.signIn) { + return _options.signIn() ? External::Epic::TryRefreshStoredAuth() : std::nullopt; + } + switch (RunEpicSignInWindow(host.GetProjectPath())) { + case EpicSignInWindow::SignedIn: return External::Epic::TryRefreshStoredAuth(); + case EpicSignInWindow::Failed: return std::nullopt; + case EpicSignInWindow::Missing: break; + } + return External::Epic::EnsureAuthenticated(Utils::StringUtils::Utf8ToWide(host.GetConfig().name)); }; const auto tokens = signIn(); if (!tokens) { diff --git a/code/framework/src/launcher/platforms/platforms.h b/code/framework/src/launcher/platforms/platforms.h index 512ada8b3..5927d531a 100644 --- a/code/framework/src/launcher/platforms/platforms.h +++ b/code/framework/src/launcher/platforms/platforms.h @@ -59,7 +59,8 @@ namespace Framework::Launcher::Platforms { std::wstring appName; // signs the player in when no stored Epic sign-in refreshes, persisting it through - // External::Epic::SignInWithAuthorizationCode; unset, a browser + clipboard sign-in + // External::Epic::SignInWithAuthorizationCode; unset, the Framework's epic_sign_in.exe + // window beside the launcher, else a browser + clipboard sign-in SignInProc signIn; }; From 1c346a885c5e7cfbf25e66f3bd7405817d0adc24 Mon Sep 17 00:00:00 2001 From: KHeartz Date: Fri, 9 Oct 2026 09:38:29 -0400 Subject: [PATCH 11/11] Launcher: keep the Epic sign-in through transient refresh failures TryRefreshStoredAuth and EnsureAuthenticated return Utils::Result with an AuthError. Only a missing or rejected (HTTP 400/401) sign-in asks the player to sign in again or clears the stored one; when Epic can't be reached the launch stops and the stored sign-in is kept. --- code/framework/src/external/epic/auth.cpp | 65 ++++++++++++------- code/framework/src/external/epic/auth.h | 29 +++++++-- .../src/launcher/platforms/platforms.cpp | 33 ++++++---- 3 files changed, 84 insertions(+), 43 deletions(-) diff --git a/code/framework/src/external/epic/auth.cpp b/code/framework/src/external/epic/auth.cpp index 53d27dc37..fb8085f16 100644 --- a/code/framework/src/external/epic/auth.cpp +++ b/code/framework/src/external/epic/auth.cpp @@ -184,18 +184,22 @@ namespace Framework::External::Epic { } } - // The body of a 200 response; anything else is logged under `what` and dropped. - std::optional HttpsOk(const char *what, const wchar_t *host, const wchar_t *path, const wchar_t *method, const std::wstring &headers, const std::string &body = {}) { + 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 std::nullopt; + return HttpResult::Err(AuthError::Unreachable); } if (response->status != 200) { Logger()->warn("Epic {} request returned HTTP {}", what, response->status); - return std::nullopt; + const bool rejected = response->status == 400 || response->status == 401; + return HttpResult::Err(rejected ? AuthError::Rejected : AuthError::Unreachable); } - return std::move(response->body); + return HttpResult::Ok(std::move(response->body)); } std::wstring BasicAuthHeader() { @@ -219,16 +223,20 @@ namespace Framework::External::Epic { } } - std::optional TokenGrant(const std::string &formBody) { + AuthResult TokenGrant(const std::string &formBody) { const auto body = HttpsOk("token", kAuthHost, kTokenPath, L"POST", BasicAuthHeader(), formBody); - return body ? ParseTokens(*body) : std::nullopt; + if (!body) { + return AuthResult::Err(body.GetError()); + } + auto tokens = ParseTokens(body.GetValue()); + return tokens ? AuthResult::Ok(std::move(*tokens)) : AuthResult::Err(AuthError::Unreachable); } - std::optional RefreshGrant(const std::string &refreshToken) { + AuthResult RefreshGrant(const std::string &refreshToken) { return TokenGrant("grant_type=refresh_token&token_type=eg1&refresh_token=" + FormEncode(refreshToken)); } - std::optional AuthCodeGrant(const std::string &authCode) { + AuthResult AuthCodeGrant(const std::string &authCode) { return TokenGrant("grant_type=authorization_code&token_type=eg1&code=" + FormEncode(authCode)); } @@ -349,7 +357,7 @@ namespace Framework::External::Epic { return std::nullopt; } Logger()->info("Epic sign-in succeeded"); - return tokens; + return std::move(tokens).GetValue(); } // Ownership-verification token the Epic launcher drops under \.egstore. We take @@ -390,14 +398,14 @@ namespace Framework::External::Epic { if (!token) { return {}; } - if (token->empty()) { + 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->data(), static_cast(token->size()))) { + if (!f.write(token.GetValue().data(), static_cast(token.GetValue().size()))) { Logger()->warn("Could not write the minted Epic ownership token"); return {}; } @@ -405,26 +413,35 @@ namespace Framework::External::Epic { } } // namespace - std::optional TryRefreshStoredAuth() { + AuthResult TryRefreshStoredAuth() { const auto rt = LoadRefreshToken(); - auto tokens = rt ? RefreshGrant(*rt) : std::nullopt; + if (!rt) { + return AuthResult::Err(AuthError::NoStoredSignIn); + } + auto tokens = RefreshGrant(*rt); if (!tokens) { - return std::nullopt; + 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->refreshToken); + PersistRotatedRefreshToken(tokens.GetValue().refreshToken); return tokens; } - std::optional EnsureAuthenticated(const std::wstring &productName) { - if (auto tokens = TryRefreshStoredAuth()) { - return tokens; + AuthResult EnsureAuthenticated(const std::wstring &productName) { + auto stored = TryRefreshStoredAuth(); + if (stored || !NeedsSignIn(stored.GetError())) { + return stored; } auto tokens = InteractiveSignIn(productName); - if (tokens) { - PersistRotatedRefreshToken(tokens->refreshToken); + if (!tokens) { + return stored; } - return tokens; + PersistRotatedRefreshToken(tokens->refreshToken); + return AuthResult::Ok(std::move(*tokens)); } std::wstring GetLoginUrl() { @@ -443,7 +460,7 @@ namespace Framework::External::Epic { return false; } // The tokens are dropped here, so the stored refresh token is the only result there is. - if (!SaveRefreshToken(tokens->refreshToken)) { + if (!SaveRefreshToken(tokens.GetValue().refreshToken)) { Logger()->error("Epic sign-in succeeded, but the refresh token could not be persisted"); return false; } @@ -460,7 +477,7 @@ namespace Framework::External::Epic { return std::nullopt; } try { - std::string code = nlohmann::json::parse(*body).value("code", std::string {}); + 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 &) { diff --git a/code/framework/src/external/epic/auth.h b/code/framework/src/external/epic/auth.h index 0933a73ea..2c15e9821 100644 --- a/code/framework/src/external/epic/auth.h +++ b/code/framework/src/external/epic/auth.h @@ -8,6 +8,8 @@ #pragma once +#include "utils/result.h" + #include #include @@ -28,13 +30,28 @@ namespace Framework::External::Epic { } }; - // A usable access token: the stored refresh token first, else a browser + clipboard sign-in - // labelled with productName. nullopt if the user cancels or auth fails. The refresh token is - // persisted, DPAPI-encrypted, under %LOCALAPPDATA%\MafiaHub. - std::optional EnsureAuthenticated(const std::wstring &productName = {}); + 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. - std::optional TryRefreshStoredAuth(); + // 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); diff --git a/code/framework/src/launcher/platforms/platforms.cpp b/code/framework/src/launcher/platforms/platforms.cpp index 9d2a411b1..d452d35c4 100644 --- a/code/framework/src/launcher/platforms/platforms.cpp +++ b/code/framework/src/launcher/platforms/platforms.cpp @@ -224,38 +224,45 @@ namespace Framework::Launcher::Platforms { 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 = [&]() -> std::optional { - if (auto tokens = External::Epic::TryRefreshStoredAuth()) { - return tokens; + 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() : std::nullopt; + return _options.signIn() ? External::Epic::TryRefreshStoredAuth() : stored; } switch (RunEpicSignInWindow(host.GetProjectPath())) { case EpicSignInWindow::SignedIn: return External::Epic::TryRefreshStoredAuth(); - case EpicSignInWindow::Failed: return std::nullopt; + case EpicSignInWindow::Failed: return stored; case EpicSignInWindow::Missing: break; } return External::Epic::EnsureAuthenticated(Utils::StringUtils::Utf8ToWide(host.GetConfig().name)); }; - const auto tokens = signIn(); - if (!tokens) { - host.ReportError("Epic sign-in is required to play the Epic version of the game"); + 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(); - const auto exchangeCode = External::Epic::GetExchangeCode(*tokens); + // 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) { - External::Epic::ClearStoredAuth(); // the next launch signs in afresh 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)); + 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); + _launchArguments = External::Epic::BuildLaunchArgs(tokens, *exchangeCode, _app.appName, _app.catalogNamespace, _app.catalogItemId, _app.installLocation); return true; }