googlesamples / google-signin-unity

Google Sign-In API plugin for Unity game engine. Works with Android and iOS.
Other
424 stars 231 forks source link

Please allow signin in editor #56

Open Thaina opened 6 years ago

Thaina commented 6 years ago

I think it easy just use HttpListener to handle login flow from desktop browser. Please add official support into this repo

I have implement quick basic ISignInImpl for getting idToken to be used in firebase auth and this code below work. I use Newtonsoft.Json.Linq package and some custom extension methods which might not get along with code in this repo so I don't make a pull request. But it possible and it make developing auth smoothly

// Tweak GoogleSignIn.cs a bit

    /// <summary>
    /// Singleton instance of this class.
    /// </summary>
    /// <value>The instance.</value>
    public static GoogleSignIn DefaultInstance {
      get {
        if (theInstance == null) {
#if UNITY_EDITOR
          theInstance = new GoogleSignIn(new GoogleSignInImplEditor(Configuration));
#elif UNITY_ANDROID || UNITY_IOS
          theInstance = new GoogleSignIn(new GoogleSignInImpl(Configuration));
#else
          theInstance = new GoogleSignIn(null);
          throw new SignInException(
              GoogleSignInStatusCode.DeveloperError,
              "This platform is not supported by GoogleSignIn");
#endif
        }
        return theInstance;
      }
    }

    internal GoogleSignIn(ISignInImpl impl) {
      this.impl = impl;
    }
// new file
namespace Google
{
  public static class GoogleSignInEditorConfig
  {
    public static string Secret;
  }
}

namespace Google.Impl
{
  using System;
  using System.Linq;
  using System.Text;

  using System.Net;
  using System.Net.NetworkInformation;

  using UnityEngine;

  using Newtonsoft.Json.Linq;

  internal class GoogleSignInImplEditor : ISignInImpl, FutureAPIImpl<GoogleSignInUser>
  {
    GoogleSignInConfiguration configuration;

    public bool Pending { get; private set; }

    public GoogleSignInStatusCode Status { get; private set; }

    public GoogleSignInUser Result { get; private set; }

    public GoogleSignInImplEditor(GoogleSignInConfiguration configuration)
    {
      this.configuration = configuration;
    }

    public void Disconnect()
    {
      throw new NotImplementedException();
    }

    public void EnableDebugLogging(bool flag)
    {
      throw new NotImplementedException();
    }

    public Future<GoogleSignInUser> SignIn()
    {
      SigningIn();
      return new Future<GoogleSignInUser>(this);
    }

    public Future<GoogleSignInUser> SignInSilently()
    {
      SigningIn();
      return new Future<GoogleSignInUser>(this);
    }

    public void SignOut()
    {
      throw new NotImplementedException();
    }

    static HttpListener BindLocalHostFirstAvailablePort()
    {
      ushort minPort = 49215;
      var listeners = IPGlobalProperties.GetIPGlobalProperties().GetActiveTcpListeners();
      return Enumerable.Range(minPort,ushort.MaxValue - minPort).Where((i) => !listeners.Any((x) => x.Port == i)).Select((port) => {
        try
        {
          var listener = new HttpListener();
          listener.Prefixes.Add($"http://localhost:{port}/");
          listener.Start();
          return listener;
        }
        catch
        {
          return null;
        }
      }).FirstOrDefault((listener) => listener != null);
    }

    void SigningIn()
    {
      Pending = true;
      var httpListener = BindLocalHostFirstAvailablePort();
      Application.OpenURL("https://accounts.google.com/o/oauth2/v2/auth?scope=email%20profile&response_type=code&redirect_uri=" + httpListener.Prefixes.FirstOrDefault() + "&client_id=" + configuration.WebClientId);
      httpListener.GetContextAsync().ContinueWith((task) => {
        var context = task.Result;

        string code;
        if(!context.Request.Url.ParseQueryString().TryGetValue("code",out code) || string.IsNullOrEmpty(code))
        {
          Pending = false;
          Status = GoogleSignInStatusCode.InvalidAccount;
          Debug.Log("no code?");
          return;
        }

        context.Response.StatusCode = 200;
        context.Response.OutputStream.Write(Encoding.UTF8.GetBytes("Can close this page"));
        context.Response.Close();

        HttpWebRequest.CreateHttp("https://www.googleapis.com/oauth2/v4/token").PostFormUrlEncoded("code=" + code + "&client_id=" + configuration.WebClientId + "&client_secret=" + GoogleSignInEditorConfig.Secret + "&redirect_uri=" + httpListener.Prefixes.FirstOrDefault() + "&grant_type=authorization_code").ContinueWith((dataTask) => {
          var jobj = JObject.Parse(dataTask.Result);
          Result = new GoogleSignInUser() {
            IdToken = (string)jobj.GetValue("id_token")
          };

          Status = GoogleSignInStatusCode.Success;
          Pending = false;
        });
      });
    }
  }
}
// usage

