supabase-community / gotrue-csharp

C# implementation of Supabase's GoTrue
https://supabase-community.github.io/gotrue-csharp/api/Supabase.Gotrue.Client.html
MIT License
39 stars 27 forks source link

Add Support for Sign In With Apple #47

Closed wiverson closed 1 year ago

wiverson commented 1 year ago

Feature request

I would like to support native Sign In With Apple (specifically in the context of a Unity project).

Is your feature request related to a problem? Please describe.

Sign In With Apple support has recently been added to Supabase.

Per the announcement, "With supabase-flutter, this is as easy as:

final AuthResponse response = await supabase.auth.signInWithApple();

A clear and concise description of what you want and what your use case is.

Describe the solution you'd like

I'd like to have the csharp library have a nice simple signInWithApple() function as well.

The flutter version calls winds up calling into the GoTrue API with this function:

https://github.com/supabase/gotrue-dart/blob/4909d8725540a5b4b0a8d87a72ed69a374dd470c/lib/src/gotrue_client.dart#L226

Provider is just a string.

The remaining details come from a native sign in solution. For Unity, for example, I have been successful bringing up the Sign in with Apple dialog and completing the flow with the Sign in with Apple Unity Plugin. Other popular packages that offer C# integration (e.g. Godot, MonoGame) would likely use other options to support bringing up the Sign In With Apple dialog.

I think that adding in the API request, combined with instructions on how to get the data from the native API would be a great option.

Describe alternatives you've considered

I have been able to get gotrue-csharp working with Unity, including email/password accounts. Apple has been increasingly aggressive about requiring developers to support Sign in with Apple, and the flow for Sign in with Apple is more seamless for many users.

Possible solution

Based on the dart code, I built out a quick and dirty version of what I think this would look like in the csharp version. I can submit a PR, but I'm not very familiar with the code base and I think it's just the one method. If someone wants to create a branch I can test it, or a PR, or...?

using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Threading.Tasks;
using Supabase.Gotrue;
using UnityEngine;
using static Supabase.Gotrue.Helpers;

public class SupabaseSignInWithAppleGoogle {
    /// Allows signing in with an ID token issued by certain supported providers.
    /// The [idToken] is verified for validity and a new session is established.
    /// This method of signing in only supports [Provider.google] or [Provider.apple].
    public void RemoveSession() { }

    public void SaveSession(Session session) { }

    public class SignInInfo {
        public readonly string Provider;
        public readonly string IDToken;

        public readonly string Nonce;
        // public string captchaToken

        public SignInInfo(string idToken, string provider, string nonce) {
            IDToken = idToken;
            Provider = provider;
            Nonce = nonce;
        }
    }

    public async Task<Session> SignInWithIdToken(Client supabase, SignInInfo info) {
        const string ProviderGoogle = "google";
        const string ProviderApple = "apple";

        RemoveSession();

        if (info.Provider != ProviderGoogle && info.Provider != ProviderApple) {
            throw new ArgumentException($"Provider must either be ${ProviderGoogle} or ${ProviderApple}.");
        }

        Dictionary<string, object> body = new Dictionary<string, object>();
        body["provider"] = info.Provider;
        body["id_token"] = info.IDToken;
        if (info.Nonce != null) body["nonce"] = info.Nonce;
        //        body["gotrue_meta_security"] = $"{'captcha_token': } "

        Task<Session> request = MakeRequest<Session>(HttpMethod.Post, $"{SupabaseStuff.SUPABASE_URL}/token?grant_type=id_token", body);

        await request;

        if (!request.IsCompletedSuccessfully)
            if (request.Exception != null)
                throw request.Exception;
            else
                throw new ArgumentException($"Request failed  {request.Status}");

        SaveSession(request.Result);
        NotifyAllSubscribers("signedIn");

        return request.Result;
    }

    private void NotifyAllSubscribers(string signedIn) {
        Debug.Log("Signed In");
    }
}
acupofjose commented 1 year ago

Thanks for your work exploring this! I’ll get to work on implementing it

