diff --git a/Assets/DualSessionDeviceTest.meta b/Assets/DualSessionDeviceTest.meta new file mode 100644 index 000000000..c31b00805 --- /dev/null +++ b/Assets/DualSessionDeviceTest.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 74db297a319e242c9a5d145a60bbc054 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/DualSessionDeviceTest/DualSessionDeviceTest.cs b/Assets/DualSessionDeviceTest/DualSessionDeviceTest.cs new file mode 100644 index 000000000..7e3803a44 --- /dev/null +++ b/Assets/DualSessionDeviceTest/DualSessionDeviceTest.cs @@ -0,0 +1,210 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Sequence.Adapter; +using Sequence.Authentication; +using Sequence.EmbeddedWallet; +using Sequence.Utils.SecureStorage; +using UnityEngine; + +namespace Sequence.DeviceTest +{ + /// + /// Manual on-device test for the guest + second-account flow (issue-tracker #7344 / #7348). + /// Guest on the default SequenceLogin, existing account (Google or email) on an isolated instance, transfer, + /// app restart, promotion. Built by Assets/DualSessionDeviceTest/Editor/BuildDualSessionDeviceTest.cs. + /// Every login failure raised by either instance is shown, so a failure on the instance that did not start a + /// sign-in is visible. + /// + public class DualSessionDeviceTest : MonoBehaviour + { + private const string UserNamespace = "user"; + + private SequenceLogin _default; + private SequenceLogin _isolated; + private EmbeddedWalletAdapter _adapter; + private SequenceWallet _guest; + private SequenceWallet _user; + private bool _busy; + private string _email = ""; + private string _code = ""; + private Vector2 _scroll; + private readonly List _log = new List(); + + private async void Start() + { + _adapter = EmbeddedWalletAdapter.GetInstance(); + _default = SequenceLogin.GetInstance(); + _isolated = SequenceLogin.CreateIsolatedInstance(UserNamespace); + + _default.OnLoginFailed += (error, method, email, methods) => Log($"DEFAULT login failed ({method}): {error}"); + _isolated.OnLoginFailed += (error, method, email, methods) => Log($"ISOLATED login failed ({method}): {error}"); + _isolated.OnSessionWalletCreated += w => { _user = w; Log("ISOLATED signed in " + w.GetWalletAddress()); }; + _isolated.OnMFAEmailSent += e => Log("code sent to " + e); + _isolated.OnMFAEmailFailedToSend += (e, error) => Log("code send failed: " + error); + SequenceWallet.OnWalletCreated += w => Log("static OnWalletCreated " + w.GetWalletAddress()); + + Log("restoring stored sessions"); + await Run("restore", async () => + { + bool recovered = await _adapter.TryRecoverWalletFromStorage(); + _guest = recovered ? _adapter.Wallet as SequenceWallet : null; + Log("default restore: " + (recovered ? Text(_adapter.Wallet?.GetWalletAddress()) : "none")); + var restored = await _isolated.TryToRestoreSessionAsync(); + _user = restored.Wallet as SequenceWallet; + Log("isolated restore: " + (_user != null ? _user.GetWalletAddress() : "none")); + }); + } + + private void OnGUI() + { + float scale = Screen.width / 540f; + GUI.matrix = Matrix4x4.Scale(new Vector3(scale, scale, 1)); + GUI.color = new Color(0.08f, 0.08f, 0.1f, 1); + GUI.DrawTexture(new Rect(0, 0, 540, Screen.height / scale), Texture2D.whiteTexture); + GUI.color = Color.white; + float width = 540 - 20; + GUI.skin.label.wordWrap = true; + GUI.skin.label.fontSize = 14; + GUI.skin.button.fontSize = 16; + GUI.skin.textField.fontSize = 16; + + GUILayout.BeginArea(new Rect(10, Screen.safeArea.yMin / scale + 10, width, Screen.height / scale - Screen.safeArea.yMin / scale - 20)); + _scroll = GUILayout.BeginScrollView(_scroll); + + Line("Default (guest)", Text(_guest?.GetWalletAddress())); + Line("Isolated (user)", Text(_user?.GetWalletAddress())); + Line("Adapter wallet", Text(_adapter?.Wallet?.GetWalletAddress())); + Line("Stored default key", StoredAddress("")); + Line("Stored isolated key", StoredAddress(UserNamespace)); + GUILayout.Label(_busy ? "working…" : "idle"); + + GUI.enabled = !_busy; + if (GUILayout.Button("1. Guest sign-in (default)", GUILayout.Height(44))) _ = Run("guest", GuestSignIn); + if (GUILayout.Button("2a. Google sign-in (isolated)", GUILayout.Height(44))) { Log("Google sign-in started on isolated"); _isolated.GoogleLogin(); } + GUILayout.BeginHorizontal(); + _email = GUILayout.TextField(_email, GUILayout.Height(40), GUILayout.Width(width * 0.6f)); + if (GUILayout.Button("2b. Send code", GUILayout.Height(40))) _ = Run("send code", () => _isolated.Login(_email.Trim())); + GUILayout.EndHorizontal(); + GUILayout.BeginHorizontal(); + _code = GUILayout.TextField(_code, GUILayout.Height(40), GUILayout.Width(width * 0.6f)); + if (GUILayout.Button("2c. Submit code", GUILayout.Height(40))) _ = Run("submit code", () => _isolated.Login(_email.Trim(), _code.Trim())); + GUILayout.EndHorizontal(); + if (GUILayout.Button("3. Send 0-value tx guest → isolated", GUILayout.Height(44))) _ = Run("transfer", Transfer); + if (GUILayout.Button("4. Sign out guest", GUILayout.Height(44))) _ = Run("guest sign-out", GuestSignOut); + if (GUILayout.Button("5. Promote isolated to default", GUILayout.Height(44))) Promote(); + if (GUILayout.Button("6. Sign out current (adapter)", GUILayout.Height(44))) _ = Run("sign out", SignOutCurrent); + if (GUILayout.Button("Reset: clear both stored sessions", GUILayout.Height(36))) Reset(); + GUI.enabled = true; + + GUILayout.Space(8); + foreach (string line in Enumerable.Reverse(_log)) GUILayout.Label(line); + GUILayout.EndScrollView(); + GUILayout.EndArea(); + } + + private async System.Threading.Tasks.Task GuestSignIn() + { + var tcs = new System.Threading.Tasks.TaskCompletionSource(); + void Created(SequenceWallet w) => tcs.TrySetResult(w); + _default.OnSessionWalletCreated += Created; + try + { + await _default.ConnectToWaaSAsGuest(); + var done = await System.Threading.Tasks.Task.WhenAny(tcs.Task, System.Threading.Tasks.Task.Delay(30000)); + _guest = done == tcs.Task ? tcs.Task.Result : null; + Log("guest: " + (_guest != null ? Text(_guest.GetWalletAddress()) : "failed")); + } + finally + { + _default.OnSessionWalletCreated -= Created; + } + } + + private async System.Threading.Tasks.Task Transfer() + { + if (_guest == null || _user == null) { Log("need both guest and isolated sessions"); return; } + TransactionReturn tx = await _guest.SendTransaction(Chain.TestnetArbitrumSepolia, + new Transaction[] { new RawTransaction(_user.GetWalletAddress(), "0") }); + Log(tx is SuccessfulTransactionReturn ok ? "tx " + ok.txHash : "tx failed: " + (tx as FailedTransactionReturn)?.error); + } + + private async System.Threading.Tasks.Task GuestSignOut() + { + if (_guest == null) { Log("no guest session"); return; } + bool dropped = await _guest.DropThisSession(); + Log("guest sign-out: " + dropped); + if (dropped) _guest = null; + } + + private void Promote() + { + if (_user == null) { Log("no isolated session"); return; } + try + { + _isolated.PromoteToDefaultSession(_user); + Log("promoted " + _user.GetWalletAddress()); + _guest = null; + } + catch (Exception e) + { + Log("promote failed: " + e.Message); + } + } + + private async System.Threading.Tasks.Task SignOutCurrent() + { + bool signedOut = await _adapter.SignOut(); + Log("adapter sign-out: " + signedOut); + if (signedOut) { _guest = null; _user = null; } + } + + private void Reset() + { + var storage = SecureStorageFactory.CreateSecureStorage(); + storage.StoreString(StorageKey(""), ""); + storage.StoreString(StorageKey(UserNamespace), ""); + _guest = null; + _user = null; + Log("stored sessions cleared (backend sessions not closed)"); + } + + private async System.Threading.Tasks.Task Run(string name, Func action) + { + _busy = true; + try { await action(); } + catch (Exception e) { Log($"{name} error: {e.Message}"); } + finally { _busy = false; } + } + + private static string StorageKey(string ns) => + Application.companyName + "-" + Application.productName + "-" + (string.IsNullOrEmpty(ns) ? "" : ns + "-") + "SessionWallet"; + + // Only the wallet address is shown; the stored value also holds the session private key. + private static string StoredAddress(string ns) + { + try + { + string raw = SecureStorageFactory.CreateSecureStorage().RetrieveString(StorageKey(ns)); + string[] parts = (raw ?? "").Split('-'); + return parts.Length >= 2 ? parts[1] : "(empty)"; + } + catch (Exception e) + { + return "(error: " + e.Message + ")"; + } + } + + // Address converts to string implicitly but throws on null. + private static string Text(Address address) => address == null ? null : address.Value; + + private static void Line(string label, string value) => GUILayout.Label($"{label}: {(string.IsNullOrEmpty(value) ? "-" : value)}"); + + private void Log(string line) + { + string stamped = DateTime.Now.ToString("HH:mm:ss ") + line; + Debug.Log("[DualSessionDeviceTest] " + stamped); + _log.Add(stamped); + } + } +} diff --git a/Assets/DualSessionDeviceTest/DualSessionDeviceTest.cs.meta b/Assets/DualSessionDeviceTest/DualSessionDeviceTest.cs.meta new file mode 100644 index 000000000..3dece0aaf --- /dev/null +++ b/Assets/DualSessionDeviceTest/DualSessionDeviceTest.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 57176fb8fbebb43b7b84396f0a24f0ba +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/DualSessionDeviceTest/Editor.meta b/Assets/DualSessionDeviceTest/Editor.meta new file mode 100644 index 000000000..2462d27ee --- /dev/null +++ b/Assets/DualSessionDeviceTest/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 304f55d6b459d40569e77e7d4266033c +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/DualSessionDeviceTest/Editor/BuildDualSessionDeviceTest.cs b/Assets/DualSessionDeviceTest/Editor/BuildDualSessionDeviceTest.cs new file mode 100644 index 000000000..948ab4803 --- /dev/null +++ b/Assets/DualSessionDeviceTest/Editor/BuildDualSessionDeviceTest.cs @@ -0,0 +1,71 @@ +using System; +using System.IO; +using Sequence.Config; +using UnityEditor; +using UnityEditor.Build.Reporting; +using UnityEditor.SceneManagement; +using UnityEngine; + +namespace Sequence.DeviceTest.Editor +{ + /// + /// Builds the DualSessionDeviceTest scene as an Android APK. + /// + /// Unity -batchmode -projectPath . -buildTarget Android -executeMethod Sequence.DeviceTest.Editor.BuildDualSessionDeviceTest.Android + /// + /// Changes the project it runs in, so run it in a copy: writes the keys below into Assets/Resources/SequenceConfig.asset, + /// turns on StoreSessionPrivateKeyInSecureStorage, switches signing to the debug keystore (ProjectSettings), and the SDK's + /// CheckUrlScheme build step adds the URL scheme to Assets/Plugins/Android/AndroidManifest.xml. + /// Android secure storage needs AndroidKeyBridge.java; in this repo the package's Samples symlink already includes it + /// (a game importing the package imports the "Android Secure Storage" sample instead). + /// Env: SEQ_WAAS_CONFIG_KEY, SEQ_BUILDER_KEY, SEQ_GOOGLE_CLIENT_ID, SEQ_URL_SCHEME (each optional, overrides the config), + /// DEVICE_TEST_APK (output path, default Builds/dual-session-test.apk). + /// + public static class BuildDualSessionDeviceTest + { + private const string ScenePath = "Assets/DualSessionDeviceTest/DualSessionDeviceTest.unity"; + + public static void Android() + { + var config = AssetDatabase.LoadAssetAtPath("Assets/Resources/SequenceConfig.asset"); + if (config == null) + { + Fail("Assets/Resources/SequenceConfig.asset not found"); + return; + } + config.WaaSConfigKey = Env("SEQ_WAAS_CONFIG_KEY") ?? config.WaaSConfigKey; + config.BuilderAPIKey = Env("SEQ_BUILDER_KEY") ?? config.BuilderAPIKey; + config.GoogleClientId = Env("SEQ_GOOGLE_CLIENT_ID") ?? config.GoogleClientId; + config.UrlScheme = Env("SEQ_URL_SCHEME") ?? config.UrlScheme; + config.StoreSessionPrivateKeyInSecureStorage = true; + EditorUtility.SetDirty(config); + AssetDatabase.SaveAssets(); + Debug.Log($"[DeviceTestBuild] UrlScheme='{config.UrlScheme}' GoogleClientId set={!string.IsNullOrEmpty(config.GoogleClientId)}"); + + var scene = EditorSceneManager.NewScene(NewSceneSetup.DefaultGameObjects, NewSceneMode.Single); + new GameObject("DualSessionDeviceTest").AddComponent(); + EditorSceneManager.SaveScene(scene, ScenePath); + + string output = Env("DEVICE_TEST_APK") ?? "Builds/dual-session-test.apk"; + Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(output))); + EditorUserBuildSettings.buildAppBundle = false; + // The project points at a release keystore that isn't in the repo; sign with Unity's debug key. + PlayerSettings.Android.useCustomKeystore = false; + BuildReport report = BuildPipeline.BuildPlayer(new[] { ScenePath }, output, BuildTarget.Android, BuildOptions.Development); + Debug.Log($"[DeviceTestBuild] result {report.summary.result}, errors {report.summary.totalErrors}, output {Path.GetFullPath(output)}"); + EditorApplication.Exit(report.summary.result == BuildResult.Succeeded ? 0 : 1); + } + + private static string Env(string name) + { + string value = Environment.GetEnvironmentVariable(name); + return string.IsNullOrEmpty(value) ? null : value; + } + + private static void Fail(string message) + { + Debug.LogError("[DeviceTestBuild] " + message); + EditorApplication.Exit(1); + } + } +} diff --git a/Assets/DualSessionDeviceTest/Editor/BuildDualSessionDeviceTest.cs.meta b/Assets/DualSessionDeviceTest/Editor/BuildDualSessionDeviceTest.cs.meta new file mode 100644 index 000000000..1c0964838 --- /dev/null +++ b/Assets/DualSessionDeviceTest/Editor/BuildDualSessionDeviceTest.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: c952ccfd8645d4d9fab1b24751515088 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Editor/DualSessionVerification.meta b/Assets/Editor/DualSessionVerification.meta new file mode 100644 index 000000000..7ff325662 --- /dev/null +++ b/Assets/Editor/DualSessionVerification.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: bd08e9b45819d43d6a412129e2ad8825 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Editor/DualSessionVerification/DualSessionVerification.cs b/Assets/Editor/DualSessionVerification/DualSessionVerification.cs new file mode 100644 index 000000000..2a2f757c3 --- /dev/null +++ b/Assets/Editor/DualSessionVerification/DualSessionVerification.cs @@ -0,0 +1,431 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Sequence.Adapter; +using Sequence.Authentication; +using Sequence.Config; +using Sequence.EmbeddedWallet; +using UnityEditor; +using UnityEngine; + +namespace Sequence.Editor.Verification +{ + /// + /// Batchmode harness for the guest + second-account flow (issue-tracker #7344 / #7348). + /// Each phase runs in its own editor process, so "restart" is a real process restart. + /// + /// Unity -batchmode -projectPath . -executeMethod Sequence.Editor.Verification.DualSessionVerification.Run -dualPhase setup + /// ... -dualPhase restart + /// ... -dualPhase final + /// ... -dualPhase social-routing (no network sign-in; social sign-in callback routing between two instances) + /// + /// Env: SEQ_WAAS_CONFIG_KEY, SEQ_BUILDER_KEY (written into Assets/Resources/SequenceConfig.asset, git-ignored), + /// DUAL_STATE_FILE (optional, where phase results are kept between processes; default + /// Logs/dual-session-state.json, OTP read from "$DUAL_STATE_FILE.otp"), DUAL_EMAIL (email phases). + /// Not run through the NUnit runner on purpose: SequenceConfig.StoreSessionKey() disables session storage while + /// a test is executing, and storage is what this harness checks. + /// + public static class DualSessionVerification + { + private const string UserNamespace = "user"; + private static readonly List Failures = new List(); + + [Serializable] + private class State + { + public string guestAddress; + public string guestSessionId; + public string userAddress; + public string userSessionId; + } + + public static void Run() + { + string phase = GetArg("-dualPhase") ?? "setup"; + Log($"phase={phase} unity={Application.unityVersion}"); + EnsureConfig(); + + Task task = phase switch + { + "setup" => Setup(), + "restart" => Restart(), + "final" => Final(), + "email-create" => EmailCreate(), + "email-merge" => EmailMerge(), + "social-routing" => SocialRouting(), + _ => throw new ArgumentException("unknown phase " + phase), + }; + + double deadline = EditorApplication.timeSinceStartup + 900; + EditorApplication.update += Wait; + + void Wait() + { + if (!task.IsCompleted && EditorApplication.timeSinceStartup < deadline) return; + EditorApplication.update -= Wait; + if (!task.IsCompleted) Failures.Add("timed out"); + if (task.IsFaulted) Failures.Add("exception: " + task.Exception?.GetBaseException()); + Log(Failures.Count == 0 ? "RESULT: ALL PASS" : "RESULT: FAIL\n " + string.Join("\n ", Failures)); + EditorApplication.Exit(Failures.Count == 0 ? 0 : 1); + } + } + + private static async Task Setup() + { + ClearStoredSessions(); + var adapter = EmbeddedWalletAdapter.GetInstance(); // subscribes to the static wallet events like an app would + + int staticWalletEvents = 0; + SequenceWallet.OnWalletCreated += _ => staticWalletEvents++; + + // 1. Guest on the default instance (today's integration). + SequenceLogin guestLogin = SequenceLogin.GetInstance(); + SequenceWallet guest = await LoginAsGuest(guestLogin); + Check(guest != null, "guest signed in on default instance"); + if (guest == null) return; + Check(staticWalletEvents == 1, $"default instance raised static OnWalletCreated once (got {staticWalletEvents})"); + Check(Same(adapter.WalletAddress, guest.GetWalletAddress()), "adapter tracks guest wallet"); + + // 2. Second account on an isolated instance while the guest stays signed in. + SequenceLogin userLogin = SequenceLogin.CreateIsolatedInstance(UserNamespace); + Check(userLogin.IsIsolated && !ReferenceEquals(userLogin, guestLogin), "isolated instance is separate from singleton"); + SequenceWallet user = await LoginAsGuest(userLogin); + Check(user != null, "second account signed in on isolated instance"); + if (user == null) return; + + Check(staticWalletEvents == 1, $"isolated instance did not raise static OnWalletCreated (count {staticWalletEvents})"); + Check(Same(adapter.WalletAddress, guest.GetWalletAddress()), "adapter still on guest after second sign-in"); + Check(!Same(guest.GetWalletAddress(), user.GetWalletAddress()), "contexts point at different wallets"); + Check(await HasSession(guest), "guest session still valid on backend after second sign-in"); + Check(await HasSession(user), "second session valid on backend"); + + // 3. Storage isolation. + Check(StoredAddress("").Equals(guest.GetWalletAddress(), StringComparison.OrdinalIgnoreCase), "default storage key holds guest session"); + Check(StoredAddress(UserNamespace).Equals(user.GetWalletAddress(), StringComparison.OrdinalIgnoreCase), "namespaced storage key holds second session"); + + // 4. Transfer from guest to the address returned by the authenticated session. + TransactionReturn tx = await guest.SendTransaction(Chain.TestnetArbitrumSepolia, + new Transaction[] { new RawTransaction(user.GetWalletAddress(), "0") }); + if (tx is SuccessfulTransactionReturn ok) + { + Log("tx " + ok.txHash); + Check(!string.IsNullOrEmpty(ok.txHash), "guest sent tx to second wallet"); + } + else + { + Check(false, "guest sent tx to second wallet: " + (tx as FailedTransactionReturn)?.error); + } + + SaveState(new State + { + guestAddress = guest.GetWalletAddress(), guestSessionId = guest.SessionId, + userAddress = user.GetWalletAddress(), userSessionId = user.SessionId, + }); + } + + private static async Task Restart() + { + State s = LoadState(); + var adapter = EmbeddedWalletAdapter.GetInstance(); + + // 5. Both contexts restore independently in a fresh process. + bool recovered = await adapter.TryRecoverWalletFromStorage(); + Check(recovered && Same(adapter.WalletAddress, s.guestAddress), "default instance restores guest after restart"); + var guest = adapter.Wallet as SequenceWallet; + + SequenceLogin userLogin = SequenceLogin.CreateIsolatedInstance(UserNamespace); + var restoredUser = await userLogin.TryToRestoreSessionAsync(); + var user = restoredUser.Wallet as SequenceWallet; + Check(user != null && Same(user.GetWalletAddress(), s.userAddress), "isolated instance restores second session after restart"); + Check(Same(adapter.WalletAddress, s.guestAddress), "isolated restore did not switch adapter"); + if (guest == null || user == null) return; + + // 6. Finish the switch: end guest, promote second session. + Check(await guest.DropThisSession(), "guest session closed"); + Check(await HasSession(user), "second session unaffected by guest sign-out"); + userLogin.PromoteToDefaultSession(user); + Check(Same(adapter.WalletAddress, s.userAddress), "adapter switched to promoted wallet"); + Check(StoredAddress("").Equals(s.userAddress, StringComparison.OrdinalIgnoreCase), "default storage key now holds promoted session"); + Check(string.IsNullOrEmpty(RawStored(UserNamespace)), "namespaced storage key cleared"); + } + + private static async Task Final() + { + State s = LoadState(); + var adapter = EmbeddedWalletAdapter.GetInstance(); + + // 7. After promotion the plain default flow restores the promoted account. + bool recovered = await adapter.TryRecoverWalletFromStorage(); + Check(recovered && Same(adapter.WalletAddress, s.userAddress), "default instance restores promoted account after restart"); + if (recovered) + { + Check(await adapter.SignOut(), "promoted account signed out"); + } + ClearStoredSessions(); + } + + [Serializable] + private class EmailState + { + public string emailWallet; + } + + private static string Email => Environment.GetEnvironmentVariable("DUAL_EMAIL"); + private static string EmailStateFile => StateFile + ".email"; + + // Existing account: sign this email in once so it owns a wallet. + private static async Task EmailCreate() + { + ClearStoredSessions(); + SequenceLogin login = SequenceLogin.GetInstance(); + var (wallet, error) = await LoginWithEmail(login, null); + Check(wallet != null, "email account created: " + error); + if (wallet == null) return; + Log("email wallet " + wallet.GetWalletAddress()); + File.WriteAllText(EmailStateFile, JsonUtility.ToJson(new EmailState { emailWallet = wallet.GetWalletAddress() })); + Check(await wallet.DropThisSession(), "email session closed"); + ClearStoredSessions(); + } + + // TapNation flow: guest live, authenticate the existing email account in an isolated instance, transfer, switch. + private static async Task EmailMerge() + { + var existing = JsonUtility.FromJson(File.ReadAllText(EmailStateFile)); + ClearStoredSessions(); + var adapter = EmbeddedWalletAdapter.GetInstance(); + int staticWalletEvents = 0; + SequenceWallet.OnWalletCreated += _ => staticWalletEvents++; + + SequenceWallet guest = await LoginAsGuest(SequenceLogin.GetInstance()); + Check(guest != null, "guest signed in on default instance"); + if (guest == null) return; + Log("guest wallet " + guest.GetWalletAddress()); + + SequenceLogin userLogin = SequenceLogin.CreateIsolatedInstance(UserNamespace); + var (user, error) = await LoginWithEmail(userLogin, "000000"); + Check(user != null, "existing email account authenticated after a wrong OTP: " + error); + if (user == null) return; + + Check(Same(user.GetWalletAddress(), existing.emailWallet), $"authentication returned the existing wallet {existing.emailWallet} (got {user.GetWalletAddress()})"); + Check(!Same(user.GetWalletAddress(), guest.GetWalletAddress()), "existing wallet differs from guest wallet"); + Check(staticWalletEvents == 1, $"isolated email login did not raise static OnWalletCreated (count {staticWalletEvents})"); + Check(Same(adapter.WalletAddress, guest.GetWalletAddress()), "adapter still on guest after email login"); + Check(await HasSession(guest), "guest session valid after email login"); + Check(StoredAddress("").Equals(guest.GetWalletAddress(), StringComparison.OrdinalIgnoreCase), "default storage key still holds guest"); + Check(StoredAddress(UserNamespace).Equals(existing.emailWallet, StringComparison.OrdinalIgnoreCase), "namespaced key holds existing email wallet"); + + TransactionReturn tx = await guest.SendTransaction(Chain.TestnetArbitrumSepolia, + new Transaction[] { new RawTransaction(user.GetWalletAddress(), "0") }); + var ok = tx as SuccessfulTransactionReturn; + Log("tx " + ok?.txHash + (tx as FailedTransactionReturn)?.error); + Check(ok != null && !string.IsNullOrEmpty(ok.txHash), "guest sent tx to existing email wallet"); + if (ok == null) return; + + Check(await guest.DropThisSession(), "guest session closed after confirmed transfer"); + userLogin.PromoteToDefaultSession(user); + Check(Same(adapter.WalletAddress, existing.emailWallet), "adapter switched to existing email wallet"); + Check(await HasSession(user), "email session valid after switch"); + Check(await adapter.SignOut(), "email account signed out"); + ClearStoredSessions(); + } + + // Sends the OTP, optionally submits a wrong code first (must fail without side effects), then the real code + // read from $DUAL_STATE_FILE.otp. + private static async Task<(SequenceWallet, string)> LoginWithEmail(SequenceLogin login, string wrongCodeFirst) + { + SequenceWallet created = null; + string lastError = ""; + int failures = 0; + void Created(SequenceWallet w) => created = w; + void Failed(string error, LoginMethod m, string email, List methods) { failures++; lastError = error; Log("login failed: " + error); } + void SendFailed(string email, string error) { lastError = error; Log("otp send failed: " + error); } + login.OnSessionWalletCreated += Created; + login.OnLoginFailed += Failed; + login.OnMFAEmailFailedToSend += SendFailed; + try + { + string otpFile = StateFile + ".otp"; + File.Delete(otpFile); + await login.Login(Email); + Log("WAITING_FOR_OTP " + Email); + + if (wrongCodeFirst != null) + { + await login.Login(Email, wrongCodeFirst); + Check(created == null && failures == 1, $"wrong OTP rejected (failures {failures})"); + } + + double until = EditorApplication.timeSinceStartup + 600; + while (!File.Exists(otpFile) && EditorApplication.timeSinceStartup < until) await Task.Delay(1000); + if (!File.Exists(otpFile)) return (null, "no OTP provided"); + string code = File.ReadAllText(otpFile).Trim(); + File.Delete(otpFile); + + await login.Login(Email, code); + double wait = EditorApplication.timeSinceStartup + 30; + while (created == null && EditorApplication.timeSinceStartup < wait) await Task.Delay(200); + return (created, lastError); + } + finally + { + login.OnSessionWalletCreated -= Created; + login.OnLoginFailed -= Failed; + login.OnMFAEmailFailedToSend -= SendFailed; + } + } + + private static async Task LoginAsGuest(SequenceLogin login) + { + var tcs = new TaskCompletionSource(); + void Created(SequenceWallet w) => tcs.TrySetResult(w); + void Failed(string error, LoginMethod m, string email, List methods) + { + Log("login failed: " + error); + tcs.TrySetResult(null); + } + login.OnSessionWalletCreated += Created; + login.OnLoginFailed += Failed; + await login.ConnectToWaaSAsGuest(); + SequenceWallet result = await Task.WhenAny(tcs.Task, Task.Delay(30000)) == tcs.Task ? tcs.Task.Result : null; + login.OnSessionWalletCreated -= Created; + login.OnLoginFailed -= Failed; + return result; + } + + // Social sign-in on the isolated instance while the default instance is alive. The callback deep link is + // delivered through Unity's own dispatcher (Application.InvokeDeepLinkActivated), the path a deep link takes on + // Android and desktop, so every subscribed instance receives it. Only the instance that started the sign-in + // should react; the other must not report a login failure. + private static Task SocialRouting() + { + SequenceLogin defaultLogin = SequenceLogin.GetInstance(); + SequenceLogin isolated = SequenceLogin.CreateIsolatedInstance(UserNamespace); + OpenIdAuthenticator defaultAuth = AuthenticatorOf(defaultLogin); + OpenIdAuthenticator isolatedAuth = AuthenticatorOf(isolated); + Check(defaultAuth != null && isolatedAuth != null && defaultAuth != isolatedAuth, + "each instance has its own OpenIdAuthenticator"); + + // What GoogleSignIn() does before opening the browser: issue a fresh state token on the starting instance. + string defaultToken = Guid.NewGuid().ToString(); + string isolatedToken = Guid.NewGuid().ToString(); + defaultAuth.InjectStateTokenForTesting(defaultToken); + isolatedAuth.InjectStateTokenForTesting(isolatedToken); + + var defaultFailures = new List(); + int defaultSignedIn = 0, isolatedSignedIn = 0; + void DefaultFailed(string error, LoginMethod m, string email, List methods) => defaultFailures.Add(error); + void DefaultSignedIn(OpenIdAuthenticationResult r) => defaultSignedIn++; + void IsolatedSignedIn(OpenIdAuthenticationResult r) => isolatedSignedIn++; + defaultLogin.OnLoginFailed += DefaultFailed; + defaultAuth.SignedIn += DefaultSignedIn; + isolatedAuth.SignedIn += IsolatedSignedIn; + void Reset() { defaultFailures.Clear(); defaultSignedIn = 0; isolatedSignedIn = 0; } + + // Fake id_token: the receiving instance then fails at WaaS with it, which is irrelevant here. + string isolatedLink = $"testscheme://callback?state=testscheme---{isolatedToken}Google&id_token=not.a.jwt"; + const System.Reflection.BindingFlags anyStatic = + System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Public; + + // 1. Broadcast delivery (Application.deepLinkActivated: Android, macOS, WebGL). + var dispatch = typeof(Application).GetMethod("InvokeDeepLinkActivated", anyStatic); + Check(dispatch != null, "found Unity's deep link dispatcher Application.InvokeDeepLinkActivated"); + dispatch?.Invoke(null, new object[] { isolatedLink }); + Check(isolatedSignedIn == 1, $"broadcast: isolated instance (started the sign-in) received the id token (count {isolatedSignedIn})"); + Check(defaultSignedIn == 0, $"broadcast: default instance did not receive the id token (count {defaultSignedIn})"); + Check(defaultFailures.Count == 0, + $"broadcast: default instance raised no login failure (got {defaultFailures.Count}: {string.Join(" | ", defaultFailures)})"); + + // 2. Single-receiver delivery (Windows IPC listener, iOS authentication session): the callback reaches + // one authenticator only, here the default one (Windows starts the listener on the first authenticator). + Reset(); + var route = typeof(OpenIdAuthenticator).GetMethod("RouteDeepLink", anyStatic); + if (route != null) route.Invoke(null, new object[] { isolatedLink, defaultAuth }); + else defaultAuth.HandleDeepLink(isolatedLink); // what the listener did before routing existed + Check(isolatedSignedIn == 1, $"single receiver: isolated instance received the id token (count {isolatedSignedIn})"); + Check(defaultFailures.Count == 0, + $"single receiver: default instance raised no login failure (got {defaultFailures.Count}: {string.Join(" | ", defaultFailures)})"); + + // 3. A link no instance issued still fails as before. + Reset(); + dispatch?.Invoke(null, new object[] { $"testscheme://callback?state=testscheme---{Guid.NewGuid()}Google&id_token=not.a.jwt" }); + Check(defaultSignedIn == 0 && isolatedSignedIn == 0, "unknown state: no instance accepted the link"); + Check(defaultFailures.Count == 1 && defaultFailures[0].Contains("State token mismatch"), + $"unknown state: default instance still reports the mismatch (got {defaultFailures.Count}: {string.Join(" | ", defaultFailures)})"); + + defaultLogin.OnLoginFailed -= DefaultFailed; + defaultAuth.SignedIn -= DefaultSignedIn; + isolatedAuth.SignedIn -= IsolatedSignedIn; + return Task.CompletedTask; + } + + private static OpenIdAuthenticator AuthenticatorOf(SequenceLogin login) => + typeof(SequenceLogin).GetField("_authenticator", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic) + ?.GetValue(login) as OpenIdAuthenticator; + + private static async Task HasSession(SequenceWallet wallet) + { + Session[] sessions = await wallet.ListSessions(); + return sessions != null && sessions.Any(x => x.id == wallet.SessionId); + } + + private static string StorageKey(string ns) => + Application.companyName + "-" + Application.productName + "-" + (string.IsNullOrEmpty(ns) ? "" : ns + "-") + "SessionWallet"; + + // Editor secure storage is PlayerPrefs (EditorSecureStorage). + private static string RawStored(string ns) => PlayerPrefs.GetString(StorageKey(ns)); + + private static string StoredAddress(string ns) + { + string[] parts = RawStored(ns).Split('-'); + return parts.Length >= 2 ? parts[1] : ""; + } + + private static void ClearStoredSessions() + { + PlayerPrefs.DeleteKey(StorageKey("")); + PlayerPrefs.DeleteKey(StorageKey(UserNamespace)); + PlayerPrefs.Save(); + } + + private static void EnsureConfig() + { + const string path = "Assets/Resources/SequenceConfig.asset"; + var config = AssetDatabase.LoadAssetAtPath(path); + if (config == null) + { + Directory.CreateDirectory("Assets/Resources"); + config = ScriptableObject.CreateInstance(); + AssetDatabase.CreateAsset(config, path); + } + config.WaaSConfigKey = Environment.GetEnvironmentVariable("SEQ_WAAS_CONFIG_KEY") ?? config.WaaSConfigKey; + config.BuilderAPIKey = Environment.GetEnvironmentVariable("SEQ_BUILDER_KEY") ?? config.BuilderAPIKey; + config.EditorStoreSessionPrivateKeyInSecureStorage = true; + EditorUtility.SetDirty(config); + AssetDatabase.SaveAssets(); + } + + // Not under Temp/: the editor deletes Temp/ on exit, and the state must survive between phase processes. + private static string StateFile => Environment.GetEnvironmentVariable("DUAL_STATE_FILE") ?? "Logs/dual-session-state.json"; + private static void SaveState(State s) => File.WriteAllText(StateFile, JsonUtility.ToJson(s)); + private static State LoadState() => JsonUtility.FromJson(File.ReadAllText(StateFile)); + + private static bool Same(string a, string b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase); + + private static void Check(bool condition, string message) + { + Log((condition ? "PASS " : "FAIL ") + message); + if (!condition) Failures.Add(message); + } + + private static void Log(string message) => Debug.Log("[DualSession] " + message); + + private static string GetArg(string name) + { + string[] args = Environment.GetCommandLineArgs(); + int i = Array.IndexOf(args, name); + return i >= 0 && i + 1 < args.Length ? args[i + 1] : null; + } + } +} diff --git a/Assets/Editor/DualSessionVerification/DualSessionVerification.cs.meta b/Assets/Editor/DualSessionVerification/DualSessionVerification.cs.meta new file mode 100644 index 000000000..7de94e298 --- /dev/null +++ b/Assets/Editor/DualSessionVerification/DualSessionVerification.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: bbb61b6693a0b4fcfb1bfe2003ab7782 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/SequenceSDK/Authentication/Tests/DeepLinkHandlerTests.cs b/Assets/SequenceSDK/Authentication/Tests/DeepLinkHandlerTests.cs index 87a36a36b..0f9d9a73f 100644 --- a/Assets/SequenceSDK/Authentication/Tests/DeepLinkHandlerTests.cs +++ b/Assets/SequenceSDK/Authentication/Tests/DeepLinkHandlerTests.cs @@ -41,7 +41,9 @@ public void TestNoStateToken() public void TestStateTokenMismatch() { OpenIdAuthenticator authenticator = new OpenIdAuthenticator(""); - string url = "https://sequence.app?code=123456&state=123456"; + // A state no authenticator issued. Other tests issue "123456", and a state issued by another + // authenticator is left to that authenticator (see TestStateIssuedByOtherAuthenticator_isIgnored). + string url = $"https://sequence.app?code=123456&state={System.Guid.NewGuid()}"; bool signInFailedEventReceived = false; authenticator.OnSignInFailed += (s, method) => { @@ -99,5 +101,62 @@ public void TestValidDeepLink_withTrailingSlash() authenticator.HandleDeepLink(url); Assert.IsTrue(eventReceived); } + + // Two authenticators exist when there is more than one SequenceLogin (SequenceLogin.CreateIsolatedInstance). + [Test] + public void TestStateIssuedByOtherAuthenticator_isIgnored() + { + OpenIdAuthenticator starter = new OpenIdAuthenticator(""); + OpenIdAuthenticator other = new OpenIdAuthenticator(""); + string token = System.Guid.NewGuid().ToString(); + starter.InjectStateTokenForTesting(token); + other.InjectStateTokenForTesting(System.Guid.NewGuid().ToString()); + string url = $"https://sequence.app?state={token}&id_token=654321"; + int starterSignedIn = 0, otherSignedIn = 0, otherFailed = 0; + starter.SignedIn += _ => starterSignedIn++; + other.SignedIn += _ => otherSignedIn++; + other.OnSignInFailed += (s, m) => otherFailed++; + + // Application.deepLinkActivated delivers the link to every authenticator, in either order. + other.HandleDeepLink(url); + starter.HandleDeepLink(url); + + Assert.AreEqual(1, starterSignedIn); + Assert.AreEqual(0, otherSignedIn); + Assert.AreEqual(0, otherFailed); + } + + [Test] + public void TestRouteDeepLink_deliversToIssuer() + { + OpenIdAuthenticator receiver = new OpenIdAuthenticator(""); + OpenIdAuthenticator issuer = new OpenIdAuthenticator(""); + string token = System.Guid.NewGuid().ToString(); + receiver.InjectStateTokenForTesting(System.Guid.NewGuid().ToString()); + issuer.InjectStateTokenForTesting(token); + int issuerSignedIn = 0, receiverSignedIn = 0, receiverFailed = 0; + issuer.SignedIn += _ => issuerSignedIn++; + receiver.SignedIn += _ => receiverSignedIn++; + receiver.OnSignInFailed += (s, m) => receiverFailed++; + + // Windows IPC and the iOS authentication session hand the link to one authenticator only. + OpenIdAuthenticator.RouteDeepLink($"https://sequence.app?state={token}&id_token=654321", receiver); + + Assert.AreEqual(1, issuerSignedIn); + Assert.AreEqual(0, receiverSignedIn); + Assert.AreEqual(0, receiverFailed); + } + + [Test] + public void TestRouteDeepLink_unknownState_goesToFallback() + { + OpenIdAuthenticator receiver = new OpenIdAuthenticator(""); + string error = null; + receiver.OnSignInFailed += (s, m) => error = s; + + OpenIdAuthenticator.RouteDeepLink($"https://sequence.app?state={System.Guid.NewGuid()}&id_token=654321", receiver); + + Assert.AreEqual("State token mismatch", error); + } } -} \ No newline at end of file +} diff --git a/Packages/Sequence-Unity/Sequence/SequenceSDK/Authentication/IosBrowser.cs b/Packages/Sequence-Unity/Sequence/SequenceSDK/Authentication/IosBrowser.cs index b1b762c7a..42d6d73c4 100644 --- a/Packages/Sequence-Unity/Sequence/SequenceSDK/Authentication/IosBrowser.cs +++ b/Packages/Sequence-Unity/Sequence/SequenceSDK/Authentication/IosBrowser.cs @@ -7,8 +7,10 @@ namespace Sequence.Authentication public class IosBrowser : IBrowser { #if UNITY_IOS && !UNITY_EDITOR - private IAuthenticator _authenticator; - private static IosBrowser _instance; + private OpenIdAuthenticator _authenticator; + // Browser that started the current authentication session; the native completion callback is static. + // One browser per authenticator, so a second SequenceLogin (CreateIsolatedInstance) doesn't take over the first one's callbacks. + private static IosBrowser _activeSession; private IosBrowser(OpenIdAuthenticator authenticator) { @@ -22,13 +24,7 @@ public static IosBrowser Setup(OpenIdAuthenticator authenticator) throw new ArgumentNullException(nameof(authenticator)); } - if (_instance == null) - { - _instance = new IosBrowser(authenticator); - return _instance; - } - _instance._authenticator = authenticator; - return _instance; + return new IosBrowser(authenticator); } public void Authenticate(string url, string redirectUrl = "") @@ -38,6 +34,7 @@ public void Authenticate(string url, string redirectUrl = "") throw new ArgumentNullException(nameof(url)); } + _activeSession = this; IntPtr sessionPointer = Auth_ASWebAuthenticationSession_InitWithURL(url, redirectUrl, OnAuthenticationSessionCompleted); Auth_ASWebAuthenticationSession_SetPrefersEphemeralWebBrowserSession(sessionPointer, 0); @@ -83,7 +80,7 @@ private void mOnAuthenticationSessionCompleted(IntPtr session, string callbackUr } else { - _authenticator.HandleDeepLink(callbackUrl); + OpenIdAuthenticator.RouteDeepLink(callbackUrl, _authenticator); } } @@ -91,7 +88,7 @@ private void mOnAuthenticationSessionCompleted(IntPtr session, string callbackUr private static void OnAuthenticationSessionCompleted(IntPtr session, string callbackUrl, int errorCode, string errorMessage) { - _instance.mOnAuthenticationSessionCompleted(session, callbackUrl, errorCode, errorMessage); + _activeSession?.mOnAuthenticationSessionCompleted(session, callbackUrl, errorCode, errorMessage); } public void SetState(string state) diff --git a/Packages/Sequence-Unity/Sequence/SequenceSDK/Authentication/OpenIdAuthenticator.cs b/Packages/Sequence-Unity/Sequence/SequenceSDK/Authentication/OpenIdAuthenticator.cs index 7b5acc2f2..ef6d5543f 100644 --- a/Packages/Sequence-Unity/Sequence/SequenceSDK/Authentication/OpenIdAuthenticator.cs +++ b/Packages/Sequence-Unity/Sequence/SequenceSDK/Authentication/OpenIdAuthenticator.cs @@ -28,6 +28,10 @@ public class OpenIdAuthenticator : IAuthenticator private string _stateToken = Guid.NewGuid().ToString(); + // Latest state token issued by each authenticator. Several authenticators exist when there is more than one + // SequenceLogin (SequenceLogin.CreateIsolatedInstance); sign-in callbacks are routed to the one that issued the state. + private static readonly Dictionary IssuedStateTokens = new Dictionary(); + private IBrowser _browser; private static bool _windowsSetup = false; @@ -208,7 +212,7 @@ public string GetRedirectUrl() private string GenerateState(LoginMethod method) { - _stateToken = Guid.NewGuid().ToString(); + SetStateToken(Guid.NewGuid().ToString()); string state = $"{_urlScheme + "---" + _stateToken + method}"; return state; } @@ -353,7 +357,7 @@ Do not increase the attack surface without careful thought. { syncContext.Post((data) => { - HandleDeepLink((string)data); + RouteDeepLink((string)data, this); }, msg); } } @@ -383,6 +387,12 @@ public void HandleDeepLink(string link) method = GetMethodFromState(state); if (!state.Contains(_stateToken)) { + OpenIdAuthenticator issuer = FindStateIssuer(state); + if (issuer != null && issuer != this) + { + // Started by another SequenceLogin instance, which receives the same link. + return; + } OnSignInFailed?.Invoke("State token mismatch", method); return; } @@ -432,12 +442,62 @@ internal LoginMethod GetMethodFromState(string state) #if UNITY_EDITOR public void InjectStateTokenForTesting(string stateToken) { - _stateToken = stateToken; + SetStateToken(stateToken); } #endif internal void BrowserModifyStateToken(string stateToken) { - _stateToken = stateToken; + SetStateToken(stateToken); + } + + private void SetStateToken(string stateToken) + { + lock (IssuedStateTokens) + { + if (IssuedStateTokens.TryGetValue(_stateToken, out OpenIdAuthenticator previous) && previous == this) + { + IssuedStateTokens.Remove(_stateToken); + } + _stateToken = stateToken; + if (!string.IsNullOrEmpty(stateToken)) + { + IssuedStateTokens[stateToken] = this; + } + } + } + + private static OpenIdAuthenticator FindStateIssuer(string state) + { + if (string.IsNullOrEmpty(state)) + { + return null; + } + lock (IssuedStateTokens) + { + foreach (var issued in IssuedStateTokens) + { + if (state.Contains(issued.Key)) + { + return issued.Value; + } + } + } + return null; + } + + /// + /// For callbacks delivered to a single authenticator (Windows IPC, iOS authentication session): hand the link + /// to the authenticator that issued its state, or to if none did. + /// + public static void RouteDeepLink(string link, OpenIdAuthenticator fallback) + { + OpenIdAuthenticator issuer = null; + Dictionary queryParams = link?.RemoveTrailingSlash().ExtractQueryAndHashParameters(); + if (queryParams != null && queryParams.TryGetValue("state", out string state)) + { + issuer = FindStateIssuer(state); + } + (issuer ?? fallback).HandleDeepLink(link); } } } \ No newline at end of file diff --git a/Packages/Sequence-Unity/Sequence/SequenceSDK/EmbeddedWallet/SequenceLogin.cs b/Packages/Sequence-Unity/Sequence/SequenceSDK/EmbeddedWallet/SequenceLogin.cs index edba427a8..d9c81b2dc 100644 --- a/Packages/Sequence-Unity/Sequence/SequenceSDK/EmbeddedWallet/SequenceLogin.cs +++ b/Packages/Sequence-Unity/Sequence/SequenceSDK/EmbeddedWallet/SequenceLogin.cs @@ -41,6 +41,28 @@ public class SequenceLogin : ILogin, IWaaSConnector private static SequenceLogin _instance; + // Empty for the default (singleton) instance. Isolated instances persist their session under their own key + // and don't raise the static SequenceWallet events, so they can run alongside the default instance. + private readonly string _storageNamespace = ""; + private LoginMethod _lastLoginMethod = LoginMethod.None; + + /// + /// True for instances created with . + /// + public bool IsIsolated => !string.IsNullOrEmpty(_storageNamespace); + + /// + /// Raised when this instance creates or restores a wallet session. Raised for both default and isolated + /// instances. The default instance also raises . + /// + public event Action OnSessionWalletCreated; + + /// + /// Raised when this instance fails to restore a stored session. The default instance also raises + /// . + /// + public event Action OnSessionRecoveryFailed; + private string _verifierToReject; // Since email auth is two separate requests, an invalid signature error may go unnoticed. So, we cache the verifier used in an initiateAuth attempt that had an invalid signature and reject the corresponding openSession attempt if it uses the same verifier. public static SequenceLogin GetInstance(IValidator validator = null, IAuthenticator authenticator = null, @@ -89,9 +111,45 @@ public void RemoveConnectedWalletAddress() _connectedWalletAddress = null; } + /// + /// Create a login instance that is independent of . Use it to hold a second wallet + /// session (for example, an existing email account) while the default instance keeps the current guest session. + /// The instance stores its session under its own namespace, does not raise the static + /// / events + /// (use / ), does not federate + /// accounts automatically and does not touch the PlayerPrefs used for the last login method. + /// Call to make its wallet the app's active session. + /// + /// Non-empty name unique to this instance, e.g. "user". + public static SequenceLogin CreateIsolatedInstance(string storageNamespace, IValidator validator = null, + IAuthenticator authenticator = null) + { + if (string.IsNullOrWhiteSpace(storageNamespace)) + { + throw new ArgumentException("An isolated instance needs a non-empty storage namespace.", nameof(storageNamespace)); + } + + var login = new SequenceLogin(validator, authenticator, null, false, null, storageNamespace); + if (login._storeSessionWallet) + { + // The constructor only configures endpoints when session storage is enabled; GetInstance() completes + // the setup for the default instance the same way. + login.SetupAuthenticator(validator, authenticator); + } + return login; + } + [Obsolete("Use GetInstance() instead.")] public SequenceLogin(IValidator validator = null, IAuthenticator authenticator = null, IWaaSConnector connector = null, bool automaticallyFederateAccountsWhenPossible = true, Address connectedWalletAddress = null) + : this(validator, authenticator, connector, automaticallyFederateAccountsWhenPossible, connectedWalletAddress, "") { + } + + private SequenceLogin(IValidator validator, IAuthenticator authenticator, IWaaSConnector connector, + bool automaticallyFederateAccountsWhenPossible, Address connectedWalletAddress, string storageNamespace) + { + _storageNamespace = storageNamespace ?? ""; + if (connector == null) { connector = this; @@ -191,8 +249,8 @@ public void SetupAuthenticator(IValidator validator = null, IAuthenticator authe var done = false; SequenceWallet wallet = null; - SequenceWallet.OnFailedToRecoverSession += HandleFailedToRecover; - SequenceWallet.OnWalletCreated += HandleRecoveredWallet; + OnSessionRecoveryFailed += HandleFailedToRecover; + OnSessionWalletCreated += HandleRecoveredWallet; TryToRestoreSession(); SetupAuthenticator(); @@ -201,14 +259,14 @@ public void SetupAuthenticator(IValidator validator = null, IAuthenticator authe await Task.Yield(); return (true, wallet); - + void HandleRecoveredWallet(SequenceWallet newWallet) { wallet = newWallet; done = true; - - SequenceWallet.OnFailedToRecoverSession -= HandleFailedToRecover; - SequenceWallet.OnWalletCreated -= HandleRecoveredWallet; + + OnSessionRecoveryFailed -= HandleFailedToRecover; + OnSessionWalletCreated -= HandleRecoveredWallet; } void HandleFailedToRecover(string error) @@ -299,6 +357,7 @@ private void TryToLoginWithStoredSessionWallet() _sessionId = IntentDataOpenSession.CreateSessionId(_sessionWallet.GetAddress()); SequenceWallet wallet = new SequenceWallet(new Address(walletInfo.Item2), _sessionId, new IntentSender(new HttpClient(WaaSWithAuthUrl), walletInfo.Item1, _sessionId, _waasProjectId, _waasVersion), walletInfo.Item3); + wallet.AttachSession(this, walletInfo.Item1); EnsureSessionIsValid(wallet); } @@ -306,9 +365,47 @@ private void TryToLoginWithStoredSessionWallet() private void FailedLoginWithStoredSessionWallet(string error) { CreateWallet(); - SequenceWallet.OnFailedToRecoverSession?.Invoke(error); + NotifyRecoveryFailed(error); + } + + private void NotifyWalletCreated(SequenceWallet wallet) + { + OnSessionWalletCreated?.Invoke(wallet); + if (!IsIsolated) + { + SequenceWallet.OnWalletCreated?.Invoke(wallet); + } + } + + private void NotifyRecoveryFailed(string error) + { + OnSessionRecoveryFailed?.Invoke(error); + if (!IsIsolated) + { + SequenceWallet.OnFailedToRecoverSession?.Invoke(error); + } + } + + private void PersistLastLoginMethod(LoginMethod method, string email) + { + _lastLoginMethod = method; + if (IsIsolated) + { + return; + } + PlayerPrefs.SetInt(WaaSLoginMethod, (int)method); + PlayerPrefs.SetString(OpenIdAuthenticator.LoginEmail, email); + PlayerPrefs.Save(); } + private static string StorageKeyFor(string storageNamespace) + { + string prefix = Application.companyName + "-" + Application.productName + "-"; + return string.IsNullOrEmpty(storageNamespace) ? prefix + _walletKey : prefix + storageNamespace + "-" + _walletKey; + } + + private string SessionStorageKey => StorageKeyFor(_storageNamespace); + private async Task EnsureSessionIsValid(SequenceWallet wallet) { Session[] activeSessions = await wallet.ListSessions(); @@ -324,7 +421,7 @@ private async Task EnsureSessionIsValid(SequenceWallet wallet) { if (activeSessions[i].id == expectedSessionId) { - SequenceWallet.OnWalletCreated?.Invoke(wallet); + NotifyWalletCreated(wallet); return; } } @@ -335,7 +432,7 @@ private async Task EnsureSessionIsValid(SequenceWallet wallet) private (EOAWallet, string, string) AttemptToCreateWalletFromSecureStorage() { ISecureStorage secureStorage = SecureStorageFactory.CreateSecureStorage(); - string walletInfo = secureStorage.RetrieveString(Application.companyName + "-" + Application.productName + "-" + _walletKey); + string walletInfo = secureStorage.RetrieveString(SessionStorageKey); if (string.IsNullOrEmpty(walletInfo)) { return (null, "", ""); @@ -444,9 +541,8 @@ public async Task ConnectToWaaS(IntentDataOpenSession loginIntent, LoginMethod m walletAddress = registerSessionResponse.wallet; OnLoginSuccess?.Invoke(sessionId, walletAddress); SequenceWallet wallet = new SequenceWallet(new Address(walletAddress), sessionId, new IntentSender(new HttpClient(SequenceLogin.WaaSWithAuthUrl), _sessionWallet, sessionId, _waasProjectId, _waasVersion), email); - PlayerPrefs.SetInt(WaaSLoginMethod, (int)method); - PlayerPrefs.SetString(OpenIdAuthenticator.LoginEmail, email); - PlayerPrefs.Save(); + wallet.AttachSession(this, _sessionWallet); + PersistLastLoginMethod(method, email); _isLoggingIn = false; wallet.OnDropSessionComplete += session => { @@ -455,7 +551,7 @@ public async Task ConnectToWaaS(IntentDataOpenSession loginIntent, LoginMethod m _connectedWalletAddress = null; } }; - SequenceWallet.OnWalletCreated?.Invoke(wallet); + NotifyWalletCreated(wallet); } catch (Exception e) { @@ -639,10 +735,8 @@ private async Task ForceCreateWaaSAccount() _failedLoginIntent.forceCreateAccount = true; await ConnectToWaaS(_failedLoginIntent, _failedLoginMethod, _failedLoginEmail); - - PlayerPrefs.SetInt(WaaSLoginMethod, (int)_failedLoginMethod); - PlayerPrefs.SetString(OpenIdAuthenticator.LoginEmail, _failedLoginEmail); - PlayerPrefs.Save(); + + PersistLastLoginMethod(_failedLoginMethod, _failedLoginEmail); } public async Task ConnectToWaaSViaPlayFab(string titleId, string sessionTicket, string email) @@ -671,12 +765,70 @@ public async Task ConnectToWaaSAsGuest() internal void StoreWalletSecurely(string waasWalletAddress, string email) { - if (!_storeSessionWallet || !SecureStorageFactory.IsSupportedPlatform()) return; + StoreSessionSecurely(_sessionWallet, waasWalletAddress, email); + } + + /// + /// Persist the given session key under this instance's storage namespace. + /// + internal void StoreSessionSecurely(EOAWallet sessionKey, string waasWalletAddress, string email) + { + if (!_storeSessionWallet || !SecureStorageFactory.IsSupportedPlatform() || sessionKey == null) return; + WriteSession(SessionStorageKey, sessionKey, waasWalletAddress, email); + } + + private static void WriteSession(string storageKey, EOAWallet sessionKey, string waasWalletAddress, string email) + { ISecureStorage secureStorage = SecureStorageFactory.CreateSecureStorage(); byte[] privateKeyBytes = new byte[32]; - _sessionWallet.privKey.WriteToSpan(privateKeyBytes); + sessionKey.privKey.WriteToSpan(privateKeyBytes); string privateKey = privateKeyBytes.ByteArrayToHexString(); - secureStorage.StoreString(Application.companyName + "-" + Application.productName + "-" + _walletKey, privateKey + "-" + waasWalletAddress + "-" + email); + secureStorage.StoreString(storageKey, privateKey + "-" + waasWalletAddress + "-" + email); + } + + /// + /// Remove the session stored under this instance's namespace. The backend session is not closed; use + /// for that. + /// + public void ClearStoredSession() + { + if (!SecureStorageFactory.IsSupportedPlatform()) return; + SecureStorageFactory.CreateSecureStorage().StoreString(SessionStorageKey, ""); + } + + /// + /// Make a wallet created by this isolated instance the app's active session: it is stored under the default + /// session key (replacing whatever is stored there), removed from this instance's namespace, and announced via + /// so EmbeddedWalletAdapter and + /// switch to it. Close the previous default session (e.g. the guest) yourself, + /// after any transfers from it have been confirmed. + /// + public void PromoteToDefaultSession(SequenceWallet wallet) + { + if (!IsIsolated) + { + throw new InvalidOperationException("Only isolated instances can promote a session."); + } + if (wallet == null) + { + throw new ArgumentNullException(nameof(wallet)); + } + if (!ReferenceEquals(wallet.Owner, this) || wallet.SessionKey == null) + { + throw new ArgumentException("The wallet was not created by this login instance.", nameof(wallet)); + } + + SequenceLogin defaultLogin = _instance ?? GetInstance(); + if (_storeSessionWallet && SecureStorageFactory.IsSupportedPlatform()) + { + WriteSession(StorageKeyFor(""), wallet.SessionKey, wallet.GetWalletAddress(), wallet.Email); + ClearStoredSession(); + } + + defaultLogin.RemoveConnectedWalletAddress(); + defaultLogin.PersistLastLoginMethod(_lastLoginMethod, wallet.Email); + wallet.AttachSession(defaultLogin, wallet.SessionKey); + SequenceWallet.OnWalletCreated?.Invoke(wallet); } public async Task FederateAccount(IntentDataFederateAccount federateAccount, LoginMethod method, string email) @@ -692,9 +844,7 @@ public async Task FederateAccount(IntentDataFederateAccount federateAccount, Log throw new Exception($"Email received from WaaS server doesn't match, received {responseEmail}, sent {email}"); } - PlayerPrefs.SetInt(WaaSLoginMethod, (int)method); - PlayerPrefs.SetString(OpenIdAuthenticator.LoginEmail, email); - PlayerPrefs.Save(); + PersistLastLoginMethod(method, email); _failedLoginEmail = ""; _failedLoginIntent = null; _failedLoginMethod = LoginMethod.None; diff --git a/Packages/Sequence-Unity/Sequence/SequenceSDK/EmbeddedWallet/SequenceWallet.cs b/Packages/Sequence-Unity/Sequence/SequenceSDK/EmbeddedWallet/SequenceWallet.cs index edb7c05ca..d42d28127 100644 --- a/Packages/Sequence-Unity/Sequence/SequenceSDK/EmbeddedWallet/SequenceWallet.cs +++ b/Packages/Sequence-Unity/Sequence/SequenceSDK/EmbeddedWallet/SequenceWallet.cs @@ -43,11 +43,37 @@ public SequenceWallet(Address address, string sessionId, IIntentSender intentSen { _email = account.email; - SequenceLogin.GetInstance().StoreWalletSecurely(_address, _email); + if (Owner != null) + { + // Store this wallet's own session key under its login's namespace. + Owner.StoreSessionSecurely(SessionKey, _address, _email); + } + else + { + SequenceLogin.GetInstance().StoreWalletSecurely(_address, _email); + } } }; } + /// + /// The login instance that created (or adopted) this wallet session, if known. + /// + internal SequenceLogin Owner { get; private set; } + + /// + /// The session signing key backing this wallet, if known. + /// + internal Sequence.Wallet.EOAWallet SessionKey { get; private set; } + + internal string Email => _email; + + internal void AttachSession(SequenceLogin owner, Sequence.Wallet.EOAWallet sessionKey) + { + Owner = owner; + SessionKey = sessionKey; + } + public Address GetWalletAddress() { return _address; diff --git a/Packages/Sequence-Unity/package.json b/Packages/Sequence-Unity/package.json index fa3a642a7..05a4f4ce8 100644 --- a/Packages/Sequence-Unity/package.json +++ b/Packages/Sequence-Unity/package.json @@ -1,6 +1,6 @@ { "name": "xyz.0xsequence.waas-unity", - "version": "5.0.12", + "version": "5.1.0", "displayName": "Sequence SDK", "description": "A Unity SDK for Sequence APIs", "unity": "2021.3",