#if UNITY_EDITOR
            GoogleSignInEditorConfig.Secret = "{APP_SECRET}";
#endif

            GoogleSignIn.Configuration = new GoogleSignInConfiguration() {
                RequestIdToken = true,
                WebClientId = "APP_CLIENT_ID",
            };

            var googleUser  = await GoogleSignIn.DefaultInstance.SignIn();
decoderzhub commented 6 years ago

I imported Newtonsoft.Json.Linq.

I tried to implement this and I'm getting errors here:

listener.Prefixes.Add($"http://localhost:{port}/");

I fixed this by changing it to this:

listener.Prefixes.Add("http://localhost:"+port+"/");

Also here:

httpListener.GetContextAsync().ContinueWith((task) => { Unity isn't recognizing .GetContextAsync() method.... I've included:

using System.Net

I'm not sure what HttpListener class you're using? Can you please let me know how I can fix this?

consolelog
Thaina commented 6 years ago

@decoderzhub That code I use C# 7.2 from incremental compiler in unity 2018.2. It seem yours test project still using dotnet 3.5. Which mean it don't contain any async/await API yet

But HttpListener also contain GetContext for pre async https://docs.microsoft.com/en-us/dotnet/api/system.net.httplistener.getcontext?view=netframework-3.5

You might need to modified related async code in the same way

Maybe just this

var context = httpListener.GetContextAsync();

string code;
if(!context.Request.Url.ParseQueryString().TryGetValue("code",out code) || string.IsNullOrEmpty(code))
{
    Pending = false;
    Status = GoogleSignInStatusCode.InvalidAccount;
    Debug.Log("no code?");
    return;
}

context.Response.StatusCode = 200;
context.Response.OutputStream.Write(Encoding.UTF8.GetBytes("Can close this page"));
context.Response.Close();

// do something similar at HttpWebRequest.CreateHttp
decoderzhub commented 6 years ago

Thanks for the quick response! I'm downloading it now...

decoderzhub commented 6 years ago

I tried the: if (!context.Request.Url.ParseQueryString()...

context doesn't contain that method I'm updating from Unity 2018.1 to 2018.2 should do the trick

Thaina commented 6 years ago

I am sorry there was my custom extension method around here

This is the file for it

#if UNITY_EDITOR

using System;
using System.IO;
using System.Net;
using System.Text;
using System.Linq;
using System.Threading.Tasks;
using System.Collections.Generic;

public static class EditorExt
{
    public static async Task<string> PostFormUrlEncoded(this HttpWebRequest request,string data) => await request.Post("application/x-www-form-urlencoded",data);
    public static async Task<string> Post(this HttpWebRequest request,string contentType,string data)
    {
        request.Method = "POST";
        request.ContentType = contentType;
        using(var stream = request.GetRequestStream())
            stream.Write(Encoding.UTF8.GetBytes(data));

        using(var response = await request.GetResponseAsync())
        {
            using(var stream = response.GetResponseStream())
                return stream.ReadToEnd(Encoding.UTF8);
        }
    }

    public static string ReadToEnd(this Stream stream,Encoding encoding = null) => new StreamReader(stream,encoding ?? Encoding.UTF8).ReadToEnd();
    public static void Write(this Stream stream,byte[] data) => stream.Write(data,0,data.Length);

    public static Dictionary<string,string> ParseQueryString(this Uri uri) => uri?.Query?.Split('?','&').Select((pair) => {
        return pair.Split(new[] { '=' },2);
    }).Where((pair) => !string.IsNullOrEmpty(pair.FirstOrDefault())).ToDictionary((pair) => pair.FirstOrDefault(),(pair) => pair.ElementAtOrDefault(1));
}

#endif
arturaz commented 5 years ago

In old .net you can do...

      httpListener.BeginGetContext(result => {
        var context = httpListener.EndGetContext(result);

        if (
          !context.Request.Url.ParseQueryString().TryGetValue("code", out var code) 
          || string.IsNullOrEmpty(code)
        ) {
          Pending = false;
          Status = GoogleSignInStatusCode.InvalidAccount;
          Debug.Log("no code?");
          return;
        }

        context.Response.StatusCode = 200;
        context.Response.OutputStream.Write(Encoding.UTF8.GetBytes("Can close this page"));
        context.Response.Close();

        var request = UnityWebRequest.Post(
          "https://www.googleapis.com/oauth2/v4/token",
          new Dictionary<string, string> {
            {"code", code},
            {"client_id", configuration.WebClientId},
            {"client_secret", GoogleSignInEditorDataSO.clientSecret.strict.value},
            {"redirect_uri", httpListener.Prefixes.FirstOrDefault()},
            {"grant_type", "authorization_code"}
          }
        );
        // This is a way to execute some code when the web request is finished, unfortunately it is too
        // complex to just paste it here. But you can devise your own way.
        request.toFuture(AcceptedResponseCodes._20X, req => req.downloadHandler.text)
          .onComplete(either => either.voidFold(
            err => {
              Log.d.error($"Failed to do google sign-in oauth request: {err.message}");
            },
            body => {
              var jObject = JObject.Parse(body);
              Result = new GoogleSignInUser {
                IdToken = (string) jObject.GetValue("id_token")
              };

              Status = GoogleSignInStatusCode.Success;
              Pending = false;
            }
          ));
        },
        null
      );
JustinSchneider commented 5 years ago

Thanks for posting this workaround, @Thaina! It'd be great to have this built into the plugin out of the box.

jivalenzuela commented 5 years ago

Thanks for posting this workaround, @Thaina! It'd be great to have this built into the plugin out of the box.

Justin can you show us how you did it finally?

thanks

phedg1 commented 5 years ago
                HttpWebRequest.CreateHttp("https://www.googleapis.com/oauth2/v4/token").PostFormUrlEncoded("code=" + code + "&client_id=" + configuration.WebClientId + "&client_secret=" + GoogleSignInEditorConfig.Secret + "&redirect_uri=" + httpListener.Prefixes.FirstOrDefault() + "&grant_type=authorization_code").ContinueWith((dataTask) => {
                    var jobj = JObject.Parse(dataTask.Result);
                    Result = new GoogleSignInUser() {
                        IdToken = (string)jobj.GetValue("id_token")
                    };

                    Status = GoogleSignInStatusCode.Success;
                    Pending = false;
                });

CreateHttp no longer seems to have the function PostFormUrlEncoded. I do not understand enough about http listeners to fix it, though I have been trying for hours. @Thaina , I don't want to intrude but could you help me?

Also, this project has not been updated in over a year. Still, there does not appear to be any better alternatives available. I wish Google committed more resources to this.

Thaina commented 5 years ago

@phedg1 HttpWebRequest is not related to HttpListener. It just a REST API here. You could just use WWW class or any REST caller you familiar with to post that data to https://www.googleapis.com/oauth2/v4/token url to get the id_token

phedg1 commented 5 years ago

I edited your scripts @Thaina , I hope you don't mind. I've posted them here in case they help anyone else.

// Tweak GoogleSignIn.cs a bit
    /// <summary>
    /// Singleton instance of this class.
    /// </summary>
    /// <value>The instance.</value>
    public static GoogleSignIn DefaultInstance {
      get {
        if (theInstance == null) {
          #if UNITY_EDITOR
            theInstance = new GoogleSignIn(new GoogleSignInImplEditor(Configuration));
          #elif UNITY_ANDROID || UNITY_IOS
            theInstance = new GoogleSignIn(new GoogleSignInImpl(Configuration));
          #else
            theInstance = new GoogleSignIn(null);
            throw new SignInException(
              GoogleSignInStatusCode.DeveloperError,
              "This platform is not supported by GoogleSignIn");
          #endif
        }
        return theInstance;
      }
    }

    internal GoogleSignIn(ISignInImpl impl) {
      this.impl = impl;
    }

    /// <summary>Starts the authentication process.</summary>
    /// <remarks>
    /// The authenication process is started and may display account picker
    /// popups and consent prompts based on the state of authentication and
    /// the requested elements.
    /// </remarks>
    public Task<GoogleSignInUser> SignIn() {
      var tcs = new TaskCompletionSource<GoogleSignInUser>();
      SignInHelperObject.Instance.StartCoroutine(
        impl.SigningIn(true));
      SignInHelperObject.Instance.StartCoroutine(
        impl.SignIn().WaitForResult(tcs));
      return tcs.Task;
    }

    /// <summary>Starts the silent authentication process.</summary>
    /// <remarks>
    /// The authenication process is started and will attempt to sign in without
    /// displaying any UI.  If this cannot be done, the developer should call
    /// SignIn().
    /// </remarks>
    public Task<GoogleSignInUser> SignInSilently() {
      var tcs = new TaskCompletionSource<GoogleSignInUser>();
      SignInHelperObject.Instance.StartCoroutine(
        impl.SigningIn(false));
      SignInHelperObject.Instance.StartCoroutine(
        impl.SignInSilently().WaitForResult(tcs));
      return tcs.Task;
    }

  internal interface ISignInImpl {
    Future<GoogleSignInUser> SignIn();
    Future<GoogleSignInUser> SignInSilently();
    void EnableDebugLogging(bool flag);
    void SignOut();
    void Disconnect();
    IEnumerator SigningIn(bool signInPressed);
  }
// Tweak GoogleSignInImple.cs a bit.
    public IEnumerator SigningIn(bool signInPressed) {
      yield return null;
    }
// New file
using System.Collections.Generic;

namespace Google {
    public static class GoogleSignInEditorConfig {
        public static string Secret = "";
    }
}

class GoogleAccessToken {
    public string access_token;
    public string id_token;
    public string refresh_token;
    public string expires_in;
    public string token_type;
    public string scope;
};

namespace Google.Impl {
    using System;
    using System.Text;

    using System.Net;
    using System.Collections;
    using System.Collections.Generic;
    using System.IO;

    using UnityEngine;

    internal class GoogleSignInImplEditor : ISignInImpl, FutureAPIImpl<GoogleSignInUser> {
        GoogleSignInConfiguration configuration;

        public bool Pending { get; private set; }

        public GoogleSignInStatusCode Status { get; private set; }

        public GoogleSignInUser Result { get; private set; }

        public HttpListener httpListener;

        public ushort port = 49651;

        private string refreshToken = "";

        public GoogleSignInImplEditor(GoogleSignInConfiguration configuration) {
            this.configuration = configuration;
        }

        public void Disconnect() {
            throw new NotImplementedException();
        }

        public void EnableDebugLogging(bool flag) {
            throw new NotImplementedException();
        }

        public Future<GoogleSignInUser> SignIn() {
            return new Future<GoogleSignInUser>(this);
        }

        public Future<GoogleSignInUser> SignInSilently() {
            return new Future<GoogleSignInUser>(this);
        }

        public void SignOut() {
            throw new NotImplementedException();
        }

        public void BindLocalHostFirstAvailablePort() {
            if (httpListener == null || !httpListener.IsListening) {
                try {
                    httpListener = new HttpListener();
                    httpListener.Prefixes.Add($"http://localhost:{port}/");
                    httpListener.Start();
                } catch {
                }
            }
        }

        public IEnumerator SigningIn(bool signInPressed) {
            Pending = true;
            // ATTEMPT TO GAIN AN ACCESS TOKEN USING A REFRESH TOKEN
            ReadRefreshToken();
            if (refreshToken != "") {
                Dictionary<string, string> contentRefresh = new Dictionary<string, string>();
                contentRefresh.Add("client_id", configuration.WebClientId);
                contentRefresh.Add("client_secret", GoogleSignInEditorConfig.Secret);
                contentRefresh.Add("refresh_token", refreshToken);
                contentRefresh.Add("grant_type", "refresh_token");

                UnityEngine.Networking.UnityWebRequest postRefresh = UnityEngine.Networking.UnityWebRequest.Post("https://oauth2.googleapis.com/token", contentRefresh);
                postRefresh.SetRequestHeader("POST", "/oauth2/v4/token HTTP/1.1");
                postRefresh.SetRequestHeader("Content-Type", "application/x-www-form-urlencoded");
                yield return postRefresh.SendWebRequest();

                if (postRefresh.isNetworkError || postRefresh.isHttpError) {
                    Pending = false;
                    Status = GoogleSignInStatusCode.Error;
                } else {
                    GoogleAccessToken accessToken = UnityEngine.JsonUtility.FromJson<GoogleAccessToken>(postCode.downloadHandler.text));
                    Result = new GoogleSignInUser() {
                        IdToken = accessToken.id_token;
                    };
                    Status = GoogleSignInStatusCode.Success;
                    Pending = false;
                    signInPressed = false;
                }
            }

            //ATTEMPT TO GAIN ACCESS USING CODE
            if (signInPressed) {
                BindLocalHostFirstAvailablePort();
                if (httpListener.IsListening) {
                    // REQUEST CODE FROM GOOGLE
                    Application.OpenURL("https://accounts.google.com/o/oauth2/v2/auth?scope=email%20profile&response_type=code&redirect_uri=http://localhost:" + port.ToString() + "&client_id=" + configuration.WebClientId + "&prompt=consent&access_type=offline");

                    // WAIT TO RECEIVE CODE
                    bool moveOn = false;
                    bool valid = true;
                    string code = "";
                    httpListener.GetContextAsync().ContinueWith((task) => {
                        // EXTRACT CODE STRING FROM REQUEST URL
                        HttpListenerContext context = task.Result;
                        string resultsModified = context.Request.Url.ToString();
                        resultsModified = resultsModified.Remove(0, ("http://localhost:" + port.ToString() + "/?").Length);
                        System.Collections.Specialized.NameValueCollection queryResults = System.Web.HttpUtility.ParseQueryString(resultsModified);
                        code = queryResults["code"];

                        // CHECK CODE IS VALID
                        if (string.IsNullOrEmpty(code)) {
                            Pending = false;
                            Status = GoogleSignInStatusCode.InvalidAccount;
                            valid = false;
                            Debug.Log(context.Request.Url.ToString());
                            Debug.Log("No code received.");
                        }

                        // SEND RESPONSE TO BROWSER
                        context.Response.StatusCode = 200;
                        byte[] response = Encoding.UTF8.GetBytes("This page can be closed now.");
                        context.Response.OutputStream.Write(response, 0, response.Length);
                        context.Response.Close();
                        moveOn = true;
                    });
                    while (!moveOn) {
                        yield return null;
                    }

                    if (valid) {
                        Dictionary<string, string> contentCode = new Dictionary<string, string>();
                        contentCode.Add("code", code);
                        contentCode.Add("client_id", configuration.WebClientId);
                        contentCode.Add("client_secret", GoogleSignInEditorConfig.Secret);
                        contentCode.Add("redirect_uri", "http://localhost:" + port.ToString());
                        contentCode.Add("grant_type", "authorization_code");

                        UnityEngine.Networking.UnityWebRequest postCode = UnityEngine.Networking.UnityWebRequest.Post("https://oauth2.googleapis.com/token", contentCode);
                        postCode.SetRequestHeader("POST", "/oauth2/v4/token HTTP/1.1");
                        postCode.SetRequestHeader("Content-Type", "application/x-www-form-urlencoded");
                        yield return postCode.SendWebRequest();

                        if (postCode.isNetworkError || postCode.isHttpError) {
                            Pending = false;
                            Status = GoogleSignInStatusCode.Error;
                        } else {
                            GoogleAccessToken accessToken = UnityEngine.JsonUtility.FromJson<GoogleAccessToken>(postCode.downloadHandler.text));
                            refreshToken = accessToken.refresh_token;
                            WriteRefreshToken();
                            Result = new GoogleSignInUser() {
                                IdToken = accessToken.id_token;
                            };
                            Status = GoogleSignInStatusCode.Success;
                            Pending = false;
                        }
                    }
                }
                yield return null;
            }
        }

        void WriteRefreshToken() {
            string path = Application.persistentDataPath + "/refresh_token.txt";
            StreamWriter writer = new StreamWriter(path, false);
            writer.Write(refreshToken);
            writer.Close();
        }
        void ReadRefreshToken() {
            string path = Application.persistentDataPath + "/refresh_token.txt";
            if (File.Exists(path)) {
                StreamReader reader = new StreamReader(path);
                refreshToken = reader.ReadToEnd();
                reader.Close();
            }
        }
    }
}

I prevented BindLocalHostFirstAvailablePort() from incrementing the port so that the redirect_uri would consistently match an authorized redirect uri in my project's credentials. Simplifying it helped me better understand what its function was.

I used UnityEngine.Networking.UnityWebRequest for my REST API.

Lastly, I made the process also return a refresh_token. That token is saved in the persistentDataPath so you can log in silently in the editor without being redirected to your browser every time.

firdiar commented 4 years ago

app_secret means firebase API Key and app_client_id means web client id (client id type 3), isn't it? . @Thaina

Thaina commented 4 years ago

@firdiar This so long I don't remember but I think it need google app ID and Secret. Not sure if it is the same as FireBase

spvn commented 4 years ago

Anyone know how to implement sign out in editor as well?

Phedg1Studios commented 4 years ago

@spvn There's no "sign out" function, per se. Use this code to revoke your access and refresh tokens then tell your app it is signed out. It will require a new sign in to get a working token next time the user tries to log in.

        foreach (string token in tokens) {
            UnityEngine.Networking.UnityWebRequest get = UnityEngine.Networking.UnityWebRequest.Get("https://accounts.google.com/o/oauth2/revoke?token=" + token);
            get.SetRequestHeader("Content-type", "application/x-www-form-urlencoded");
            get.SendWebRequest();
        }