wiverson commented 1 year ago

Thanks! LMK if/when you have a cut and I can test it out. I will write up the instructions on how to get it working in Unity w/all the back and forth with Apple Dev. :)

acupofjose commented 1 year ago

@wiverson alrighty! There you are #48. Would love some feedback when you can.

wiverson commented 1 year ago
  1. The body dictionary is created but not passed to the MakeRequest parameter in the new SignInWithIdToken.
  2. I think it's better to add the nonce and the captchaToken to the dictionary only if the values are present. Otherwise it's sending null values which is probably not good.
  3. Debugging this stuff is awful. :P I was able to verify that my Apple <-> Supabase config was right via a SvelteKit web app but yeesh - a single extra return character in the Supabase/Apple JWT config after running the Ruby script is just one of countless easy-to-break things.
  4. I keep getting errors that look like they are coming from a 401 on the Supabase side. When I try using an raw http client I'm getting a 404 to hit my project URL. Possible something is broken on the Supabase side. I'm going to bang on it a bit more and see what I can sort out and I might send a ticket in to Supabase, as well as look at the dart implementation a bit more to see if there is something else I'm missing.
wiverson commented 1 year ago

Oh, and thank you very much! :)

acupofjose commented 1 year ago
  1. 🤦 - yep. That's bad. lol. All fixed.
  2. Got it!
  3. Agreed. Especially since you have to those live systems!
  4. Maybe the wrong url? It should use the format: https://PROJECT_ID.supabase.io/auth/v1
wiverson commented 1 year ago

Ok, I made a bunch of progress.

For the moment, the nonce stuff is complicating things. I got it working without nonce for now, I think getting the other parts working without nonce is tricky enough for the moment. It looks like there is some choreography where if you pass in a nonce hash, the identifier key that comes back from Apple includes that nonce hash back again which can then flow back to Supabase to validate it. Unfortunately nothing about the nonce is really documented other than in code (e.g. the dart code). That said, if you omit the nonce from the request Apple omits it and the flow is easier. For the record, I'm not saying not to implement the nonce portion, just that it seems to be something that can be revisited after getting the rest fo the flow working.

I was able to get the REST API to work with the following request/body via the Rider HTTP client:

POST https://fvkoppwyozwgkiowmcrg.supabase.co/auth/v1/token?grant_type=id_token
Accept: application/json
apiKey: <my public Supabase key>
Authorization: Bearer <my public Supabase key>

{
  "provider": "apple",
  "id_token": "<id token that comes back from Apple native sign in>"
}

Note that I'm not passing a nonce in to Apple native sign in, so the id token doesn't include it, and Supabase seems to be fine with that.

I was able to get the method to hit the Supabase API by add in the following headers manually:

            Dictionary<string, string> h = new Dictionary<string, string>();

            string SUPABASE_PUBLIC_KEY =
                "<my project public Supabase key>";

            h.Add("apikey", SUPABASE_PUBLIC_KEY);
            h.Add("Authorization", $"Bearer {SUPABASE_PUBLIC_KEY}");

            return Helpers.MakeRequest<Session>(HttpMethod.Post, $"{Url}/token?grant_type=id_token", body, h);

Which doesn't make a ton of sense - I would have thought that the auth lib would add in those headers automatically when I init the client (that seems to work for RPC calls).

But at least the API was able to fetch the JSON back from Supabase with the user!

The JSON that comes back however is breaking the JSON deserialization, with the error: Exception Unable to find a constructor to use for type Supabase.Gotrue.User. A class should either have a default constructor, one constructor with arguments or a constructor marked with the JsonConstructor attribute. Path 'user.id', line 1, position 1025.

It looks like User has a default constructor, so I'm not really following what it's complaining about. Perhaps this JSON can help? I could take a stab at writing a test case that just makes sure it can parse/handle this JSON, or if you want to try...?

