supabase-flutter icon indicating copy to clipboard operation
supabase-flutter copied to clipboard

Manual linking with native flow (ID token) for google, apple

Open willsmanley opened this issue 1 year ago • 10 comments

SUMMARY

There are a lot of messy comments below as I investigated this issue and tangential issues, but here is the primary request:

Manual linking for anonymous accounts only supports PKCE flows with oauth. This technically works but is much more complicated (both for the user and the developer) than supporting native linking from ID token. There should be a method to link an identity directly from an ID token. Instead, linking from an ID token always results in a new account being created, which orphans the anonymous account. PKCE flows are not great for the user since they open a webpage instead of doing it in-app natively.

willsmanley avatar Jan 17 '25 19:01 willsmanley

Ah I found the note about "enabling manual linking in your supabase profile" and that seems to have fixed the hanging part.

I think that could be displayed as a minor section with a screenshot as this sentence is easily overlooked.

Also, that method should throw a useful error if manual linking is disabled at the account level rather than hanging.

willsmanley avatar Jan 17 '25 19:01 willsmanley

Actually, I think there is still something wrong. linkIdentity method opens a URL on ios devices instead of using the native login flow that produces an idToken. Shouldn't there be some way to use native sign in and then send the idToken to supabase for the existing user, similar to how the normal create user flow works?

Something like this should work, but does not:


      // Flow for an anonymous user to sign in with apple on an ios device and manually link the oauth login

      final rawNonce = Supabase.instance.client.auth.generateRawNonce();
      final hashedNonce = sha256.convert(utf8.encode(rawNonce)).toString();

      final credential = await SignInWithApple.getAppleIDCredential(
        scopes: [
          AppleIDAuthorizationScopes.email,
          AppleIDAuthorizationScopes.fullName,
        ],
        nonce: hashedNonce,
      );

      final idToken = credential.identityToken;
      if (idToken == null) {
        throw const AuthException(
            'Could not find ID Token from generated credential.');
      }

      final AuthResponse response =
          await Supabase.instance.client.auth.signInWithIdToken(
        provider: OAuthProvider.apple,
        idToken: idToken,
        nonce: rawNonce,
      );

willsmanley avatar Jan 17 '25 20:01 willsmanley

After researching this further, I think the thing I am asking for is a method like linkIdentityWithIdToken which has the same parameters as signInWithIdToken, but it just links the identity rather than creating a new user.

this should be feasible particularly since the linkIdentity flow ultimately does result in exposing some sort of backend behavior that attaches the identity from an idtoken, so this just needs to be exposed to the client. i would even be happy just making a raw http request if that is a current viable workaround.

willsmanley avatar Jan 17 '25 21:01 willsmanley

after looking further, i realize that yall are using gotrue on server side. so it would require a new endpoint, something like api/link_identity.go:

package api

import (
	"context"
	"encoding/json"
	"net/http"
	"strings"

	jwt "github.com/golang-jwt/jwt/v4"
	"github.com/netlify/gotrue/api/provider"
	"github.com/netlify/gotrue/models"
	"github.com/netlify/gotrue/storage"
)

type LinkIdentityParams struct {
	Provider   string `json:"provider"`             
	IDToken    string `json:"id_token"`         
	AccessToken string `json:"access_token,omitempty"`
	Nonce      string `json:"nonce,omitempty"`
}

func (a *API) LinkIdentityWithIDToken(w http.ResponseWriter, r *http.Request) error {
	ctx := r.Context()
	config := a.getConfig(ctx)

	authUser, err := a.getUserFromRequest(r)
	if err != nil || authUser == nil {
		return unauthorizedError("Could not determine current user, or not authenticated.")
	}

	params := &LinkIdentityParams{}
	if err := json.NewDecoder(r.Body).Decode(params); err != nil {
		return badRequestError("Failed to parse JSON body: %v", err)
	}
	if params.Provider == "" || params.IDToken == "" {
		return badRequestError("Provider and ID token fields are required.")
	}

	var email string
	var discoveredUser *models.User
	err = a.db.Transaction(func(tx *storage.Connection) error {
		oAuthProvider, providerErr := a.OAuthProvider(ctx, params.Provider)
		if providerErr != nil {
			return badRequestError("Unsupported provider: %v", providerErr).WithInternalError(providerErr)
		}

		userData, verifyErr := verifyIDToken(ctx, oAuthProvider, params.IDToken, params.Nonce, params.AccessToken)
		if verifyErr != nil {
			return badRequestError("Failed to verify ID token: %v", verifyErr).WithInternalError(verifyErr)
		}

		email = strings.ToLower(userData.Email)

		discoveredUser = authUser

		appMetaUpdates := map[string]interface{}{
			params.Provider: true, // or store an ID, etc.
		}
		if err := discoveredUser.UpdateAppMetaData(tx, appMetaUpdates); err != nil {
			return internalServerError("Error updating app_metadata. %s", err)
		}

		if len(userData.Metadata) > 0 {
			if err := discoveredUser.UpdateUserMetaData(tx, userData.Metadata); err != nil {
				return internalServerError("Error updating user_metadata. %s", err)
			}
		}

		if !discoveredUser.IsConfirmed() {
			if err := discoveredUser.Confirm(tx); err != nil {
				return internalServerError("Error confirming user. %s", err)
			}
		}

		if err := models.NewAuditLogEntry(tx, discoveredUser.InstanceID, discoveredUser, models.LoginAction, map[string]interface{}{
			"linked_provider": params.Provider,
		}); err != nil {
			return err
		}

		return nil
	})

	if err != nil {
		return err
	}

	return sendJSON(w, http.StatusOK, map[string]interface{}{
		"message": "Identity linked successfully",
		"email":   email,
	})
}

