#include "runtime/auth_runtime_composition.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "jnivm/jnivm.h" #include "legacy/legacy_runtime.h" #include "runtime/environment.h" #include "runtime/runtime_paths.h" #include "services/auth_service.h" #include "services/http_client.h" namespace mocktail { namespace runtime { namespace { constexpr char kCredential[] = "_|test-auth-runtime-credential"; static_assert(!std::is_copy_constructible_v); static_assert(!std::is_move_constructible_v, "credential provider context must keep a stable address"); static_assert(!std::is_move_assignable_v, "credential provider binding must not move implicitly"); static_assert(!std::is_copy_assignable_v); static_assert(std::is_nothrow_move_constructible_v); class MapEnvironment final : public Environment { public: void Set(std::string name, std::string value) { values_[std::move(name)] = std::move(value); } std::optional Get(std::string_view name) const override { const auto found = values_.find(std::string(name)); if (found == values_.end()) { return std::nullopt; } return found->second; } private: std::map values_; }; class FakeHttpClient final : public services::HttpClient { public: services::HttpResponse Get(const services::HttpRequest& request) override { ++request_count; last_request = request; if (before_response) { before_response(); } return response; } services::HttpResponse response; services::HttpRequest last_request; std::function before_response; int request_count = 0; }; class TempDirectory final { public: TempDirectory() { static std::atomic serial{0}; path_ = std::filesystem::temp_directory_path() / ("mocktail-auth-composition-" + std::to_string(getpid()) + "-" + std::to_string(serial.fetch_add(1))); std::error_code error; std::filesystem::create_directories(path_, error); } ~TempDirectory() { std::error_code error; std::filesystem::remove_all(path_, error); } const std::filesystem::path& path() const { return path_; } private: std::filesystem::path path_; }; bool WriteFile(const std::filesystem::path& path, const std::string& contents) { std::error_code error; std::filesystem::create_directories(path.parent_path(), error); if (error) { return false; } if (chmod(path.parent_path().c_str(), S_IRWXU) != 0) { return false; } std::ofstream stream(path, std::ios::binary | std::ios::trunc); stream << contents; stream.close(); return stream.good() && chmod(path.c_str(), S_IRUSR | S_IWUSR) == 0; } std::string ReadFile(const std::filesystem::path& path) { std::ifstream input(path, std::ios::binary); return {std::istreambuf_iterator(input), std::istreambuf_iterator()}; } RuntimePaths PathsFor(const MapEnvironment& environment, const TempDirectory& directory) { return RuntimePaths::FromEnvironment(environment, directory.path()); } std::string ReadJavaString(JNIEnv* env, jstring value) { if (env == nullptr || value == nullptr) { return {}; } const char* chars = env->GetStringUTFChars(value, nullptr); if (chars == nullptr) { return {}; } const std::string result(chars); env->ReleaseStringUTFChars(value, chars); return result; } struct NativeCookieCallbackProbe { int calls = 0; std::string cookie; std::string url; }; void IgnoreDataModelNotification(void*, JNIEnv*, jstring, jstring) {} void CaptureNativeCookie(void* context, JNIEnv* env, jstring cookie, jstring url) { auto* probe = static_cast(context); ++probe->calls; probe->cookie = ReadJavaString(env, cookie); probe->url = ReadJavaString(env, url); } TEST(AuthRuntimeCompositionTest, ResolvesSavedCookieAndInjectsOnlyTypedIdentity) { TempDirectory directory; MapEnvironment environment; environment.Set("HOME", directory.path().string()); environment.Set("MOCKTAIL_ROBLOX_COOKIES", std::string(".ROBLOSECURITY=") + kCredential); FakeHttpClient http; http.response = { true, 200, R"({"id":321,"name":"RuntimeUser","displayName":"Runtime Display"})", {}}; services::AuthService auth_service(http); const AuthRuntimeComposition composition = ComposeAuthRuntime( environment, PathsFor(environment, directory), auth_service); ASSERT_TRUE(composition); EXPECT_EQ(composition.status, AuthRuntimeStatus::kAuthenticated); EXPECT_EQ(composition.http_status, 200); EXPECT_TRUE(composition.error.empty()); ASSERT_EQ(http.request_count, 1); EXPECT_FALSE(http.last_request.follow_redirects); ASSERT_EQ(http.last_request.headers.size(), 2U); EXPECT_EQ(http.last_request.headers[1], std::string("Cookie: .ROBLOSECURITY=") + kCredential); EXPECT_EQ(composition.credential.view(), std::string(".ROBLOSECURITY=") + kCredential); const jnivm::RobloxAuthIdentity identity = composition.jni_vm->GetRobloxAuthIdentitySnapshot(); EXPECT_EQ(identity.user_id, 321); EXPECT_EQ(identity.username, "RuntimeUser"); EXPECT_EQ(identity.display_name, "Runtime Display"); EXPECT_EQ(composition.account_identity.user_id, identity.user_id); EXPECT_EQ(composition.account_identity.username, identity.username); EXPECT_EQ(identity.username.find(kCredential), std::string::npos); EXPECT_EQ(identity.display_name.find(kCredential), std::string::npos); } TEST(AuthRuntimeCompositionTest, DiscardedGuestSignInCandidateKeepsProductionJniAvailable) { TempDirectory directory; MapEnvironment environment; environment.Set("HOME", directory.path().string()); environment.Set("MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP", "1"); FakeHttpClient http; services::AuthService auth_service(http); const auto paths = PathsFor(environment, directory); AuthRuntimeComposition production = ComposeAuthRuntime(environment, paths, auth_service); ASSERT_EQ(production.status, AuthRuntimeStatus::kGuest); ASSERT_TRUE(production); // The first-launch browser can capture a cookie header without an // authenticated Roblox session. PromptFirstLaunchSignIn discards that // second guest composition and keeps the original production VM. environment.Set("MOCKTAIL_ROBLOX_COOKIES", "RBXEventTrackerV2=test-tracker"); { AuthRuntimeComposition candidate = ComposeAuthRuntime(environment, paths, auth_service); ASSERT_EQ(candidate.status, AuthRuntimeStatus::kGuest); ASSERT_TRUE(candidate); ASSERT_NE(candidate.jni_vm, production.jni_vm); } JNIEnv* env = production.jni_vm->GetJNIEnv(); JavaVM* java_vm = production.jni_vm->GetJavaVM(); EXPECT_EQ(jnivm::VM::FromJavaVM(java_vm), production.jni_vm.get()); void* queried_env = nullptr; EXPECT_EQ(java_vm->GetEnv(&queried_env, JNI_VERSION_1_6), JNI_OK); EXPECT_EQ(queried_env, env); void* attached_env = nullptr; EXPECT_EQ(java_vm->AttachCurrentThread(&attached_env, nullptr), JNI_OK); EXPECT_EQ(attached_env, env); EXPECT_EQ(http.request_count, 0); } TEST(AuthRuntimeCompositionTest, SoberCookieIsIgnoredAndGuestPolicyRequired) { TempDirectory directory; MapEnvironment environment; environment.Set("HOME", directory.path().string()); ASSERT_TRUE(WriteFile( directory.path() / ".var/app/org.vinegarhq.Sober/data/sober/cookies", std::string(".ROBLOSECURITY=") + kCredential + "\n")); FakeHttpClient http; services::AuthService auth_service(http); AuthRuntimeComposition composition = ComposeAuthRuntime( environment, PathsFor(environment, directory), auth_service); EXPECT_FALSE(composition); EXPECT_EQ(composition.status, AuthRuntimeStatus::kInvalidCredentials); EXPECT_EQ(composition.error, "missing .ROBLOSECURITY cookie"); EXPECT_EQ(http.request_count, 0); EXPECT_TRUE(composition.credential.empty()); EXPECT_EQ(composition.account_identity.user_id, -1); environment.Set("MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP", "1"); composition = ComposeAuthRuntime( environment, PathsFor(environment, directory), auth_service); ASSERT_TRUE(composition); EXPECT_EQ(composition.status, AuthRuntimeStatus::kGuest); EXPECT_EQ(http.request_count, 0); const jnivm::RobloxAuthIdentity identity = composition.jni_vm->GetRobloxAuthIdentitySnapshot(); EXPECT_EQ(identity.user_id, -1); EXPECT_TRUE(identity.username.empty()); EXPECT_TRUE(identity.display_name.empty()); } TEST(AuthRuntimeCompositionTest, GuestJavaCookiePersistsPrivatelyAndBecomesCurrentCredential) { TempDirectory directory; MapEnvironment environment; environment.Set("HOME", directory.path().string()); environment.Set("MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP", "1"); const RuntimePaths paths = PathsFor(environment, directory); FakeHttpClient http; services::AuthService auth_service(http); AuthRuntimeComposition composition = ComposeAuthRuntime(environment, paths, auth_service); ASSERT_TRUE(composition); ASSERT_EQ(composition.status, AuthRuntimeStatus::kGuest); const std::string saved = ".ROBLOSECURITY=_|embedded-login-cookie"; { const ScopedRobloxCredentialBinding binding(composition.jni_vm.get(), composition.credential); ASSERT_TRUE(binding.bound()); JNIEnv* env = composition.jni_vm->GetJNIEnv(); jclass clazz = env->FindClass("com/roblox/universalapp/cookie/JNICookieManager"); ASSERT_NE(clazz, nullptr); jobject manager = env->AllocObject(clazz); jmethodID setter = env->GetMethodID( clazz, "setCookie", "(Ljava/lang/String;Ljava/lang/String;)V"); ASSERT_NE(setter, nullptr); env->CallVoidMethod(manager, setter, env->NewStringUTF("https://www.roblox.com/"), env->NewStringUTF(saved.c_str())); std::string provider_value = "must be cleared"; EXPECT_TRUE(composition.jni_vm->CopyRobloxCredentialFromProvider( &provider_value)); EXPECT_EQ(provider_value, saved); jmethodID getter = env->GetStaticMethodID( clazz, "getCookie", "(Ljava/lang/String;)Ljava/lang/String;"); auto current = static_cast(env->CallStaticObjectMethod( clazz, getter, env->NewStringUTF("roblox.com"))); EXPECT_EQ(ReadJavaString(env, current), saved); EXPECT_EQ( composition.jni_vm->GetRobloxAuthIdentitySnapshot().user_id, -1); } std::ifstream input(paths.cookie_file(), std::ios::binary); ASSERT_TRUE(input.good()); const std::string persisted((std::istreambuf_iterator(input)), std::istreambuf_iterator()); EXPECT_EQ(persisted, saved + "\n"); struct stat auth_status = {}; struct stat cookie_status = {}; ASSERT_EQ(lstat(paths.auth_root().c_str(), &auth_status), 0); ASSERT_EQ(lstat(paths.cookie_file().c_str(), &cookie_status), 0); EXPECT_TRUE(S_ISDIR(auth_status.st_mode)); EXPECT_EQ(auth_status.st_mode & (S_IRWXG | S_IRWXO), 0U); EXPECT_TRUE(S_ISREG(cookie_status.st_mode)); EXPECT_EQ(cookie_status.st_mode & (S_IRWXG | S_IRWXO), 0U); } TEST(AuthRuntimeCompositionTest, NativeSignInPromotesValidatedIdentityWithoutRestart) { TempDirectory directory; MapEnvironment environment; environment.Set("HOME", directory.path().string()); environment.Set("MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP", "1"); const RuntimePaths paths = PathsFor(environment, directory); auto http = std::make_shared(); http->response = { true, 200, R"({"id":987,"name":"LiveUser","displayName":"Live User"})", {}}; services::AuthService auth_service(*http); AuthRuntimeComposition composition = ComposeAuthRuntime(environment, paths, auth_service, http); ASSERT_TRUE(composition); ASSERT_EQ(composition.status, AuthRuntimeStatus::kGuest); ASSERT_EQ(http->request_count, 0); const std::string saved = ".ROBLOSECURITY=_|live-native-login-cookie"; { const ScopedRobloxCredentialBinding binding(composition.jni_vm.get(), composition.credential); ASSERT_TRUE(binding.bound()); ASSERT_TRUE(composition.jni_vm->DispatchRobloxCredential(saved.data(), saved.size())); std::string current; ASSERT_TRUE( composition.jni_vm->CopyRobloxCredentialFromProvider(¤t)); EXPECT_EQ(current, saved); } ASSERT_EQ(http->request_count, 1); ASSERT_GE(http->last_request.headers.size(), 2U); EXPECT_EQ(http->last_request.headers[1], "Cookie: " + saved); const jnivm::RobloxAuthIdentity identity = composition.jni_vm->GetRobloxAuthIdentitySnapshot(); EXPECT_EQ(identity.user_id, 987); EXPECT_EQ(identity.username, "LiveUser"); EXPECT_EQ(identity.display_name, "Live User"); EXPECT_EQ(ReadFile(paths.cookie_file()), saved + "\n"); } TEST(AuthRuntimeCompositionTest, NativeCookieCallbackAuthenticatesFromManagedFileAfterRestart) { TempDirectory directory; MapEnvironment environment; environment.Set("HOME", directory.path().string()); environment.Set("MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP", "1"); const RuntimePaths paths = PathsFor(environment, directory); FakeHttpClient initial_http; services::AuthService initial_auth_service(initial_http); AuthRuntimeComposition composition = ComposeAuthRuntime(environment, paths, initial_auth_service); ASSERT_TRUE(composition); ASSERT_EQ(composition.status, AuthRuntimeStatus::kGuest); const std::string saved = ".ROBLOSECURITY=_|native-login-cookie"; auto callback_probe = std::make_shared(); jnivm::RobloxDataModelNotificationCallbacks callbacks; callbacks.on_notification = &IgnoreDataModelNotification; callbacks.on_set_cookie = &CaptureNativeCookie; composition.jni_vm->SetRobloxDataModelNotificationCallbacks(callback_probe, callbacks); { const ScopedRobloxCredentialBinding binding(composition.jni_vm.get(), composition.credential); ASSERT_TRUE(binding.bound()); JNIEnv* env = composition.jni_vm->GetJNIEnv(); jclass handler_class = env->FindClass( "com/roblox/universalapp/cookie/" "JNICookieProtocol$OnSetCookieHandler"); jobject handler = env->AllocObject(handler_class); jmethodID on_set_cookie = env->GetMethodID( handler_class, "onSetCookie", "([Ljava/lang/String;Ljava/lang/String;)V"); jclass string_class = env->FindClass("java/lang/String"); jobjectArray cookies = env->NewObjectArray(1, string_class, nullptr); env->SetObjectArrayElement( cookies, 0, env->NewStringUTF( ".ROBLOSECURITY=_|native-login-cookie; Domain=.roblox.com; " "Path=/; Secure; HttpOnly")); env->CallVoidMethod(handler, on_set_cookie, cookies, env->NewStringUTF("https://www.roblox.com/")); } composition.jni_vm->ClearRobloxDataModelNotificationCallbacks(); EXPECT_EQ(callback_probe->calls, 1); EXPECT_EQ(callback_probe->cookie, saved); EXPECT_EQ(callback_probe->url, "https://www.roblox.com/"); EXPECT_EQ(ReadFile(paths.cookie_file()), saved + "\n"); MapEnvironment restart_environment; restart_environment.Set("HOME", directory.path().string()); FakeHttpClient restart_http; restart_http.response = { true, 200, R"({"id":777,"name":"RestartUser","displayName":"Restart User"})", {}}; services::AuthService restart_auth_service(restart_http); const AuthRuntimeComposition restarted = ComposeAuthRuntime( restart_environment, PathsFor(restart_environment, directory), restart_auth_service); ASSERT_TRUE(restarted) << restarted.error; EXPECT_EQ(restarted.status, AuthRuntimeStatus::kAuthenticated); ASSERT_EQ(restart_http.request_count, 1); EXPECT_EQ(restart_http.last_request.headers[1], "Cookie: " + saved); } TEST(AuthRuntimeCompositionTest, RejectedEnvironmentCredentialNeverCreatesProductionVm) { TempDirectory directory; MapEnvironment environment; environment.Set("HOME", directory.path().string()); environment.Set("MOCKTAIL_ROBLOX_COOKIES", kCredential); environment.Set("MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP", "1"); const RuntimePaths paths = PathsFor(environment, directory); const std::string managed_cookie = "RBXEventTrackerV2=browserid=123456&browserid_insert_timestamp=1\n"; ASSERT_TRUE(WriteFile(paths.cookie_file(), managed_cookie)); FakeHttpClient http; http.response = {true, 401, {}, {}}; services::AuthService auth_service(http); const AuthRuntimeComposition composition = ComposeAuthRuntime(environment, paths, auth_service); EXPECT_FALSE(composition); EXPECT_EQ(composition.status, AuthRuntimeStatus::kInvalidCredentials); EXPECT_EQ(composition.http_status, 401); EXPECT_EQ(composition.error, "authentication rejected"); EXPECT_EQ(composition.error.find(kCredential), std::string::npos); EXPECT_EQ(ReadFile(paths.cookie_file()), managed_cookie); } TEST(AuthRuntimeCompositionTest, Http401ClearsManagedCredentialAndStartsNativeSignInGuest) { TempDirectory directory; MapEnvironment environment; environment.Set("HOME", directory.path().string()); environment.Set("MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP", "1"); const RuntimePaths paths = PathsFor(environment, directory); const std::string remaining_cookies = "RBXEventTrackerV2=browserid=123456&browserid_insert_timestamp=1\n" "OtherCookie=keep\n"; const std::string rejected_cookie = std::string(".ROBLOSECURITY=") + kCredential; ASSERT_TRUE(WriteFile(paths.cookie_file(), rejected_cookie + "\n" + remaining_cookies)); FakeHttpClient http; http.response = {true, 401, {}, {}}; services::AuthService auth_service(http); const AuthRuntimeComposition composition = ComposeAuthRuntime(environment, paths, auth_service); ASSERT_TRUE(composition) << composition.error; EXPECT_EQ(composition.status, AuthRuntimeStatus::kGuest); EXPECT_EQ(composition.http_status, 401); EXPECT_TRUE(composition.error.empty()); EXPECT_TRUE(composition.credential.empty()); EXPECT_EQ(composition.account_identity.user_id, -1); EXPECT_EQ(http.request_count, 1); EXPECT_EQ( ReadFile(paths.cookie_file()), std::string(rejected_cookie.size(), ' ') + "\n" + remaining_cookies); struct stat metadata = {}; ASSERT_EQ(lstat(paths.cookie_file().c_str(), &metadata), 0); EXPECT_TRUE(S_ISREG(metadata.st_mode)); EXPECT_EQ(metadata.st_mode & (S_IRWXG | S_IRWXO), 0U); FakeHttpClient restart_http; services::AuthService restart_auth_service(restart_http); const AuthRuntimeComposition restarted = ComposeAuthRuntime(environment, paths, restart_auth_service); ASSERT_TRUE(restarted) << restarted.error; EXPECT_EQ(restarted.status, AuthRuntimeStatus::kGuest); EXPECT_EQ(restart_http.request_count, 0); } TEST(AuthRuntimeCompositionTest, Http401ClearsPrivateReadOnlyManagedCredential) { TempDirectory directory; MapEnvironment environment; environment.Set("HOME", directory.path().string()); environment.Set("MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP", "1"); const RuntimePaths paths = PathsFor(environment, directory); const std::string remaining_cookies = "OtherCookie=keep\n"; const std::string rejected_cookie = std::string(".ROBLOSECURITY=") + kCredential; ASSERT_TRUE(WriteFile(paths.cookie_file(), rejected_cookie + "\n" + remaining_cookies)); ASSERT_EQ(chmod(paths.cookie_file().c_str(), S_IRUSR), 0); FakeHttpClient http; http.response = {true, 401, {}, {}}; services::AuthService auth_service(http); const AuthRuntimeComposition composition = ComposeAuthRuntime(environment, paths, auth_service); ASSERT_TRUE(composition) << composition.error; EXPECT_EQ(composition.status, AuthRuntimeStatus::kGuest); EXPECT_EQ( ReadFile(paths.cookie_file()), std::string(rejected_cookie.size(), ' ') + "\n" + remaining_cookies); struct stat metadata = {}; ASSERT_EQ(lstat(paths.cookie_file().c_str(), &metadata), 0); EXPECT_EQ(metadata.st_mode & (S_IRWXG | S_IRWXO), 0U); EXPECT_NE(metadata.st_mode & S_IWUSR, 0U); } TEST(AuthRuntimeCompositionTest, Http401RedactsRawAndDuplicateSegmentsWithMixedDelimiters) { TempDirectory directory; MapEnvironment environment; environment.Set("HOME", directory.path().string()); environment.Set("MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP", "1"); const RuntimePaths paths = PathsFor(environment, directory); const std::string raw_cookie = kCredential; const std::string duplicate = ".ROBLOSECURITY=duplicate-value"; const std::string cookies = raw_cookie + "\r\nOtherCookie=keep; " + duplicate + "\tTailCookie=keep\n"; ASSERT_TRUE(WriteFile(paths.cookie_file(), cookies)); FakeHttpClient http; http.response = {true, 401, {}, {}}; services::AuthService auth_service(http); const AuthRuntimeComposition composition = ComposeAuthRuntime(environment, paths, auth_service); ASSERT_TRUE(composition) << composition.error; EXPECT_EQ(composition.status, AuthRuntimeStatus::kGuest); const std::string expected = std::string(raw_cookie.size(), ' ') + "\r\nOtherCookie=keep;" + std::string(duplicate.size() + 1, ' ') + "\tTailCookie=keep\n"; EXPECT_EQ(ReadFile(paths.cookie_file()), expected); EXPECT_EQ(expected.size(), cookies.size()); FakeHttpClient restart_http; services::AuthService restart_auth_service(restart_http); const AuthRuntimeComposition restarted = ComposeAuthRuntime(environment, paths, restart_auth_service); ASSERT_TRUE(restarted) << restarted.error; EXPECT_EQ(restarted.status, AuthRuntimeStatus::kGuest); EXPECT_EQ(restart_http.request_count, 0); } TEST(AuthRuntimeCompositionTest, Http403ClearsManagedCookieAndStartsGuest) { TempDirectory directory; MapEnvironment environment; environment.Set("HOME", directory.path().string()); environment.Set("MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP", "1"); const RuntimePaths paths = PathsFor(environment, directory); const std::string cookies = std::string(".ROBLOSECURITY=") + kCredential + "\nRBXEventTrackerV2=browserid=123456\n"; ASSERT_TRUE(WriteFile(paths.cookie_file(), cookies)); FakeHttpClient http; http.response = {true, 403, {}, {}}; services::AuthService auth_service(http); const AuthRuntimeComposition composition = ComposeAuthRuntime(environment, paths, auth_service); ASSERT_TRUE(composition) << composition.error; EXPECT_EQ(composition.status, AuthRuntimeStatus::kGuest); EXPECT_EQ(composition.http_status, 403); EXPECT_TRUE(composition.rejected_credential_retired); const std::string rejected_cookie = std::string(".ROBLOSECURITY=") + kCredential; EXPECT_EQ(ReadFile(paths.cookie_file()), std::string(rejected_cookie.size(), ' ') + "\nRBXEventTrackerV2=browserid=123456\n"); } TEST(AuthRuntimeCompositionTest, Http401ClearsManagedCredentialButDoesNotStartGuestWhenDisabled) { TempDirectory directory; MapEnvironment environment; environment.Set("HOME", directory.path().string()); const RuntimePaths paths = PathsFor(environment, directory); const std::string remaining_cookies = "RBXEventTrackerV2=browserid=123456\n"; const std::string rejected_cookie = std::string(".ROBLOSECURITY=") + kCredential; const std::string cookies = rejected_cookie + "\n" + remaining_cookies; ASSERT_TRUE(WriteFile(paths.cookie_file(), cookies)); FakeHttpClient http; http.response = {true, 401, {}, {}}; services::AuthService auth_service(http); const AuthRuntimeComposition composition = ComposeAuthRuntime(environment, paths, auth_service); EXPECT_FALSE(composition); EXPECT_EQ(composition.status, AuthRuntimeStatus::kInvalidCredentials); EXPECT_EQ(composition.http_status, 401); EXPECT_EQ( ReadFile(paths.cookie_file()), std::string(rejected_cookie.size(), ' ') + "\n" + remaining_cookies); } TEST(AuthRuntimeCompositionTest, Http401DoesNotClearExplicitCredentialOverride) { TempDirectory directory; const std::filesystem::path explicit_cookie = directory.path() / "explicit-cookie"; const std::string cookies = std::string(".ROBLOSECURITY=") + kCredential + "\n"; ASSERT_TRUE(WriteFile(explicit_cookie, cookies)); MapEnvironment environment; environment.Set("HOME", directory.path().string()); environment.Set("MOCKTAIL_COOKIE_FILE", explicit_cookie.string()); environment.Set("MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP", "1"); FakeHttpClient http; http.response = {true, 401, {}, {}}; services::AuthService auth_service(http); const AuthRuntimeComposition composition = ComposeAuthRuntime( environment, PathsFor(environment, directory), auth_service); EXPECT_FALSE(composition); EXPECT_EQ(composition.status, AuthRuntimeStatus::kInvalidCredentials); EXPECT_EQ(composition.http_status, 401); EXPECT_EQ(ReadFile(explicit_cookie), cookies); } TEST(AuthRuntimeCompositionTest, Http401FailsClosedWhileCookieWriterLockIsHeld) { TempDirectory directory; MapEnvironment environment; environment.Set("HOME", directory.path().string()); environment.Set("MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP", "1"); const RuntimePaths paths = PathsFor(environment, directory); const std::string cookies = std::string(".ROBLOSECURITY=") + kCredential + "\n"; ASSERT_TRUE(WriteFile(paths.cookie_file(), cookies)); const std::filesystem::path lock_path = paths.cookie_file().parent_path() / ("." + paths.cookie_file().filename().string() + ".mocktail-writer.lock"); ASSERT_TRUE(WriteFile(lock_path, "")); const int lock_descriptor = open(lock_path.c_str(), O_RDWR | O_CLOEXEC | O_NOFOLLOW); ASSERT_GE(lock_descriptor, 0); ASSERT_EQ(flock(lock_descriptor, LOCK_EX | LOCK_NB), 0); FakeHttpClient http; http.response = {true, 401, {}, {}}; services::AuthService auth_service(http); const AuthRuntimeComposition composition = ComposeAuthRuntime(environment, paths, auth_service); EXPECT_FALSE(composition); EXPECT_EQ(composition.status, AuthRuntimeStatus::kUnavailable); EXPECT_EQ(composition.http_status, 401); EXPECT_EQ(ReadFile(paths.cookie_file()), cookies); EXPECT_EQ(flock(lock_descriptor, LOCK_UN), 0); EXPECT_EQ(close(lock_descriptor), 0); } TEST(AuthRuntimeCompositionTest, CredentialRefreshDuringHttp401RecoveryIsPreserved) { TempDirectory directory; MapEnvironment environment; environment.Set("HOME", directory.path().string()); environment.Set("MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP", "1"); const RuntimePaths paths = PathsFor(environment, directory); ASSERT_TRUE(WriteFile(paths.cookie_file(), std::string(".ROBLOSECURITY=") + kCredential + "\n")); struct stat original_metadata = {}; ASSERT_EQ(lstat(paths.cookie_file().c_str(), &original_metadata), 0); const std::string refreshed_contents = ".ROBLOSECURITY=_|newly-refreshed-credential\n" "RBXEventTrackerV2=browserid=987654\n"; FakeHttpClient http; http.response = {true, 401, {}, {}}; http.before_response = [&]() { EXPECT_TRUE(WriteFile(paths.cookie_file(), refreshed_contents)); }; services::AuthService auth_service(http); const AuthRuntimeComposition composition = ComposeAuthRuntime(environment, paths, auth_service); EXPECT_FALSE(composition); EXPECT_EQ(composition.status, AuthRuntimeStatus::kUnavailable); EXPECT_EQ(composition.http_status, 401); EXPECT_EQ(composition.error, "Roblox cookie changed while authentication was checked"); EXPECT_EQ(ReadFile(paths.cookie_file()), refreshed_contents); struct stat refreshed_metadata = {}; ASSERT_EQ(lstat(paths.cookie_file().c_str(), &refreshed_metadata), 0); EXPECT_EQ(refreshed_metadata.st_dev, original_metadata.st_dev); EXPECT_EQ(refreshed_metadata.st_ino, original_metadata.st_ino); } TEST(AuthRuntimeCompositionTest, CookieReplacementDuringHttp401RecoveryFailsClosed) { TempDirectory directory; MapEnvironment environment; environment.Set("HOME", directory.path().string()); environment.Set("MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP", "1"); const RuntimePaths paths = PathsFor(environment, directory); ASSERT_TRUE(WriteFile(paths.cookie_file(), std::string(".ROBLOSECURITY=") + kCredential + "\n")); const std::filesystem::path protected_target = directory.path() / "protected-cookie"; const std::string protected_contents = ".ROBLOSECURITY=_|newly-refreshed-credential\n"; ASSERT_TRUE(WriteFile(protected_target, protected_contents)); FakeHttpClient http; http.response = {true, 401, {}, {}}; http.before_response = [&]() { std::error_code error; std::filesystem::remove(paths.cookie_file(), error); EXPECT_FALSE(error); std::filesystem::create_symlink(protected_target, paths.cookie_file(), error); EXPECT_FALSE(error); }; services::AuthService auth_service(http); const AuthRuntimeComposition composition = ComposeAuthRuntime(environment, paths, auth_service); EXPECT_FALSE(composition); EXPECT_EQ(composition.status, AuthRuntimeStatus::kUnavailable); EXPECT_EQ(composition.http_status, 401); EXPECT_EQ(composition.error, "rejected Roblox cookie could not be cleared safely"); EXPECT_TRUE(std::filesystem::is_symlink(paths.cookie_file())); EXPECT_EQ(ReadFile(protected_target), protected_contents); } TEST(AuthRuntimeCompositionTest, UnavailableAuthFailsClosedAndRedactsErrors) { TempDirectory directory; MapEnvironment environment; environment.Set("HOME", directory.path().string()); environment.Set("MOCKTAIL_ROBLOX_COOKIES", kCredential); FakeHttpClient http; http.response = {false, 0, {}, std::string("request exposed ") + kCredential}; services::AuthService auth_service(http); const AuthRuntimeComposition composition = ComposeAuthRuntime( environment, PathsFor(environment, directory), auth_service); EXPECT_FALSE(composition); EXPECT_EQ(composition.status, AuthRuntimeStatus::kUnavailable); EXPECT_EQ(composition.error, "authentication service unavailable"); EXPECT_EQ(composition.error.find(kCredential), std::string::npos); } TEST(AuthRuntimeCompositionTest, ExplicitCookieFileIsAuthoritative) { TempDirectory directory; MapEnvironment environment; environment.Set("HOME", directory.path().string()); environment.Set("MOCKTAIL_CONFIG_ROOT", (directory.path() / "config").string()); environment.Set("MOCKTAIL_COOKIE_FILE", (directory.path() / "missing-cookie").string()); environment.Set("MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP", "1"); const RuntimePaths paths = PathsFor(environment, directory); ASSERT_TRUE(WriteFile(paths.cookie_file(), std::string(".ROBLOSECURITY=") + kCredential)); FakeHttpClient http; services::AuthService auth_service(http); const AuthRuntimeComposition composition = ComposeAuthRuntime(environment, paths, auth_service); EXPECT_FALSE(composition); EXPECT_EQ(composition.status, AuthRuntimeStatus::kUnavailable); EXPECT_EQ(composition.error, "configured Roblox cookie file is unavailable"); EXPECT_EQ(http.request_count, 0); } TEST(AuthRuntimeCompositionTest, RejectsCookieFileWithSharedPermissions) { TempDirectory directory; const std::filesystem::path cookie_file = directory.path() / "cookie"; ASSERT_TRUE( WriteFile(cookie_file, std::string(".ROBLOSECURITY=") + kCredential)); ASSERT_EQ(chmod(cookie_file.c_str(), S_IRUSR | S_IWUSR | S_IRGRP), 0); MapEnvironment environment; environment.Set("HOME", directory.path().string()); environment.Set("MOCKTAIL_COOKIE_FILE", cookie_file.string()); FakeHttpClient http; services::AuthService auth_service(http); const AuthRuntimeComposition composition = ComposeAuthRuntime( environment, PathsFor(environment, directory), auth_service); EXPECT_FALSE(composition); EXPECT_EQ(composition.status, AuthRuntimeStatus::kUnavailable); EXPECT_EQ(composition.error, "Roblox cookie file permissions are not private"); EXPECT_EQ(http.request_count, 0); } TEST(AuthRuntimeCompositionTest, RejectsSymlinkCookieSource) { TempDirectory directory; const std::filesystem::path target = directory.path() / "cookie-target"; const std::filesystem::path symlink = directory.path() / "cookie-link"; ASSERT_TRUE(WriteFile(target, std::string(".ROBLOSECURITY=") + kCredential)); std::error_code error; std::filesystem::create_symlink(target, symlink, error); ASSERT_FALSE(error); MapEnvironment environment; environment.Set("HOME", directory.path().string()); environment.Set("MOCKTAIL_COOKIE_FILE", symlink.string()); FakeHttpClient http; services::AuthService auth_service(http); const AuthRuntimeComposition composition = ComposeAuthRuntime( environment, PathsFor(environment, directory), auth_service); EXPECT_FALSE(composition); EXPECT_EQ(composition.status, AuthRuntimeStatus::kUnavailable); EXPECT_EQ(composition.error, "configured Roblox cookie file is unavailable"); EXPECT_EQ(http.request_count, 0); } TEST(AuthRuntimeCompositionTest, FileReplacementCannotChangeCredentialOrIdentityDependency) { constexpr char kCredentialA[] = "_|validated-credential-a"; constexpr char kCredentialB[] = "_|replacement-credential-b"; TempDirectory directory; const std::filesystem::path cookie_file = directory.path() / "cookie"; ASSERT_TRUE( WriteFile(cookie_file, std::string(".ROBLOSECURITY=") + kCredentialA)); MapEnvironment environment; environment.Set("HOME", directory.path().string()); environment.Set("MOCKTAIL_COOKIE_FILE", cookie_file.string()); FakeHttpClient http; http.response = { true, 200, R"({"id":777,"name":"ConsistentUser","displayName":"Consistent"})", {}}; services::AuthService auth_service(http); AuthRuntimeComposition composition = ComposeAuthRuntime( environment, PathsFor(environment, directory), auth_service); ASSERT_TRUE(composition); ASSERT_TRUE( WriteFile(cookie_file, std::string(".ROBLOSECURITY=") + kCredentialB)); mocktail::legacy::RuntimeDependencies dependencies(std::move(composition)); const std::string expected_header = std::string(".ROBLOSECURITY=") + kCredentialA; ASSERT_EQ(http.request_count, 1); EXPECT_EQ(http.last_request.headers[1], "Cookie: " + expected_header); EXPECT_EQ(dependencies.roblox_credential().view(), expected_header); EXPECT_EQ(dependencies.account_identity().user_id, 777); EXPECT_EQ(dependencies.account_identity().username, "ConsistentUser"); ASSERT_NE(dependencies.jni_vm(), nullptr); const jnivm::RobloxAuthIdentity vm_identity = dependencies.jni_vm()->GetRobloxAuthIdentitySnapshot(); EXPECT_EQ(vm_identity.user_id, dependencies.account_identity().user_id); EXPECT_EQ(vm_identity.username, dependencies.account_identity().username); { const ScopedRobloxCredentialBinding credential_binding( dependencies.jni_vm().get(), dependencies.roblox_credential()); ASSERT_TRUE(credential_binding.bound()); JNIEnv* env = dependencies.jni_vm()->GetJNIEnv(); jclass cookie_manager = env->FindClass("com/roblox/universalapp/cookie/JNICookieManager"); jmethodID get_cookie = env->GetStaticMethodID( cookie_manager, "getCookie", "(Ljava/lang/String;)Ljava/lang/String;"); auto java_cookie = static_cast(env->CallStaticObjectMethod( cookie_manager, get_cookie, env->NewStringUTF("roblox.com"))); EXPECT_EQ(ReadJavaString(env, java_cookie), expected_header); } EXPECT_FALSE( dependencies.jni_vm()->CopyRobloxCredentialFromProvider(nullptr)); } TEST(AuthRuntimeCompositionTest, GuestRuntimePromotesDispatchedCredentialAndExposesToJava) { const TempDirectory directory; MapEnvironment environment; environment.Set("HOME", directory.path().string()); environment.Set("MOCKTAIL_ALLOW_NO_COOKIE_LUA_APP", "1"); auto http = std::make_shared(); http->response = { true, 200, R"({"id":999,"name":"GuestPromotedUser","displayName":"GuestPromoted"})", {}}; services::AuthService auth_service(*http); AuthRuntimeComposition composition = ComposeAuthRuntime( environment, PathsFor(environment, directory), auth_service, http); ASSERT_TRUE(composition); EXPECT_EQ(composition.status, AuthRuntimeStatus::kGuest); std::shared_ptr vm = composition.jni_vm; ASSERT_NE(vm, nullptr); EXPECT_EQ(vm->GetRobloxAuthIdentitySnapshot().user_id, -1); const std::string dispatched_header = std::string(".ROBLOSECURITY=") + kCredential; EXPECT_TRUE(vm->DispatchRobloxCredential(dispatched_header.c_str(), dispatched_header.size())); const jnivm::RobloxAuthIdentity promoted_identity = vm->GetRobloxAuthIdentitySnapshot(); EXPECT_EQ(promoted_identity.user_id, 999); EXPECT_EQ(promoted_identity.username, "GuestPromotedUser"); std::string read_credential; EXPECT_TRUE(vm->CopyRobloxCredentialFromProvider(&read_credential)); EXPECT_EQ(read_credential, dispatched_header); JNIEnv* env = vm->GetJNIEnv(); jclass cookie_manager = env->FindClass("com/roblox/universalapp/cookie/JNICookieManager"); jmethodID get_cookie = env->GetStaticMethodID( cookie_manager, "getCookie", "(Ljava/lang/String;)Ljava/lang/String;"); auto java_cookie = static_cast(env->CallStaticObjectMethod( cookie_manager, get_cookie, env->NewStringUTF("roblox.com"))); EXPECT_EQ(ReadJavaString(env, java_cookie), dispatched_header); } } // namespace } // namespace runtime } // namespace mocktail