{
  "access_token": "<long string with the token>",
  "token_type": "bearer",
  "expires_in": 3600,
  "refresh_token": "oQ3uzBCfkSWQQJGQV8b-qg",
  "user": {
    "id": "794b6a65-4adf-4bda-b82f-1dfbdc374e98",
    "aud": "authenticated",
    "role": "authenticated",
    "email": "<email address>",
    "email_confirmed_at": "2023-04-25T06:31:59.43156Z",
    "phone": "",
    "confirmed_at": "2023-04-25T06:31:59.43156Z",
    "last_sign_in_at": "2023-04-25T07:39:22.105617638Z",
    "app_metadata": {
      "provider": "apple",
      "providers": [
        "apple"
      ]
    },
    "user_metadata": {
      "aud": "io.dayboard.UnityTest",
      "auth_time": 1682404292,
      "c_hash": "faDOUyCCRl_4pwwS7swXOQ",
      "email": "<my email>",
      "email_verified": "true",
      "exp": 1682490692,
      "iat": 1682404292,
      "iss": "https://appleid.apple.com",
      "nonce_supported": true,
      "sub": "000538.4e496882c5d24936a71d30c3b22465f3.2149"
    },
    "identities": [
      {
        "id": "<string with my id>",
        "user_id": "<another string with a different id",
        "identity_data": {
          "aud": "io.dayboard.UnityTest",
          "auth_time": 1682404292,
          "c_hash": "faDOUyCCRl_4pwwS7swXOQ",
          "email": "<my email>",
          "email_verified": "true",
          "exp": 1682490692,
          "iat": 1682404292,
          "iss": "https://appleid.apple.com",
          "nonce_supported": true,
          "sub": "<some string>"
        },
        "provider": "apple",
        "last_sign_in_at": "2023-04-25T06:31:59.429778Z",
        "created_at": "2023-04-25T06:31:59.429817Z",
        "updated_at": "2023-04-25T07:33:45.061502Z"
      }
    ],
    "created_at": "2023-04-25T06:31:59.421953Z",
    "updated_at": "2023-04-25T07:39:22.10715Z"
  }
}
wiverson commented 1 year ago

Looks like the constructor failing is due to overly aggressive Unity code stripping. Working on a fix now.

wiverson commented 1 year ago

Success! Changing the stripping level from "low" to "minimal" in the Unity build settings fixed it.

I can now log in via Unity's Sign in with Apple and then use that to log in to Supabase. I yelled a bit when it worked and startled my wife. :)

I think the only open Q in my book is why the method is failing unless I add the API key to the headers. Any guesses? Should the API key be an argument to the method and just set the headers, or is there something about the headers for this method that's failing, or...?

wiverson commented 1 year ago

Here is a link to a description of how to integrate a Sign in with Apple nonce with a backend service. In theory this should also work with Supabase, but it does need to be tested. A tweaked version of this would be a nice utility to throw into gotrue-csharp - no need for every developer to reimplement this from scratch every time.

wiverson commented 1 year ago

Another post clarifying the nonce flow for reference.

acupofjose commented 1 year ago

Thank you so much @wiverson for your work on this! Reading through these made me smile haha.

  1. Yes - you have to manually specify the header api key, this is handled automatically in the supabase-csharp repo, but not here. It was left out here because I wanted to keep the library decoupled from the hosted supabase instances. But, maybe adding in the api key should be part of the default functionality... I could be persuaded!
  2. Thank goodness you found the Unity Code stripping issue, I was totally at a loss!
  3. Okay. The PKCE flow needed a nonce setup too. I suppose this can be generalized to for this method too!: https://github.com/supabase-community/gotrue-csharp/blob/0ab16d427e2e9f88da71c8f6d6f6e2136b14bae5/Gotrue/Helpers.cs#L44-L72
wiverson commented 1 year ago

If you can make those two methods public, perhaps as just GenerateNonce() and GenerateNonceSHA256() or similar I think that would do the trick.

Would you like to update the PR branch and then I'll try it out again wiping out my now hacked up version? ;)

wiverson commented 1 year ago