a lot of this would be like a combination between token.go and existing linking logic, so shared logic.

willsmanley avatar Jan 18 '25 00:01 willsmanley

Since apple wasn't working well out of the box, I have now begun trying the email OTP flow for converting anonymous users to non-anonymous users.

It does let me call:

await Supabase.instance.client.auth.updateUser(
   UserAttributes(email: _emailController.value.text),
);

which does assign the email address provided as an unverified email. it sends the "change email address" template. i have configured that one to provide the OTP. reason for this is that the user might not have their email on the same device as the anonymous login. if they click on a verification link, there is no way to get to the anonymous account on the other device. the OTP would solve this by allowing them to enter it from wherever, doesn't matter where their email is signed in.

however when the user later enters the OTP and i call:

await Supabase.instance.client.auth.verifyOTP(
   email: widget.email,
   token: value,
   type: OtpType.email,
);

what that does is actually creates a brand new, non-anonymous account and orphans the original anonymous account. in other words, this is not performing manual linking or automatic linking.

this is problematic because anonymous users should have the same options for login as they were offered originally, but it seems like both apple and email OTP are not working as expected.

hopefully this helps, i know this is a lot of information to go through. happy to jump on a call to explain whats going on.

willsmanley avatar Jan 18 '25 21:01 willsmanley

@kangmingtay wanted to CC you since it looks like you had solve an anonymous -> non-anonymous user issue earlier this year and might have the context i'm missing.

thank you in advance, i know yall are busy! :)

willsmanley avatar Jan 18 '25 22:01 willsmanley

annoyingly, even if you do this with a service key:

  await supabase.auth.admin.updateUserById(userId, {
    email: email,
  });
  // ... wait some time to send custom email and validate code against hash...
  await supabase.auth.admin.updateUserById(userId, {
    email_confirm: true,
  });

it still doesnt update is_anonymous to true, despite meeting the requirement to have a verified email.

and i dont think we can directly update rows in auth.users with postgrest, although i could be wrong.

this was my attempt at setting up my own endpoint to bypass the broken OTP linking experience, but it seems this doesnt work either.

i think i will have to turn off anonymous users for now until there is a solution.

willsmanley avatar Jan 19 '25 00:01 willsmanley

one more issue. if you do this:

await Supabase.instance.client.auth.signInWithOtp(email: _emailController.value.text)

it will randomly hang. not clear as to why as there is no error response or anything. i've noticed it happens more often if you call it on a second attempt (such as after having typed in the first email incorrectly).

this part has nothing to do with the anonymous user flows, just seems like a weird/buggy behavior. perhaps it is related to rate limits or email limits?

willsmanley avatar Jan 19 '25 00:01 willsmanley

The auth team is currently working on linking identity with the native flow.

When opening issues, please post only one issue per post. The OTP issue should be a separate post.

dshukertjr avatar Jan 21 '25 01:01 dshukertjr

What’s the ETA on linking identity with the native flow?

jerdmeyer avatar Feb 23 '25 00:02 jerdmeyer

Any news about the native flow ? 🙏

sowakillian avatar Jun 19 '25 12:06 sowakillian

Hey everyone,

We have an open PR (https://github.com/supabase/supabase-flutter/pull/1206) for adding support for native identity linking. Thanks a lot for your patience!

grdsdev avatar Aug 08 '25 12:08 grdsdev