diff --git a/Thirdweb.Console/Program.cs b/Thirdweb.Console/Program.cs index 31dd8d7..8af4723 100644 --- a/Thirdweb.Console/Program.cs +++ b/Thirdweb.Console/Program.cs @@ -4,6 +4,7 @@ using Newtonsoft.Json; using Nethereum.RPC.Eth.DTOs; using Nethereum.Hex.HexTypes; +using System.Diagnostics; DotEnv.Load(); @@ -22,32 +23,45 @@ // Create wallets (this is an advanced use case, typically one wallet is plenty) var privateKeyWallet = await PrivateKeyWallet.Create(client: client, privateKeyHex: privateKey); -var inAppWallet = await InAppWallet.Create(client: client, email: "firekeeper+7121271d@thirdweb.com"); // or email: null, phoneNumber: "+1234567890" -// // Reset InAppWallet (optional step for testing login flow) -// if (await inAppWallet.IsConnected()) -// { -// await inAppWallet.Disconnect(); -// } +// var inAppWallet = await InAppWallet.Create(client: client, email: "firekeeper+7121271d@thirdweb.com"); // or email: null, phoneNumber: "+1234567890" +var inAppWallet = await InAppWallet.Create(client: client, authprovider: AuthProvider.Google); // or email: null, phoneNumber: "+1234567890" + +// Reset InAppWallet (optional step for testing login flow) +if (await inAppWallet.IsConnected()) +{ + await inAppWallet.Disconnect(); +} // Relog if InAppWallet not logged in if (!await inAppWallet.IsConnected()) { - await inAppWallet.SendOTP(); - Console.WriteLine("Please submit the OTP."); - var otp = Console.ReadLine(); - (var inAppWalletAddress, var canRetry) = await inAppWallet.SubmitOTP(otp); - if (inAppWalletAddress == null && canRetry) - { - Console.WriteLine("Please submit the OTP again."); - otp = Console.ReadLine(); - (inAppWalletAddress, _) = await inAppWallet.SubmitOTP(otp); - } - if (inAppWalletAddress == null) - { - Console.WriteLine("OTP login failed. Please try again."); - return; - } + var address = await inAppWallet.LoginWithOauth( + isMobile: false, + (url) => + { + var psi = new ProcessStartInfo { FileName = url, UseShellExecute = true }; + _ = Process.Start(psi); + }, + "thirdweb://", + new InAppWalletBrowser() + ); + Console.WriteLine($"InAppWallet address: {address}"); + // await inAppWallet.SendOTP(); + // Console.WriteLine("Please submit the OTP."); + // var otp = Console.ReadLine(); + // (var inAppWalletAddress, var canRetry) = await inAppWallet.SubmitOTP(otp); + // if (inAppWalletAddress == null && canRetry) + // { + // Console.WriteLine("Please submit the OTP again."); + // otp = Console.ReadLine(); + // (inAppWalletAddress, _) = await inAppWallet.SubmitOTP(otp); + // } + // if (inAppWalletAddress == null) + // { + // Console.WriteLine("OTP login failed. Please try again."); + // return; + // } } // Create smart wallet with InAppWallet signer diff --git a/Thirdweb.Tests/Thirdweb.Transactions.Tests.cs b/Thirdweb.Tests/Thirdweb.Transactions.Tests.cs index f7fcfcb..e5620e6 100644 --- a/Thirdweb.Tests/Thirdweb.Transactions.Tests.cs +++ b/Thirdweb.Tests/Thirdweb.Transactions.Tests.cs @@ -221,10 +221,13 @@ public async Task EstimateTotalCosts_HigherThanGasCostsByValue() _ = transaction.SetValue(new BigInteger(1000000000000000000)); // 100 gwei accounting for fluctuations _ = transaction.SetGasLimit(21000); - var totalCosts = await ThirdwebTransaction.EstimateTotalCosts(transaction); - var gasCosts = await ThirdwebTransaction.EstimateGasCosts(transaction); + var totalCostsTask = ThirdwebTransaction.EstimateTotalCosts(transaction); + var gasCostsTask = ThirdwebTransaction.EstimateGasCosts(transaction); - Assert.True(totalCosts.wei > gasCosts.wei); + var costs = await Task.WhenAll(totalCostsTask, gasCostsTask); + + Assert.True(costs[0].wei > costs[1].wei); + Assert.True(costs[0].wei - costs[1].wei == transaction.Input.Value.Value); } [Fact] diff --git a/Thirdweb/Thirdweb.Wallets/InAppWallet/IThirdwebBrowser.cs b/Thirdweb/Thirdweb.Wallets/InAppWallet/IThirdwebBrowser.cs new file mode 100644 index 0000000..f46f047 --- /dev/null +++ b/Thirdweb/Thirdweb.Wallets/InAppWallet/IThirdwebBrowser.cs @@ -0,0 +1,34 @@ +public interface IThirdwebBrowser +{ + Task Login(string loginUrl, string redirectUrl, Action browserOpenAction, CancellationToken cancellationToken = default); +} + +public enum BrowserStatus +{ + Success, + UserCanceled, + Timeout, + UnknownError, +} + +public class BrowserResult +{ + public BrowserStatus status { get; } + + public string callbackUrl { get; } + + public string error { get; } + + public BrowserResult(BrowserStatus status, string callbackUrl) + { + this.status = status; + this.callbackUrl = callbackUrl; + } + + public BrowserResult(BrowserStatus status, string callbackUrl, string error) + { + this.status = status; + this.callbackUrl = callbackUrl; + this.error = error; + } +} diff --git a/Thirdweb/Thirdweb.Wallets/InAppWallet/InAppWallet.cs b/Thirdweb/Thirdweb.Wallets/InAppWallet/InAppWallet.cs index 081d9c6..7a430cf 100644 --- a/Thirdweb/Thirdweb.Wallets/InAppWallet/InAppWallet.cs +++ b/Thirdweb/Thirdweb.Wallets/InAppWallet/InAppWallet.cs @@ -1,42 +1,65 @@ +using System.Web; using Nethereum.Signer; using Thirdweb.EWS; namespace Thirdweb { + public enum AuthProvider + { + Default, + Google, + Apple, + Facebook, + // JWT, + // AuthEndpoint + } + public class InAppWallet : PrivateKeyWallet { internal EmbeddedWallet _embeddedWallet; internal string _email; internal string _phoneNumber; + internal string _authProvider; - internal InAppWallet(ThirdwebClient client, string email, string phoneNumber, EmbeddedWallet embeddedWallet, EthECKey ecKey) + internal InAppWallet(ThirdwebClient client, string email, string phoneNumber, string authProvider, EmbeddedWallet embeddedWallet, EthECKey ecKey) : base(client, ecKey) { _email = email; _phoneNumber = phoneNumber; _embeddedWallet = embeddedWallet; + _authProvider = authProvider; } - public static async Task Create(ThirdwebClient client, string email = null, string phoneNumber = null) + public static async Task Create(ThirdwebClient client, string email = null, string phoneNumber = null, AuthProvider authprovider = AuthProvider.Default) { - if (string.IsNullOrEmpty(email) && string.IsNullOrEmpty(phoneNumber)) + if (string.IsNullOrEmpty(email) && string.IsNullOrEmpty(phoneNumber) && authprovider == AuthProvider.Default) { - throw new ArgumentException("Email or Phone Number must be provided to login."); + throw new ArgumentException("Email, Phone Number, or OAuth Provider must be provided to login."); } + var authproviderStr = authprovider switch + { + AuthProvider.Google => "Google", + AuthProvider.Apple => "Apple", + AuthProvider.Facebook => "Facebook", + AuthProvider.Default => string.IsNullOrEmpty(email) ? "PhoneOTP" : "EmailOTP", + _ => throw new ArgumentException("Invalid AuthProvider"), + }; + var embeddedWallet = new EmbeddedWallet(client); EthECKey ecKey; try { - var user = await embeddedWallet.GetUserAsync(email, email == null ? "PhoneOTP" : "EmailOTP"); + if (!string.IsNullOrEmpty(authproviderStr)) { } + var user = await embeddedWallet.GetUserAsync(email, authproviderStr); ecKey = new EthECKey(user.Account.PrivateKey); } catch { - Console.WriteLine("User not found. Please call InAppWallet.SendOTP() to initialize the login process."); + Console.WriteLine("User not found. Please call InAppWallet.SendOTP() or InAppWallet.LoginWithOauth to initialize the login process."); ecKey = null; } - return new InAppWallet(client, email, phoneNumber, embeddedWallet, ecKey); + return new InAppWallet(client, email, phoneNumber, authproviderStr, embeddedWallet, ecKey); } public override async Task Disconnect() @@ -45,6 +68,59 @@ public override async Task Disconnect() await _embeddedWallet.SignOutAsync(); } + #region OAuth2 Flow + + public virtual async Task LoginWithOauth( + bool isMobile, + Action browserOpenAction, + string mobileRedirectScheme = "thirdweb://", + IThirdwebBrowser browser = null, + CancellationToken cancellationToken = default + ) + { + if (isMobile && string.IsNullOrEmpty(mobileRedirectScheme)) + { + throw new ArgumentNullException(nameof(mobileRedirectScheme), "Mobile redirect scheme cannot be null or empty on this platform."); + } + + var platform = "unity"; + var redirectUrl = isMobile ? mobileRedirectScheme : "http://localhost:8789/"; + var loginUrl = await _embeddedWallet.FetchHeadlessOauthLoginLinkAsync(_authProvider); + loginUrl = $"{loginUrl}?platform={platform}&redirectUrl={redirectUrl}&developerClientId={_client.ClientId}&authOption={_authProvider}"; + + browser ??= new InAppWalletBrowser(); + var browserResult = await browser.Login(loginUrl, redirectUrl, browserOpenAction, cancellationToken); + var callbackUrl = + browserResult.status != BrowserStatus.Success + ? throw new Exception($"Failed to login with {_authProvider}: {browserResult.status} | {browserResult.error}") + : browserResult.callbackUrl; + + while (string.IsNullOrEmpty(callbackUrl)) + { + if (cancellationToken.IsCancellationRequested) + { + throw new TaskCanceledException("LoginWithOauth was cancelled."); + } + await Task.Delay(100, cancellationToken); + } + + var decodedUrl = HttpUtility.UrlDecode(callbackUrl); + Uri uri = new(decodedUrl); + var queryString = uri.Query; + var queryDict = HttpUtility.ParseQueryString(queryString); + var authResultJson = queryDict["authResult"]; + + var res = await _embeddedWallet.SignInWithOauthAsync(_authProvider, authResultJson, null); + if (res.User == null) + { + throw new Exception("Failed to login with OAuth2"); + } + _ecKey = new EthECKey(res.User.Account.PrivateKey); + return await GetAddress(); + } + + #endregion + #region OTP Flow public async Task SendOTP() diff --git a/Thirdweb/Thirdweb.Wallets/InAppWallet/InAppWalletBrowser.cs b/Thirdweb/Thirdweb.Wallets/InAppWallet/InAppWalletBrowser.cs new file mode 100644 index 0000000..363ccec --- /dev/null +++ b/Thirdweb/Thirdweb.Wallets/InAppWallet/InAppWalletBrowser.cs @@ -0,0 +1,103 @@ +using System.Net; + +namespace Thirdweb +{ + public class InAppWalletBrowser : IThirdwebBrowser + { + private TaskCompletionSource _taskCompletionSource; + + private readonly string closePageResponse = + @" + + + + + +
+ DONE! +
+ You can close this tab/window now. +
+
+ + "; + + public async Task Login(string loginUrl, string redirectUrl, Action browserOpenAction, CancellationToken cancellationToken = default) + { + _taskCompletionSource = new TaskCompletionSource(); + + cancellationToken.Register(() => + { + _taskCompletionSource?.TrySetCanceled(); + }); + + using var httpListener = new HttpListener(); + + try + { + redirectUrl = AddForwardSlashIfNecessary(redirectUrl); + httpListener.Prefixes.Add(redirectUrl); + httpListener.Start(); + _ = httpListener.BeginGetContext(IncomingHttpRequest, httpListener); + + Console.WriteLine($"Opening browser with URL: {loginUrl}"); + browserOpenAction.Invoke(loginUrl); + + var completedTask = await Task.WhenAny(_taskCompletionSource.Task, Task.Delay(TimeSpan.FromSeconds(30), cancellationToken)); + return completedTask == _taskCompletionSource.Task ? await _taskCompletionSource.Task : new BrowserResult(BrowserStatus.Timeout, null, "The operation timed out."); + } + finally + { + httpListener.Stop(); + } + } + + private void IncomingHttpRequest(IAsyncResult result) + { + var httpListener = (HttpListener)result.AsyncState; + var httpContext = httpListener.EndGetContext(result); + var httpRequest = httpContext.Request; + var httpResponse = httpContext.Response; + var buffer = System.Text.Encoding.UTF8.GetBytes(closePageResponse); + + httpResponse.ContentLength64 = buffer.Length; + var output = httpResponse.OutputStream; + output.Write(buffer, 0, buffer.Length); + output.Close(); + + _taskCompletionSource.SetResult(new BrowserResult(BrowserStatus.Success, httpRequest.Url.ToString())); + } + + private string AddForwardSlashIfNecessary(string url) + { + string forwardSlash = "/"; + if (!url.EndsWith(forwardSlash)) + { + url += forwardSlash; + } + return url; + } + } +}