FYI I started writing up my notes on this, incomplete but it's a start.

https://gist.github.com/wiverson/86ec69ddf13b137306842348eaec37a2

wiverson commented 1 year ago

Looking over the hacks/tweaks I made, it looks like the one thing that I'm not sure on is the proper way to ensure that the apikey header[s] are injected. I don't have any strong thoughts on how to do this WRT the supabase core vs gotrue-csharp, I just know that hacking up SignInWithIdToken to slam in my hard-coded headers is not the way to do it lol.

acupofjose commented 1 year ago

Alright! You should have access to Supabase.Gotrue.Helpers.GenerateNonce() and Supabase.Gotrue.Helpers.GenerateNonceVerifier().

Try making setting your headers like this:

var auth = new Supabase.Gotrue.Client(new ClientOptions<Session>
{
    Url = "https://PROJECT_ID.supabase.co/auth/v1",
    Headers = new Dictionary<string, string>
    {
        { "apikey", SUPABASE_PUBLIC_KEY }
    }
})
wiverson commented 1 year ago

So, for whatever reason the nonce hash function in GenerateNonceVerifier() isn't working. Here is an alternative implementation I built based on the dart version:

using System.Security.Cryptography;
using System.Text;

namespace App {
    public static class NonceGenerator {
        public static string GenerateSHA256NonceFromRawNonce(string rawNonce) {
            SHA256Managed sha = new SHA256Managed();
            byte[] utf8RawNonce = Encoding.UTF8.GetBytes(rawNonce);
            byte[] hash = sha.ComputeHash(utf8RawNonce);

            string result = string.Empty;
            foreach (byte t in hash) {
                result += t.ToString("x2");
            }

            return result;
        }
    }
}

The rest of it seems to be working great.

With Nonce:

  1. Generate a nonce with the Supabase.Gotrue.Helpers.GenerateNonce();
  2. Hash that nonce with the function above and pass it to the native Sign in with Apple function
  3. Call Auth.SignInWithIdToken(Constants.Provider.Apple, identityToken, nonce); with the original nonce

Without Nonce:

  1. Call the Sign in with Apple function
  2. Call Auth.SignInWithIdToken(Constants.Provider.Apple, identityToken);

Both of these scenarios are now working, which is most excellent.

I'm not sure what the best strategy is for the helper functions, as it might be the PKCE verify and the Apple verify are just simply two different hash strategies? Perhaps leave the original function as PKCE verify and use the above as AppleVerify?

Anyways, let me know what you decide and LMK and I'll test it.

wiverson commented 1 year ago

So, I think if you can add the Apple compatible version of the nonce hash I think you could release a build that has everything needed & working. LMK if/when and I can finish writing up and maybe make a YouTube video or something to help explain how it all works. :)

acupofjose commented 1 year ago

Agreed! Planning on doing a real ease with these changes sometime later tonight. Will let you know. Thanks again for all your help!!

acupofjose commented 1 year ago

@wiverson alrighty - available in 3.1.1! Let me know if you make a tutorial/write-up and I'll gladly include it on the README!

wiverson commented 1 year ago

I wound up making a video instead of trying to write it up.

https://youtu.be/S0hTwtsUWcM

Between the video and the links in the description I think it's everything someone who is experienced with Unity would need to get started. I think it would clock in at tens of thousands of words to cover everything from scratch. ¯_(ツ)_/¯

I'm working on building a Unity test/sample package that includes all of the error handling and a nice out-of-the-box UI that someone could drop into their project. If/when I wind up publishing that I'll be sure to let you know.

I think this means you can close the ticket as a pretty huge new feature added! I'm doing this for Unity but I don't see any reason this wouldn't work with Godot C# or MonoGame. :)

Let me know if you need anything else. Thank you so much for your help!

acupofjose commented 1 year ago

Nice! Solid video. Definitely a lot of moving parts there! I've included it on the readme here.

Available in the latest release for supabase-csharp@0.9.1 and gotrue-csharp@3.1.1 Appreciate you and your help on this!