Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

All-in-one Login with Oauth Flow #20

Merged
merged 3 commits into from
May 15, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 35 additions & 21 deletions Thirdweb.Console/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using Newtonsoft.Json;
using Nethereum.RPC.Eth.DTOs;
using Nethereum.Hex.HexTypes;
using System.Diagnostics;

DotEnv.Load();

Expand All @@ -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: "[email protected]"); // 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: "[email protected]"); // 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
Expand Down
9 changes: 6 additions & 3 deletions Thirdweb.Tests/Thirdweb.Transactions.Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
34 changes: 34 additions & 0 deletions Thirdweb/Thirdweb.Wallets/InAppWallet/IThirdwebBrowser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
public interface IThirdwebBrowser
{
Task<BrowserResult> Login(string loginUrl, string redirectUrl, Action<string> 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;
}
}
90 changes: 83 additions & 7 deletions Thirdweb/Thirdweb.Wallets/InAppWallet/InAppWallet.cs
Original file line number Diff line number Diff line change
@@ -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<InAppWallet> Create(ThirdwebClient client, string email = null, string phoneNumber = null)
public static async Task<InAppWallet> 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()
Expand All @@ -45,6 +68,59 @@ public override async Task Disconnect()
await _embeddedWallet.SignOutAsync();
}

#region OAuth2 Flow

public virtual async Task<string> LoginWithOauth(
bool isMobile,
Action<string> 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()
Expand Down
103 changes: 103 additions & 0 deletions Thirdweb/Thirdweb.Wallets/InAppWallet/InAppWalletBrowser.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
using System.Net;

namespace Thirdweb
{
public class InAppWalletBrowser : IThirdwebBrowser
{
private TaskCompletionSource<BrowserResult> _taskCompletionSource;

private readonly string closePageResponse =
@"
<html>
<head>
<style>
body {
font-family: Arial, sans-serif;
background-color: #2c2c2c;
color: #ffffff;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
flex-direction: column;
}
.container {
background-color: #3c3c3c;
padding: 20px;
border-radius: 10px;
box-shadow: 0 0 10px rgba(0,0,0,0.3);
text-align: center;
}
.instruction {
margin-top: 20px;
font-size: 18px;
}
</style>
</head>
<body>
<div class='container'>
<b>DONE!</b>
<div class='instruction'>
You can close this tab/window now.
</div>
</div>
</body>
</html>";

public async Task<BrowserResult> Login(string loginUrl, string redirectUrl, Action<string> browserOpenAction, CancellationToken cancellationToken = default)
{
_taskCompletionSource = new TaskCompletionSource<BrowserResult>();

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;
}
}
}
Loading