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

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

Open
willsmanley opened this issue Jan 17, 2025 · 9 comments
Open

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

willsmanley opened this issue Jan 17, 2025 · 9 comments
Labels
auth This issue or pull request is related to authentication blocked This issue is blocked by another issue enhancement New feature or request

Comments

@willsmanley
Copy link

willsmanley commented Jan 17, 2025

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 willsmanley added the bug Something isn't working label Jan 17, 2025
@willsmanley
Copy link
Author

willsmanley commented Jan 17, 2025

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 willsmanley changed the title Can't link new oauth login profile with an existing anonymous/guest user Documentation Clarity: Enable manual linking for anonymous user could be displayed more prominently Jan 17, 2025
@willsmanley
Copy link
Author

willsmanley commented Jan 17, 2025

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 willsmanley changed the title Documentation Clarity: Enable manual linking for anonymous user could be displayed more prominently Issues with manual linking for anonymous user Jan 17, 2025
@willsmanley
Copy link
Author

willsmanley commented Jan 17, 2025

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
Copy link
Author

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.

@dshukertjr dshukertjr added the auth This issue or pull request is related to authentication label Jan 18, 2025
@willsmanley
Copy link
Author

willsmanley commented Jan 18, 2025

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
Copy link
Author

@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
Copy link
Author

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
Copy link
Author

willsmanley commented Jan 19, 2025

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 willsmanley changed the title Issues with manual linking for anonymous user Issues with manual linking for anonymous user (non-PKCE oauth for google and apple; OTP doesn't work with linking) Jan 19, 2025
@dshukertjr dshukertjr added enhancement New feature or request blocked This issue is blocked by another issue and removed bug Something isn't working labels Jan 21, 2025
@dshukertjr
Copy link
Member

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.

@willsmanley willsmanley changed the title Issues with manual linking for anonymous user (non-PKCE oauth for google and apple; OTP doesn't work with linking) Manual linking with native flow (ID token) for google, apple Jan 23, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
auth This issue or pull request is related to authentication blocked This issue is blocked by another issue enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

2 participants