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

Implement account linking #447

Closed
rishabhpoddar opened this issue May 15, 2022 · 30 comments
Closed

Implement account linking #447

rishabhpoddar opened this issue May 15, 2022 · 30 comments
Labels
enhancement New feature or request

Comments

@rishabhpoddar
Copy link
Member

rishabhpoddar commented May 15, 2022

This issue is about account linking

Flow diagrams: https://lucid.app/lucidchart/82064b11-858b-4f97-b2ee-3d6e6ed604a6/edit?viewport_loc=-91%2C-655%2C2048%2C1196%2C0_0&invitationId=inv_92259a69-24b4-472f-bbc6-0af69bf835a5#

TODO:

  • All recipes in backend sdk
    • emailpassword
    • thirdpartyemailpassword
    • thirdparty
    • passwordless
    • thirdpartypasswordless
    • session
    • metadata
    • dashboard
    • jwt
    • openid
    • emailverification
    • roles
    • useridmapping
  • Fix typing for linkAccountToExistingAccountPOST API on backend SDK
  • In linkAccountsWithUserFromSession should we throw email verification claim failure in case the existing account is not verified? How will this be handled on the frontend (with respect to claim validation order for mfa: chat reference: https://supertokens.slack.com/archives/D02B887A2MQ/p1683003076081709)?
  • email verification functions should be given recipe user ID only (internal to our SDK)
  • Make email verification functions take email compulsory - so that if a primary user ID is passed, it does not end up getting the wrong email (we should also make an ADR for this. See comment here)
    • We've decided against doing this, the EV functions take a strictly typed recipeUserId instead.
  • Make a demo app to show user how to add phonerNumber or email to an existing logged in account. E.g. user logged in via TP flow, and on settings page they add password or phoneNumber and it should create emailpassword or passwordless account
  • Fix dashboard getUsers function to return the new user type. This also involves changing the frontend
  • Function name changes:
    • getUserByAccountInfo (current) to getUserByAccountInfoWithLoginMethod (new)
  • Review core paths and methods for the new APIs
  • Fix frontend of dashboard to work with new user object type
  • Try and minimise use of user type specific to each recipe, and use the main User object as much as possible. For example, in sign up function / API for emailpassword recipe, we want to return the main User object as opposed to the emailpassword user object - right?
  • How would someone customise it to have account linking with a prompt during sign in?
    • This will be included as an example. In short: they can add a claim somewhat similar to the proposed account linking claim.
  • Due to how linkAccountsWithUserFromSession works, in MFA, we are forcing email verification to happen right after the first factor (cause email verification is required for account linking by default)
    • I don't think that this is true - cause this happens only if the first factor is not a primary user. But in our case, we are making the first factor a primary user.
  • Check if recursion can be done in backend sdk in recipe functions
  • Unlink account API in core should check that it's not causing a situation where in the account is supposed to be deleted (cause recipe user id === primary user id).
  • Error codes that require contacting support should have error codes associated with them to make it easier for devs integrating with SuperTokens to debug the errors.
    • Added error codes to reason
    • Docs page
  • Link accounts API in core should mark the relevant emails as verified post account linking
  • account linking claim needs to be removed from the session after calling linkAccountsWithUserFromSession
  • SignUpPost can return an accountLinkingStatus property instead of retuning createdNewUser: boolean to make the interface less confusing
    • createdNewUser has been renamed as createdNewRecipeUser instead + we will show guidance about how to check if a new primary user was created in the docs.
  • we should create a function in all recipes to create a user without signing up so that if the developer has overridden signUp it wont get called during account linking.
    • Done where necessary, we've decided against doing this in other recipes.
  • Sign up recipe function in emailpassword should use getUser to refetch the user post account linking and not just return the user returned from the core level recipe sign up.
  • During normal sign up (recipe level) store the recipe user id in some table (this should be in the same transaction as the one that creates the recipe user id). If shouldDoAccountLinking returns false or linkAccounts succeeds remove the recpe user id from the table. During sign in if the user id is present in the table, do account linking for that recipe user id.
    • This is to counteract the issue that sign up of recipe level user can succeed, but account linking of this user might fail. If we do not do this, then this user will never be account linked automatically.
    • Same situation holds true if the user's email is verified but then after that, account linking fails for whatever reason
  • changeEmailOrPassword should check if the email is already associated with a primary user id (this should be in the core and not in the backend sdk).
  • Remove recipe level getUserById and email etc.
  • updateEmailOrPassword logic should happen in the core (the checks in the lucid chart diagram). This only takes a recipe user ID
  • For all core APIs that take a user ID as an input, we need to write down if it's primary user id or recipe, or just one of them.
  • Need to initialise account linking recipe by default in backend SDK
  • In linkAccountsWithUserFromSession, there is a part where we return NEW_ACCOUNT_NEEDS_TO_BE_VERIFIED_ERROR. Right before that, we have a todo for this should never happen.. solve it
  • in the implementation for account linking, in linkAccountToExistingAccountPOST, previously, the code also removed the Account linking session claim in some situations - why?
    • It wasn't required, but we did it anyway cause just in case the claim was there.
  • check that the recipe implementation of all recipes is minimal. Just calling the core, and then calling relevant hooks or interacting with session recipe.
  • Make sure that for all recipe's linkAccountToExistingAccountPOST, we verify the credentials properly if the user already exists.
  • Do a test in which we call linkAccountToExistingAccountPOST wherein we want to link an existing user to the session, but with wrong credentials of the existing user.
  • Test that if we call linkAccountToExistingAccountPOST where the current session's email is not verified, then it results in email verification claim failure
  • In linkAccountToExistingAccountPOST should it matter if the session's account is verified or not (if it's a primary user already)?
    • It shouldn't matter cause we are already logged into that account, and we are logging into the new account as well - there doesn't seem to be any security issues as such. But it DOES matter if the new account is verified or not, if the emails are different
  • In linkAccountToExistingAccountPOST before creating a recipe user, should we check the same conditions as in isSignUpAllowed - that, is if there is another primary user which doesn't have the same email as unverified? Maybe this is not needed cause if there was another primary user with the same email, we prevent that anyway (regardless of if their email is verified or not)..
  • Should we run delete user in the core during unlink accounts? What if the dev wants to have a hook for user deletions?
    • Yes. They could add an override, but this is a function they call manually - they can check the response and update their own DB.
  • In linkAccountToExistingAccountPOST, if the account linking claim is not to be added and if it exists, it should be removed - just for cleanup (basically you want to remove this claim after linking is done)
  • Test that in linkAccountToExistingAccountPOST if the new account is a primary user, then we should not allow linking.
  • Test that in linkAccountToExistingAccountPOST when we are checking if the email of the new and the existing account is same, we need to check for ALL identifying infos of the existing account and NOT just the currently logged in account. For example, if the new user is emailA and the logged in user is emailB, but the logged in user has another linked account with emailA, it means that even if emailA is not verified (of the new or existing account), we do the linking.
  • Test that the following flows work in a scenario where a primary user exists with email A verified, and email B, unverified and A == B (they are same email) - both are linked:
    • sign up using another provider (should link this account with the primary user id)
    • post login sign up using another provider (should link this account with the primary user id)
    • password reset for the email password flow (where neither of the existing accounts are email password)
    • password reset for the email password flow (where one of the existing accounts are email password)
  • Test that in reset password, if email password user's account is verified and not linked to anything, post verification, the password reset, the account is not linked to anything either (regardless of shouldRequireVerification status)
  • Is there anything we should do about post login password reset? (in the settings page that is)? Or adding a password to an existing third party account which should implicitly create an email password account?
  • If the email password recipe is not initialised, then we should throw an error if the shouldRequireVerification is true. But where should this happen?
    • We've decided against this throwing, we can use the verified info in the user object.
  • How would we implement phone + password login when account linking is enabled? (Update example app)
  • Wherever we are calling updateEmailOrPassword, we should check for password policy thing as well (even in dashboard password change API)
  • Add extensive debug logs in the API flow
  • We need to think of a better func signature for createNewSession.
  • Fix ts playground
  • fetchValue should take a recipeId as well now and EmailVerificationClaim should use that and not the primary user id (in verifyEmailPOST)
  • Whenever we link accounts or make an account as a primary user, we need to remove the account linking claim from the session regardless of the situation in hand.
  • Remove account linking claim from verifyEmailPOST
  • Make sure to call purgeSessionOfAccountLinkingClaimIfRequired everytime before getting the value from the claim on the backend (or better, add it to how we get the value in the first place as part of the claim definition)
  • Check that in verifyEmailPOST, we remove the account linking claim if required.
  • We need to remove from accounts to link table when we make a user a primary user or link an account.
  • Make sure to add a version of linkEmailPasswordAccountsWithUserFromSession to all auth recipes.
  • Test that emailpassword.linkEmailPasswordAccountsWithUserFromSession works fine (along with other recipe functions).
  • Should we also expose purgeSessionOfAccountLinkingClaimIfRequired in account linking index.ts?
  • What would be the effect of account linking on invite flow via password reset email?
    • This flow is now special-cased in the node implementation.
  • Should we also revoke session in core when calling link / unlink account? Cause the user might do that via a direct call to the core and not the backend SDK, and in this case, the session associated with those accounts will continue to live on causing issues?
    • Yes
  • Check what happens to the session when:
    • It's linked to an account when the session was created, and then we manually unlink it via an API call to the core
    • It's linked to an account when the session was created, and then we manually unlink it via the recipe function on the backend SDK for account linking
    • a new user signs up, account is unverified, and then we link the accounts manually and then start the verification flow.
    • a new user sigs up, we go through the email verification flow and cause it to link. Then we change the email, of the account making it unverified. And then we unlink the account and go through the email verification flow - what happens post verification?
    • a new user sigs up, we go through the email verification flow and cause it to link. Then we change the email, of the account making it unverified. And then we link the account to a different primary user and go through the email verification flow - what happens post verification?
  • Need to make sure that everything works even if account linking is not enabled in the core
  • Test that account linking claim is removed post successful email verification after the new account has been linked to the session account.
  • Add tests to make sure that EmailVerificationClaim fetch value is based on recipeUserId and not primary user id
  • What changes are needed for the session refresh API in case the account for this user has been linked to another user whilst this session still exists?
    • I don't think anything specific is required cause the core will have revoked the session anyway? But we should confirm.
  • Dashboard email verification API changes:
    • userEmailVerifyGet takes a recipeUserId instead of userId
    • userEmailVerifyPut takes a recipeUserId instead of userId
    • userEmailVerifyTokenPost takes a recipeUserId instead of userId
  • In the core, the APIs for email verification should use recipeUserId in the input and output instead of using userId
  • What happens on the dashboard in this case: The user signs up with google -> logout -> sign up with email password (email is in unverified state). We then go to the dashboard and manually mark that ep user's account as verified.
    • this causes accounts to be linked - how does the UI react to this?
    • How does the session and account linking claim on the actual user's browser react to this?
  • If the AccountLinkingClaim is kept in the session even after linking, is that OK?
    • Check that calling the email verification APIs should remove this claim
    • Does this have any effect on the frontend or backend otherwise?
    • Should we have something to remove this claim is not needed from time to time? Maybe we can add a check in the session refresh API?
  • Make sure that we do not ready account linking claim directly via session.getClaimValue and instead always use the resyncAndGetValue function.
  • Test that the email verification session claim error thrown from linkAccountsWithUserFromSession is of the right structure and that it sends back a 403
  • Test that by default, account linking is disabled on the backend SDK
  • Remove all mockCore files
  • In session recipe, we should make recipeUserId compulsory and primary user id optional
  • Remove createAndSendCustomEmail from all recipes (all deprecated ways of sending emails)
  • Is using normalizedInputMap in the User type the best idea? How can we detect if that is not used as to prevent bugs (specifically, how can we enforce that hasSameEmailAs type functions are used when comparing emails etc for users?
  • Session recipe should only take recipeUserId and figure out the primary user ID based on that.
  • See all APis that return the user object - do they need to change? What about the functions we have added for hasSameEmailAs etc? Do they get ignored in the JSON?
  • should we add recipeUserId to error handlers in session recipe?
  • Add check for recipeUserId in when the version is >= v3 (or whatever version we have for account linking) in validateAccessTokenStructure
  • in third party api implementation, where we verify the email, we must pass in the user.recipeUserId and not the user.id to the email verification recipe.
  • in third party api implementation, where we create a new session, we must pass in the user.recipeUserId and not the user.id to the email verification recipe.
  • in passwordless api implementation, where we verify the email, we must pass in the user.recipeUserId and not the user.id to the email verification recipe.
  • in passwordless api implementation, where we create a new session, we must pass in the user.recipeUserId and not the user.id to the email verification recipe.
  • Check email and SMS sending backend API change.
  • Functions that accept only a recipe user id, should use recipeUserId instead of userId or id. Related to this, should we have specific types for recipe user id and primary user id so that users and we don't make a mistake with this? Similar to how we have string vs NormalisedURLDomain (so we can have RecipeUserId, PrimaryUserId)
  • For node SDK, remove MOCK=true from the .github/workflows/tests.yml file
  • There is a TODO about normalise phone number in mockCore.ts in accountlinking recipe.
  • Email password verification emails might be verified by the user without them realising that it's for a new email password account. Increase in chances of them clicking on the verification email given that they had previously signed up with social login.
    • This case is handled by not allowing sign-in
  • Check that account linking happens even if the email is only on the recipe level account and not the actual primary account.
  • How can users implement a confirm account link flow (where before linking accounts, during sign up, we ask the user to confirm that before actually creating a user etc.
    • Doable through a "preliminary session"
  • Create functions to get info for user id based on email verification and password reset tokens.
  • Implement getPasswordResetTokenInfo in the core
  • Implement getEmailVerificationTokenInfo in the core
  • Use revokeSessionsForLinkedAccounts in implementation of revokeAllSessionsForUser in core.
  • Use fetchSessionsForAllLinkedAccounts in implementation of getAllSessionHandlesForUser in core.
  • For session access token, we need to take into account older versions of the access token that don't have recipeUserId
  • Dashboard recipe:
    • userGet should return primary user object
    • metadata change should work on primary user id and not recipe level user id
  • Remove conversions of recipeUserId string to object type in index.ts functions of session and email verification recipe. We added them only cause of testing purposes.
  • What is the effect of switching email verification required mode to existing linked accounts that are not verified?
    • Nothing, they stay linked - even if account linking is turned off completely
  • Do we really need the fetchFromAccountToLinking table stuff given that we are storing the recipeId in the claim?
    • Yes we do cause the email veirifcaiton link can be opened in a different browser, and there is no session in there
  • If third party account is signing up and their email is not verified, then it should not allow sign up in case a primary user already exists. This is to prevent the attack where the user may get an email verification email and then they click on it by mistake, linking the attacker's account. We do this check in email password sign up as well by allowLinking: false, when calling isSignUpAllowed. In this case, we also want to make sure that we end up creating the social login ID - cause otherwise how will support team actually link the account?
    • If the email does not exist, we should check if the third party info exists - if it does, it's a sign in and not a sign up - therefore this case does not qualify for stopping the login process
  • Add a test where all auth methods allow sign up if the email being used is unverified in the primary account (with one login method), and also verified with another login method
  • Add a test wherein the email verification link is opened in a different browser for account linking post sign up, and then it should still all just work (cause of account to link table being there)
  • Add a test to check the shape of the user object returned from the apis for each of the auth methods (sign in, sign up APIs)
  • remove use of process.env.MOCK env var entirely
  • For link and unlink accounts in the core, we must clear the session in the core only. We decided to do this in the core and not in the backend SDK cause it's similar to delete user function where we do that in the core only as well.
  • Add test for "change in account to link does not cause account linking post email verification with the older primary user id (with and without session)"
  • Complete "hasSamePhoneNumberAs function in user object work" test
  • Complete "hasSameThirdPartyInfoAs function in user object work" test
  • If a user is shared across tenants, and then they use the same email on another tenant, they should have their account linked automatically.
    • Sharing will be blocked in case a primary user conflict happens.
  • In linkAccountWithUserFromSessionPOST, we can also return form field error.
  • Dashboard related changes:
    • Confirmation of delete user text should change to say that all linked accounts will also be deleted
    • We separate out the login methods into their own boxes
    • Each login method will be able to be unliked or deleted. These will be shown when you click on the edit button
    • User metadata and session will remain the same.
    • The info showed against the user (next to the profile image), should be based on the oldest recipeUserId (timeJoined) for that user. This also affects the user listing page
    • If there is only one login method for a user, we do not allow it to be unlinked / deleted (on a login method level) from the UI.
    • Each login method will have an edit button on top right.
    • The info for user metadata + session + everything on the details page should be based on the user ID returned from the user GET API, and not from the query param.
    • In listing page:
      • Auth method will show up for users with single login methods, else it will show "Multiple"
      • We get rid of the drop down on the right of each user item.
    • Backend API changes
      • delete API will accept a userId and a removeAllLinkedAccounts boolean. removeAllLinkedAccounts should be false when deleting recipe level users, and true when deleting the main user.
      • userEmailVerifyGet query param change from userId to recipeUserId
      • userEmailVerifyPut will take recipeUserId instead of userId. After this is called, we need to refetch all data cause it can result in linking.
      • userEmailVerifyTokenPost will take recipeUserId
      • userGet will continue to take a userId (primary or recipe level), and not take recipeId anymore. In the output, RECIPE_NOT_INITIALISED will go away. Type of status OK, will not have recipeId and also, the user type will change to be a primary user with first name and last name.
      • userPasswordPut will take a recipeUserId.
      • userPut will have new output of EMAIL_CHANGE_NOT_ALLOWED_ERROR status (whose error needs to be displayed on the frontend). Input will take recipeUserId. If first name or last name is to change, then we need to make that change on a primary user id (which can be fetched from the input recipe user id)
      • usersGet output type will change.
  • User count, pagination and search changes
  • Reset password should revoke all sessions (for the recipe user id) cause if an attacker is logged into an ep account which they did not verify, and then the legit user resets the password, then the attacker should be logged out.
  • Add debug logs to isSignUpAllowed function.
  • Is it possible to create a new session right after password reset itself during invite flow?
  • Account deduplication flow should account for changes in email of social provider. The situation is that if an email password user exists with email "A" and then a social login user exists with email "B", then if the social login user changes their email to "A" on the provider's site, it will result in two accounts in supertokens with email "A" even though account deduplication is implemented.
  • Test that when linking post login, and verification flow is happening, the user still has access to the rest of the app on the frontend etc.
  • In MFA recipe, when someone is creating their own custom factor which has its own user ID, how will that affect the account linking recipe, given that the recipe impl right now only works based on user IDs known to supertokens
  • Test for getUser, listUsersByAccountInfo, getUsers (supertokens level functions)
  • Can we eliminate the need for accountlinking claim by just sending the email verification email in the API directly? This way, it's all easier. When verified, the accounts will be linked. One issue is that how will emails be resent here?
  • verifyCredentialsFunc in third party and passwordless cannot run multiple times cause those codes are one time use only. This is in the context of the impl for API linkAccountWithUserFromSessionPOST, and places when we are recursing due to race conditions.
  • Add recipe user id to front-token so that frontend can also use Session.getRecipeUserId
  • Add test in backend SDK for checking that front-token has recipeUserId
  • Add test in backend SDK for checkint v2 access token parsing is fine and compatible with v3 access token.
  • Calling account linking APIs in the core if license key is not there should have the same effect as if account linking was not done - cause there are a few functions like verifyEmailUsingToken, which by default attempt account linking
  • if condition in updatePasswordlessUser in thirdpartypasswordless is wrong. where we check for user.thirdParty.length > 0. Need to fix
  • What should be done if listUsersByAccountInfo gets multiple inputs - like email and third party id or email and phone number?
  • consumeCodePOST in passwordless should not do email verification. Instead, the recipe later should do email verification, before calling the link accounts function.
  • updatePasswordlessUser has a "// TODO: the above if condition is wrong." in third party passwordless recipe impl.
  • If email verification is in optional mode, and the user does some activity and then verifies their email, it would result in the user ID changing. So what can we do about this?
    • disable account linking in email verification api by checking if email verification is optional or not
      • This is not great - data could be created during sign up itself.
    • go ahead and link it anyway, but tell devs to implement the call back where data transfer can happen
      • onAccountLinked
  • We should disable the APIs for linkPostSession by default and the user can enable it by doing something like
    AccountLinking.init({
       enableAPIForAccountLinkingWithSession: {
          emailPassword: true,
          passwordless: true,
          thirdParty: true
       }
    })
    
    Similarly, on the frontend pre build UI, we should also add these booleans so that the frontend route knows to call the other API instead of the regular sign in up
  • Frontend sdk changes
    • pre built UI
      • sign in up callback / code consume callback pages should call the post account linking API in case a session exists.
      • These screens need to also take into account that email verification would be required.
      • New event types need to be made for this as well.
  • Can we eliminate need for linkAccountPostSession alltogether for now and to link table as well? This depends on how MFA will do linking.
  • Should we allow account linking during sign in as well? Cause we can ask the dev to check their db in shouldDoAccountLinking, and if the user has no data, then we should just do sign in account linking as well. Furthermore, we will be asking devs to do this anyway cause if they have optional email verification, then they would need to check for user's data in db anyway..
  • in email verification recipe, should we make same email or other recipe user IDs as verified as well in case they are linked to this recipe user id?
    • Decided not to do cause when the user signs in with the other recipe ID, their email will be auto verified by the core.
  • Should updateEmailOrPassword recipe function call the isEmailChangeAllowed function?
  • Should we get rid of combination recipes on the backend? If yes, this will require a change in the routing logic based on rid.
    • We want to do this, but not now/in this PR.
  • Make sure that all index functions have userCOntext initialised an an empty object when passing it to functions instead it. Specifically, email verification recipe's createEmailVerificationToken function does not normalise the user context when passing it to getEmailForRecipeUserId
  • linkAccounts recipe function should return a user object in status OK.
  • Remove the session arg from shouldDoAutomaticAccountLinking if we have totally commented out the linkAccountWithSession functions.
  • Make the helper functions in account linking recipe.ts as recipe functions so that they can be override (not all of them need to be - TBD)
  • Check how much time the backend APIs take with account linking.
  • Need to do account linking even if a user is being associated with a new tenant and that tenant already has a user (with a different ID) for the same login method and account info.
    • Automatic account linking is limited to tenants
  • In passwordless sign in tests in node sdk, we also wanna test that email is marked as verified. It should be on the recipe function level. This should happen in the recipeImplementation and not API implementation.
  • Change createdNewUser boolean in thirdparty and passwordless to be true iff there is a new primary user created or a new recipe user created (without linking).
    • this field has been renamed as createdNewRecipeUser
  • In passwordless node sdk, add test for email change not allowed when updating the user's email.
  • In passwordless node sdk, we need to add phone number tests such that account linking is enabled with email verificartion required, and make sure that simple use of phone number works (even though it won't be account linked).
  • If two accounts and linked, and then the user completely disables account linking - logging in via those two accounts should still yield the same user ID (i.e., account linking should still be enabled for those 2 accounts), but not happen for new accounts.
  • Add test to check that if the primary user is deleted, but still has linked recipe user, and then we try and do the password reset flow to create an email password user, does that work? (it should)
  • Core optimizations
    • Sign in/up should return the new user object and the used recipeUserId in all recipes
    • Link account should return the updated user object in status OK
    • linkAccount should return the updated user object in status RECIPE_USER_ID_ALREADY_LINKED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR
    • lower prio (not doing right now)
      • updateEmailOrPassword should return the updated user
      • updatePasswordlessUser should return the updated user
      • consumePasswordResetToken should return the updated user
  • Make backend backwards compatible (relying on the fdi-version header)
  • frontend sdk should be compatible with older fdi
  • backend sdk's shouldDoAutomaticAccountLinking's first arg should also take in the recipe user id
  • updateUser function in passwordless does not return an EMAIL_CHANGE_NOT_ALLOWED_ERROR similar to updateEmailOrPassword from email password?
  • The function call to updateUser in passwordless does not auto verify the email in case other login methods with the same email exist and that is verified? (See updateEmailOrPassword in email password recipe)
  • optimise the number of core requests and see how it affects rps of the core.
  • Email password issue -> email exist api returns that it exists, but sign up is still allowed.
  • The older get user by ID API in the core needs to return the right recipe user. For example, if we call the email password one, it may return the passwordless user instead of returning unknown_user_id
  • API for reset password in the backend SDK should be same as what it was in the previous FDI
  • FIx these issues: https://supertokens.slack.com/archives/D02B887A2MQ/p1693992251821039 and https://supertokens.slack.com/archives/D02B887A2MQ/p1693993173295319
  • fix in auth-react: getRedirectionURL on the frontend doesn’t get the user object in the context. So it’s impossible to tell if it’s a sign up or just an account linking - cause we only have context.isNewRecipeUser. Please fix.
  • in associateUserToTenant, if the input userId is a recipe user (that is linked to a primary user), what are you doing? Are you adding the tenantId to just the recipe user, or the primary user id? Are you checking for the account linking conditions for the whole primary user? If we are just adding the tenant to the recipe user id (which i think is the right thing to do), we should change the input in the node sdk to make it a RecipeUserId object
  • https://github.com/supertokens/supertokens-core/blob/feat/account-linking/src/main/java/io/supertokens/emailpassword/EmailPassword.java#L594 here, needs to also check if the user id of the loginMethod is the same as the input one. Cause this api accepts only recipe user id.
    Please check across all APIs that accept only recipe user id that you are doing such a check.
  • https://github.com/supertokens/supertokens-core/blob/feat/account-linking/src/main/java/io/supertokens/multitenancy/Multitenancy.java#L479, what happens if the input user id is = primary user id, but the recipe user that has that primary user is is deleted? This can happen when calling unlink on a primary user id that has other login methods.
  • isEmailChangeAllowed takes a tenant id -> but it should not. Cause even if this returns true for a specific tenant, it may return false for another one that the user belongs to.. so the dev will ALWAYS have to loop through all tenants anyway.
@rishabhpoddar rishabhpoddar added the enhancement New feature or request label May 15, 2022
@rishabhpoddar
Copy link
Member Author

rishabhpoddar commented May 16, 2022

Extra notes:

  • Only new and verified accounts can be linked automatically (whilst they are unverified, they are not linked)
  • Account delinking is not something that happens automatically.
  • Automatic account linking is disabled by default
  • If the user has disabled account linking and then enables it, then existing accounts that could have been linked will not get linked. In the opposite situation, we do not unlink accounts.

@rishabhpoddar
Copy link
Member Author

rishabhpoddar commented May 17, 2022

Data structure to handle account linking info:

userId -> [{
   recipeId: string,
   recipeUserId: string,
   verifiedIdentifyingIds: set<>, // NOTE: these should be dynamically calculated based on email verification table
   unverifiedIdentifyingIds: set<>,  // NOTE: these should be dynamically calculated based on email verification table
}]
  • combination of (recipeId, recipeUserId) is unique per primary user.
  • verifiedIdentifyingId and unverifiedIdentifyingId can either be an email or phone number.
  • The reason that these IDs are arrays is that some recipe like passwordless can have two identifying info (email and phone number).

Email verification

  • Email verification cause linking of accounts or creation of a primary user ID

Sign up changes

  • If the email / phone number is already verified during sign up, then we find the associated primary user ID and add this new recipe to that ID's array. If there is no primary user that exists for this ID, you make a new primary user (totally different user ID)
  • If it's not verified and the user has NOT enabled email verification globally, then you DO NOT create a primary user ID for this sign up at all.
  • Sign up is disallowed if the input email === to email of another primary user such that ALL linked accounts of that primary user containing that email is unverified. This is done because:
    • If we allow sign up i this situation, it will result in the new account to be linked to the unverified emails' primary account. In this case, a legit user's account (who just tried to sign up), will get linked to an attacker's account.

Sign in changes

  • We disallow sign in for thirdparty is the email has changed on the thirdparty provider's side such that it also belongs to another primary account.

Fetching user ID

  • If the current login method used is not linked to anything, then return the recipe ID in getUserId function
  • If the current login method used is linked, then we return the primary user ID, else we return the recipe level ID.

Post sign up / in callback

  • These remain the same.
  • Additionally, we provide onAccountLinked and onAccountUnlinked

Changing user info

  • Users cannot change their email / phone number to another one if that email / phone number belongs to another primary user ID.
  • Change to a user's info in one recipe DO NOT propogate to another recipe automatically. This in turn means that someone could, over time, have multiple verified identifiers linked to one account. So if there is a new sign up with ANY one of those identifiers, that account will be linked to this existing one)

Disabling / enabling account linking

  • Automatic account linking only applies to new accounts after account linking has been enabled. So if there was an old account with email a, and then account linking was enabled, and then a new account is created with email a, that new account will NOT be linked to the older account, but if another new account is created with a, then it will be linked to the previous one.
  • If account linking is disabled, then any new account will not be linked automatically even if it could have been linked.
  • We should also have a function which the user can override to decide if a user should be considered for account linking during sign up. By default, this function will just be equal to the global config for if account linking is enabled or not.

Migration

  • Existing accounts won't have an associated primary user - so will not automatically be linked anyway. So there should be no data migration needed -> just creation of new tables and logical updates

Internal vs external users

Old - 1 (for reference)

When someone already has existing users and they want to migrate those users into supertokens, they have to call the signUp function for that existing user. This will create a new user in supertokens with its own userId. To facilitate easier migration, we want to make sure that this user's ID is the same as the external user ID. For this, we should allow the developer to create a primary user ID (with their own userID = external userId) that is associated with the new account. The email / phone number of the new account will go in the verifiedIdentifyingId array of this entry.

Note that this account linking is done manually and therefore can be done even if the account linking feature is disabled.

The catch to this is that if there already exists a primary user account with the same identifying info, then this new account will be linked to that. In this way, the user's ID cannot be set to the existing external user ID - but this is OK since it means that this external user already somehow had an account with supertokens via a different login method.. which should be impossible?

Old - 2

If account linking is enabled

We have a table already for user ID mapping. When a user is being migrated, we will consider their identifiers as verified immediately, and so their getUserId function will return the primary user ID. This means, that we can map the primary User ID to the external userID in the user ID mapping table.

Now a problem is that there might already be a primary user ID mapping in that table. This can happen if there existed a user in the older system who logged in with a different method but with the same email as the user being migrated now (but account linking was not enabled in their older system). In this case, the user ID mapping table will not allow the same primary user ID to be inserted again (unique constraint on the supertokens user ID column). So here, we can either:

  • Link the new account with the older one and ignore the new external user ID; OR
  • Create a new primary user ID for this new account and map this new primary user ID to the external user ID. This way, there will be two primary user IDs with the same email, yet they will be delinked (just like in the older system). This situation should arise only during migration. In this case the problem is that if there is a new sign up with the same email, which one should it be associated with (so we cannot allow this situation at all)?; OR
  • Even if account linking is enabled, we totally ignore it and not create primary users for migrated users at all. This means that any future signs ups with the same email will not be linked to the existing user account.; OR
  • We associate the new user with the existing primary account, but mark them isFullyLinked as false for them. This means their account is not linked, so we will map their recipe user ID to the external user ID. This implies that any new user sign up with the same email will get associated with the same external user ID that was migrated first (I think i prefer this one).

New

Let take a scenario where two external user accounts are linked to the same externalUserId. Which means an externalUserId has multiple login methods for the user. Now when importing such user, we'll end up creating two recipe users (linked to the same primaryUserId), with primaryUserId having external userId mapping.

E.g.

  • external user 1:
    • id: EU1
    • login info: L1
  • external user 2:
    • id: EU1
    • login info: L2

When importing EU1 with L1, recipe user R1 is created with primaryUserId P1 and external userId mapping with EU1.
When importing EU1 with L2, recipe user R2 is created. Here, because EU1 is also associated with P1, we link R2 to P1.

If there is an email password user sign up, we create a recipe user ID R1 with no primary user ID. Now if the user maps R1 to E1 (external user ID), and then verifies the account (which creates a primary user ID, P1 === R1), then it will still work since now the primary user ID will be mapped to the E1. One problem here is that if R1 needs to be deleted, but P1 is also linked to another account, then we still want to keep the user ID mapping. So for this, we need to make the user ID mapping reference the primary user ID OR recipe user ID. TODO: db testing

Deleting a user

If the user ID belongs to a primary account, all the linked accounts are also deleted. If the user ID belongs to an individual recipe account, only that account is removed and delinked.

Note that we need to delete from reset password table as well explicitly even if email password user does not exist - cause password reset tokens may exists for a primary account.

Relation to all_auth_recipe_users table

  • When a user signs up, we add them to the all_auth_recipe_users account with their recipe ID
  • If account linking is not done for this user, then nothing changes.
  • New column in this table for primary user ID.

@rishabhpoddar
Copy link
Member Author

rishabhpoddar commented May 22, 2022

Change required to session and functions that take userID

The session object's getUserId function will return the primary user ID function. There will be an additional function called getRecipeUserId which will return the recipe user ID of used for the current method of login.

All functions that currently take a userID (that need a recipe userID) should check if the input userID is a primary user id or not. If it is a primary user ID, it would thrown an error asking the user to give a recipe user ID.
Reason: The recipeUserId will be equal to the primaryUserId

In case an account is not linked, the primary user ID would be equal to the recipe user ID, so these functions would all still work.


EDIT: OLD -> see next next section in this comment
The way this will be stored in the session is that the userId field in it will have <recipe user id>|<primary user id>. The delimiter will be used to separate out the two user IDs. When we try to split based on the delimiter, we should be careful to only consider the last element of the array as the recipe user ID. If the size of the array is more than 2, then we should join back everything except for the last element with a |. This is because we we will allow users to pass their own primary user ID.

If the primary user id === recipe user ID <--> the userId will just be that ID and nothing else.

When making a JWT, we will always just use the primary userID for it (just like it's happening now anyway).


EDIT: (this is what we decided to do):

  • We should add a recipeUserId field which will always be the recipe user ID
  • The sub can be primary user ID (if linked) or recipe user ID (if not linked)
  • revokeSession needs to change to allow work with either recipeUserId or primaryUserId. This means that we do a query like delete from session_info where primaryUserId = ".." OR recipeUserId = "..."

@rishabhpoddar
Copy link
Member Author

rishabhpoddar commented May 24, 2022

Change to verify email function interface

During email verification, we want to upgrade the user's session in the following case:

  • Checking if an email is verified API - this should upgrade the user's current session if it does not reflect their new primary user ID
  • After email verification is successful

Right now, these recipe functions take the email / token directly. Just having this, there is no way to create a new session with the new userID. So instead, we should also make them taken an optional session object which it can use to upgrade the session if needed. The reason it is optional is because if the user is calling these function offline, they won't have the session object. -> The session change happens in the API level and not recipe function level.

Change to refreshing flow

Nothing changes here because when we link accounts, we revoke all sessions belonging to the recipeUser, if the recipe user id != primary user ID. In this case, a session refresh with those sessions will automatically log out the user

Change to session container

We now also need to be able to create a new session from an existing session. This requires that we get access to all the info that is required to create a new session from an existing session container object. In some cases, this is already there, but in some frameworks (like in python), we do not store all the info - for example, the request object is not stored in the session container.

the above point makes no sense.. what was that about?
-> for functions like refresh session, if primary user id has changed, we still have access to the request object. But for functions like email verification, we may not have the request object in python sdk which would be required to create a new session once the email is verified. This is what the above comment meant.

In both the cases above, we want to create a new session as opposed to modify the userID of the older session cause modifying the userID of the older session will immediately invalidate the refresh token stored on the frontend. And if the new refresh token doesn't reach the frontend (cause maybe of some network failure), then the user will be logged out on refresh.

The creation of the new session happens in the API, so it may work:

  • Existing access token payload should not be used when creating the new session. If the user wants to transfer the content of the existing session as well, they can override the email verification GET and POST APIs to manually update the new session with the contents of the older session.

@rishabhpoddar
Copy link
Member Author

rishabhpoddar commented May 27, 2022

Affect on delete user

The existing function in supertokens backend SDK would be good enough for this. If that function is given a primary user ID, it would delete all the linked accounts and the primary account (all in one transaction)

If the input is a recipe user ID, then it would only delete that account and remove it from linked accounts. If that is the only account in the linked accounts, then we delete the primary user ID as well

We should explicitly also delete password reset tokens for given userId because it may have password reset tokens for the primary account if emailpassword account didn't existed when creating the token. Incase we are only deleting recipeUserId then we will delete password reset token only if the recipe is email password

@rishabhpoddar
Copy link
Member Author

rishabhpoddar commented May 27, 2022

Additional functions created

// if isPrimaryUser object is false, loginMethods will always contain
// one item in the array which would corresponds to the recipe user
type User = {
    id: string; // primaryUserId or recipeUserId
    timeJoined: number; // minimum timeJoined value from linkedRecipes
    isPrimaryUser: boolean; //  something that we use and the user should not really care about
    emails: string[],
    phoneNumbers: string[],
    loginMethods: {
        recipeId: string,
        recipeUserId: string,
        timeJoined: number,
        verified: boolean,
        email?: string,
        phoneNumber?: string,
        thirdParty?: {
            id: string;
            userId: string;
        }
    }[]
}

type RecipeLevelUser = {
   id: "", // this will always be the recipe user ID
   timeJoined: ...,
   primaryUserId?: "",
   ... // email or phone number or thirdPartyInfo
}

type AccountInfo = {
    email: string
} | {
    thirdpartyId: string,
    thirdpartyUserId: string
} | {
    phoneNumber: string
}

type AccountInfoWithAuthType = {
    authType: "emailpassword" | "passwordless",
    email: string
} | {
    authType: "thirdparty",
    thirdpartyId: string,
    thirdpartyUserId: string
} | {
    authType: "passwordless",
    phoneNumber: string
}

// this is there cause we use this in the shouldDoAccountLinking callback and that
// function takes in an input user. In case of thirdparty, if the input user doesn't have email,
// it will be strange for the developer, so we add an email to the "thirdparty" type as well. 
type AccountInfoAndEmailWithAuthType = {
    authType: "emailpassword" | "passwordless",
    email: string
} | {
    authType: "thirdparty",
    thirdpartyId: string,
    thirdpartyUserId: string,
    email: string
} | {
    authType: "passwordless",
    phoneNumber: string
}

SuperTokens.getUser(userId: string) => User | undefined // userId can be primary or recipe
SuperTokens.listUsersByAccountInfo(info: AccountInfo) => User[] | undefined
SuperTokens.getUserByAccountInfo(info: AccountInfoWithAuthType) => User | undefined

/*
- Both recipeUserId and primaryUserId must exist.
- recipeUserId should not be linked to any other account.
*/
AccountLinking.linkAccounts(recipeUserId: string, primaryUserId: string) => Promise<{status: "OK", user: User} | {status: "ACCOUNTS_CANNOT_BE_LINKED_ERROR", reason: string}>

/*
- If recipeUserId is equal to it's primary user ID, we should delete the recipe user ID.
*/
AccountLinking.unlinkAccount(recipeUserId: string) => Promise<{status: "OK" | "PRIMARY_USER_ID_NOT_FOUND_ERROR"}>

/*
- Creates a new primary user ID for the input ID such that the primary user ID === recipe user ID
- Input recipe user ID must not be associated with any primary ID already.
*/
AccountLinking.createPrimaryUser(recipeUserId: string) => Promise<{status: "OK", user: User} | {status: "PRIMARY_USER_ALREADY_EXISTS_ERROR", reason: string}>

AccountLinking.canLinkAccounts(recipeUserId: string, primaryUserId: string) => Promise<{canLink: false, reason: string} | { canLink: true }>
  • For getUser, if the input is a recipe user ID, and if that input has an associated primary user ID, the function will return the whole user object where the id is the primary user ID. This is true even if the input recipe user ID != primary id

@rishabhpoddar
Copy link
Member Author

rishabhpoddar commented May 28, 2022

Post sign up / in callback (Ignore this comment)

Even if there is a recipe level sign up, it may not mean that post email verification, their user ID might change (as is in the case of email password login). We want the dev to run their post sign in / up logic only after account linking has been fully finished.

This calls for different post sign up callback. We want these to be used even if account linking is disabled. Something like:

EmailPassword.init({
   callbacks: {
      postSignUp: (user, session, formFields, userContext) => Promise<void>,
      postSignIn: (user, session, userContext) => Promise<void>,
   }
})

ThirdParty.init({
   callbacks: {
      postSignUp: (user, session, authCodeResponse, userContext) => Promise<void>,
      postSignIn: (user, session, authCodeResponse, userContext) => Promise<void>
   }
})

Passwordless.init({
   callbacks: {   
      postSignUp: (user, session, preAuthSessionId, userContext) => Promise<void>,
      postSignIn: (user, session, preAuthSessionId, userContext) => Promise<void>
   }
})

ThirdPartyEmailPassword.init({
   callbacks: {
      postEmailPasswordSignUp: (user, session, formFields, userContext) => Promise<void>,
      postEmailPasswordSignIn: (user, session, userContext) => Promise<void>,
      postThirdPartySignUp: (user, session, authCodeResponse, userContext) => Promise<void>,
      postThirdPartySignIn: (user, session, authCodeResponse, userContext) => Promise<void>
   }
})

ThirdPartyPasswordless.init({
   callbacks: {
      postPasswordlessSignUp: (user, session, preAuthSessionId, userContext) => Promise<void>,
      postPasswordlessSignIn: (user, session, preAuthSessionId, userContext) => Promise<void>
      postThirdPartySignUp: (user, session, authCodeResponse, userContext) => Promise<void>,
      postThirdPartySignIn: (user, session, authCodeResponse, userContext) => Promise<void>
   }
})
  • If account linking is disabled, these will be called from the API directly with the relevant input
  • If account linking is enabled:
    • If the account is fully linked already in sign in / up, then we can call these functions
    • If account is not linked, we need to store the "extra info" (like formFields, authCodeResponse...) in session data (in db against the session), and then when it does get fully linked, we can retrieve those and put them in the callback (then delete them from the session's data - or since we will be creating a new session anyway, we can just let that data be)
  • The problem with this is that from a dev's point of view, it may be difficult to know when this is called - they may expected the sign up stuff to be called when the user is created in supertokens. Maybe instead, we can have a postAccountLinked callback which they should use instead of post sign up (only applicable when account linking is enabled).

@rishabhpoddar
Copy link
Member Author

rishabhpoddar commented May 28, 2022

Enabling / disabling automatic account linking

By default, we want to keep it disabled (due to the complexity it presents). However, we allow users to enable it on a per recipe level that allows them to configure if an account should be linked to another or not. This would give the flexibility to the user to enable / disable account linking on a per user basis. Accounts that were linked in the past will remain linked even after the user has disabled automatic account linking.

The function on a per recipe level can look like this:

AccountLinking.init({
   shouldDoAccountLinking: (newAccountInfo: AccountInfoAndEmailWithAuthType, primaryUser: User | undefined, session: SessionContainer | undefined, userContext: any) => Promise<{shouldAutomaticallyLink: false} | {shouldAutomaticallyLink: true, shouldRequireVerification?: boolean = true}>
})
  • primaryUserId is optional because during signup it may or may not be there.
  • session: session will be undefined during signup. But during account linking post login, it will be there.

Note that this function only governs if automatic account linking should happen or not. If this function returns false and the user does manual account linking, the account linking will succeed.

@rishabhpoddar
Copy link
Member Author

rishabhpoddar commented May 28, 2022

Fetching info about user from non auth recipes

Ideally, there should be no extra info associated with the recipe user ID, and all info should be associated with the primary user ID. So I think we can not do anything special in here.

We have added an extra primaryUserId in the user object type of the recipe level functions.

Tying into session claims

Since the order of the failure of the claims depends on the order in which the user gives the claims, if email verification is not first always, then it may cause a situation where other claims fail even if they are not supposed to, just cause the user ID of this user is not yet the primary user ID.

In order to solve this, we can reorder the claims to always have email verification first.

@rishabhpoddar
Copy link
Member Author

rishabhpoddar commented May 29, 2022

Affect on user pagination and count functions

The all_auth_recipe_users now contains users that are recipe user IDs (non linked) and primary user IDs.

So the return type of the pagination functions change to:

{
    users: User[];
    nextPaginationToken?: string;
}[]

SQL Queries (For pagination and count)

  • first query (without pagination token)
SELECT 
	*
FROM (
    SELECT
	    MIN(recipe_user_id) as recipe_user_id, primary_user_id, timejoined
    FROM supertokens.all_auth_recipe_users
    WHERE
	    primary_user_id is not null
    GROUP BY primary_user_id
    UNION
    SELECT
	    *
    FROM supertokens.all_auth_recipe_users
    WHERE
	    primary_user_id is null
) as result
ORDER BY timejoined, recipe_user_id
LIMIT X;
  • sql query with pagination token info
SELECT 
	*
FROM (
	SELECT
		MIN(recipe_user_id) as recipe_user_id, primary_user_id, timejoined
	FROM supertokens.all_auth_recipe_users
	WHERE
		primary_user_id is not null
		and
		(
			(
				primary_user_id > 1
				and
				timejoined = 1
			) OR
            (
				timejoined > 1
			)
		)
	group by primary_user_id
	UNION
	SELECT
		*
	FROM supertokens.all_auth_recipe_users
	WHERE
		primary_user_id is null
		and
        (
			(
				recipe_user_id > 3
				and
				timejoined = 1
			) OR
            (
				timejoined > 1
			)
		)
) as result
ORDER BY timejoined, recipe_user_id
LIMIT X;
  • user count sql
SELECT 
	COUNT(*) as total
FROM (
    SELECT
	    MIN(recipe_user_id) as recipe_user_id, primary_user_id, timejoined
    FROM supertokens.all_auth_recipe_users
    WHERE
	    primary_user_id is not null
    GROUP BY primary_user_id
    UNION
    SELECT
	    *
    FROM supertokens.all_auth_recipe_users
    WHERE
	    primary_user_id is null
) as result;

@rishabhpoddar
Copy link
Member Author

rishabhpoddar commented May 29, 2022

Known issues:

  • (RESOLVED) If the user signs up with email password and their account is not yet verified, the user ID returned will != the one after their account is verified. This means if the developer saves any application data in the middle, it will be lost. But this is not such a big problem as this can only happen if the email verification mode is switched on for the app - so account email verification is bound to happen quickly.
    • This is not true if we are creating a new primary user id. Otherwise, the user will get a postAccountLink callback where they can do data migration
  • If I sign up with email a and then i change it to email b (which doesn't belong to me), and it's not verified (so it's in the unverifiedIdentifyingId array, then the actual person who has email b cannot sign up.
    • We are okay with this problem
  • (RESOLVED) Migrating users may not end up getting external ID if a primary account already exists with the a randomly generated ID.
    • primary user Id are no longer auto generated. So they can't clash as they are unique
  • (RESOLVED) Because of the possibility of a row being deleted / changed from all_auth_recipe_users, there is a chance that the total user count will be slightly higher than what is actually the case, and that pagination results may be inconsistent.
    • The query to get user count will change
  • (RESOLVED) We do not allow sign ups if the email is already registered from another sign up method and is in the unverified ids array of that method's primary user - should we instead allow sign up but not link the accounts? The new signed up account will just not be linked automatically ever in that case (unless manually linked)
    • this will lead to a bad UX because the actual user will not be able to link their new account with another account at all.
  • (RESOLVED) It is possible that during social sign in, a user's email may have changed than what it was when they signed up. Normally this would change to a unique one, but what if some other primary user already exists with this same email? That we cannot update this user's mapping to that email.. in which case it becomes an issue.
    • Right now, we solve this y disallowing sign in and sending an error message to contact support.
  • (RESOLVED) When a session's userId needs to be changed (post email verification), then it creates a whole new session and keeps the older session alive. Is that OK?
    • during linking accounts, we remove all older sessions.
  • (RESOLVED) If a user signs up and has not verified their email (so their account is not linked yet), and then if the dev switches off email verification, then this user will be able to use the app. However, the post sign up callback will never be called for this user.
    • No post signup callback anymore
  • (RESOLVED) Should we map external user IDs via account linking feature? Or a different feature?
    • This is already a different feature
  • (RESOLVED) If a user has not signed up with email password and they have done with social login, and if they try and reset their password, they will not get an email
    • Now they will based on our flows
  • Reordering of claims validators to have email verification first. Is that a good idea?
    • When the user adds their own claims (in verify session or in global function), we always tell them to add the recipe added global validator claims first, and then their claims. This means, that the email verification claim validator will be added first and then their claims validators (like user roles validators). So this would not be an issue unless people reorder that validators array or if in the future we have other recipes that add validators and the user initialises them first.
    • For now, we decided to not solve this and wait until people have issues with this before taking any action to build a priority system.

@rishabhpoddar
Copy link
Member Author

rishabhpoddar commented Jun 3, 2022

Change to reset password flow

If attacker signs up with social provider with email a (which they have access to), and then creates another email password account with email a (and verifies it) that is linked. Then they change their email to email b (unverified). Then the real user who owns email b tries to sign up with email password (email b), it will tell them that the email already exists. In this case, if they go through the reset password flow and finish that, then they can login, and verify their email. In this case, the attacker will now be linked to the account in which b is verified.

This has now been accounted for in our flows

@rishabhpoddar
Copy link
Member Author

rishabhpoddar commented Jun 3, 2022

New emails

On account linking, we should send an email to the user saying that they just logged in via XYZ, and if it wasn't them, then they should contact support for help or visit . In that URL, we should tell the dev to give the option of unlinking accounts.

Decided: We can do this at a later time. For now, it's not needed

@rishabhpoddar rishabhpoddar changed the title Implement account linking and user ID mapping Implement account linking Jul 9, 2022
@rishabhpoddar
Copy link
Member Author

rishabhpoddar commented Aug 11, 2022

Additional considerations:

  • Implicitly creating email password user (if they have an existing account already with the identifying info with another recipe) if reset password is being done -> this also implies that their email is verified cause they just reset their password -> which means we can link it immediately.
  • Allowing social login user to set a password and then create an email password user which can be linked.
  • Allow user to do account linking even if email not verified, regardless of security risks.
    • sign up
    • account linking post login
    • reset password
    • refreshing session
    • delete user
    • email verification.
  • Allow account linking on a set of accounts (for example social login and not email password login)

@rishabhpoddar
Copy link
Member Author

rishabhpoddar commented Aug 16, 2022

If the account to be linked has existing non auth recipe info (like in metadata), that info is kept as is and nothing is done with it. We don't even check if such info exists since 99% of the time, no info will exist anyway.

@bhumilsarvaiya
Copy link
Contributor

bhumilsarvaiya commented Aug 25, 2022

What if the user is calling isEmailVerified with a primaryUserId and email (which belongs to non primary account)? This will return false even if the email is verified (cause the entry wont exist in the email verification db). This can happen if the user makes the mistake of passing primaryUserId instead of recipeUserId. Should we account for this?

We should just ignore this kind of mistake for now.

@bhumilsarvaiya
Copy link
Contributor

bhumilsarvaiya commented Sep 2, 2022

Account linked callback:

onAccountLinked(primaryUser: User, newAccountInfo: RecipeLevelUser & {recipeId: "..."}) => Promise<void>

supertokens.init({
   recipeList: [
      AccountLinking.init({
          onAccountLinked,
      })
   ]
})

This is called whenever our SDK calls SuperTokens.linkAccounts(...) and that returns success and an account has actually been linked (vs just a primary user ID has been created). This function is called by our SDK in:

  • sign up
    • normal sign up
    • account linking post login
  • email verification post
  • when user calls linkAccounts manually

Account unlinking callback:

onAccountUnlinked(primaryUser: User, unlinkedAccount: RecipeLevelUser & {recipeId: "..."}) => Promise<void>

This is called in the unlinkAccounts function

@supertokens supertokens deleted a comment from rishabhpoddar Sep 2, 2022
@supertokens supertokens deleted a comment from rishabhpoddar Sep 2, 2022
@bhumilsarvaiya
Copy link
Contributor

bhumilsarvaiya commented Sep 22, 2022

Table structure changes

  • Password reset table (because we allow doing password reset flow even if there is no email password user but when the user has a different account):
    • Foreign Key constraint from reset password table to email password table needs to be dropped
    • Add an additional column for email and make it part of primary key
  • User ID mapping foreign key constraint for all_auth_users table should be on primary user id column OR recipe user id column -> TODO - how is this possible?
  • all_auth_recipe_users will need a new column for primary user ID
  • session_info table should have a recipe_user_id column as well now
  • A new table called recipe_account_to_link which will contain recipeId and primaryUserId (both primary key). We insert into this table during post login account linking if the account to link requires email verification.

Table structure for primary_user_to_recipe_user

CREATE TABLE IF NOT EXISTS primary_user_to_recipe_user(
    primary_user_id CHAR(128),
    recipe_user_id CHAR(128) NOT NULL,
    recipe_id VARCHAR(128) NOT NULL,
    time_joined BIGINT NOT NULL,
    PRIMARY KEY (recipe_user_id)
);

CREATE INDEX primary_user_to_recipe_user_primary_user_index ON primary_user_to_recipe_user(primary_user_id, time_joined);
  • Note that primary_user_id can be NULL.
  • We have the time_joined cause it helps with pagination.

@bhumilsarvaiya
Copy link
Contributor

bhumilsarvaiya commented Sep 27, 2022

To Discuss

  • concept of account linking and how it works
  • changes to sign-up flow
  • user object
  • function interfaces for account linking recipe
  • user pagination function change
  • post account linking callback
  • enabling account linking per recipe level
  • new api for post login account linking (POST /user/account/link -> requires a session + info about other account)
    • if the user wants to do something post sign-up, they will have to do in normal sign-up API as well as this one
  • affect on delete user
  • changes for dashboard (Only with Nemi)
  • changes for session claims (Only with Mihaly)
  • createdNewUser boolean
  • session userId format
  • userId mapping (Only with Joel)
  • Should an email be sent when account is linked? -> we can add later if required..
  • disabling signup if they have unverified identity linked to an existing primaryUserId

@rishabhpoddar
Copy link
Member Author

rishabhpoddar commented Sep 29, 2022

Discussion notes

  • Make it very clear that the user ID will change.
  • Phone number with password -> phone number is not verified.
  • Do not directly tie to email verification recipe, but instead to the recipe specific way of knowing if the info is verified or not.
  • “Adaptive account linking” -> even if email verification is not done, then the user’s account can be linked cause maybe they used the same device etc..
  • (which option to pick) if b is linked and verified, and the user changes their email, we should:
    • option 1) not allow changing of email; OR
    • option 2) keep the email as b, but change it only verified. OR
    • option 3) unverify it, but delink it.
    • Decision: We picked none of these options and kept with disabling sign up cause this happens in a rare enough situation only.

Why is soft linking done?
- In email password (or anything that yields and unverified email) sign up, we want to save the linked status there and not during email verification because we want to prevent sign ups using other methods with the same email whilst this account is unverified and is a candidate for being linked.
- TODO: Why else? Do we even require this, especially if we go with option 2 in the above point.
We no longer have this concept of soft linking

What to implement

On sign up without user explicit consent: -> automatic (not doing now, but just architecting for it)

By user on post sign up (link github to my existing account) -> user driven manual

By developer using linkAccount themselves: -> developer driven manual

Account deduplication -> Preventing account duplication (i.e. if email ID with another recipe already exists, prevent signup with same email ID with another recipe or prompt the user to try signing up with original recipe method) - similar to what atlasian does.

@rishabhpoddar
Copy link
Member Author

Changes to API interface and recipe interface

  • Email verification API interface:
    • isEmailVerifiedGET -> return a session as well (cause it might be a new account linked session)
    • verifyEmailPOST -> return an optional session as well (if the input had a session, this will return a session) (cause it might be a new account linked session)

@rishabhpoddar
Copy link
Member Author

rishabhpoddar commented Nov 1, 2022

createdNewUser boolean

This should be true if (account linking enabled && new primary user created) || (account linking disabled && sign up called).

This implies that even if the account is not fully linked yet (as is the case with emailpassword recipe on sign up), we still set createdNewUser boolean to false.

We can introduce a new boolean like createdNewRecipeUser:

  • createdNewUser: false, createdNewRecipeUser: false -> sign in
  • createdNewUser: false, createdNewRecipeUser: true -> sign up post login || sign up and account linked (verified or not)
  • createdNewUser: true, createdNewRecipeUser: false -> not possible
  • createdNewUser: true, createdNewRecipeUser: true -> sign up with new primary user ID creation || sign up without account linking

@supertokens supertokens deleted a comment from bhumilsarvaiya Nov 1, 2022
@supertokens supertokens deleted a comment from bhumilsarvaiya Nov 1, 2022
@bhumilsarvaiya
Copy link
Contributor

bhumilsarvaiya commented Nov 21, 2022

Core API changes:

TODO

-> For the APIs that returns recipeUserId, it should default to the userId

API: /recipe/signup
Recipe: emailpassword
METHOD: POST
CHANGE:
    - the returned user object will have the same structure as the global user.

API: /recipe/user
Recipe: core
METHOD: GET
CHANGE:
    - the returned user object will have the same structure as the global user.
    - we will also remove recipe level /recipe/user GET APIs

API: /recipe/user
Recipe: emailpassword
METHOD: PUT
ASSERT:
    - the input userId must be a recipeUserId pointing to an email password recipe - else return UNKNOWN_USER_ID_ERROR
REVIEW:
    - the logic might need to account for not allowing user to change the associated email if another primary user has the same email.

API: /recipe/signin
Recipe: emailpassword
METHOD: POST
CHANGE:
    - the returned user object will have the same structure as the global user.

API: /recipe/user/password/reset/token
Recipe: emailpassword
METHOD: POST
ASSERT:
    - the input userId should be either recipeUserId or primaryUserId. If primaryUserId has only one associated recipe user, do password reset for that. Else throw 400 error
    - TODO -> What are the list of changes here?

API: /recipe/user/password/reset
Recipe: emailpassword
METHOD: POST
CHANGE:
    - This API will just go away, and be replaced with /recipe/user/password/reset/token/consume (see below)

API: /recipe/user/passwordhash/import
Recipe: emailpassword
METHOD: GET
CHANGE:
    - the returned user object will have the same structure as the global user.

API: /recipe/signinup
Recipe: thirdparty
METHOD: POST
CHANGE:
    - the returned user object will have the same structure as the global user.
    - If the input thirdPartyInfo is associated with an existing primary user, and the input email is also associated with another primary user, then we return {status: `SIGN_IN_NOT_ALLOWED`, description: "..."}`

API: /recipe/session
METHOD: POST
CHANGE:
    - recipeUserId in input
    - accessToken payload should contain recipeUserId

API: /recipe/session
METHOD: GET
CHANGE:
    - accessToken payload should contain recipeUserId

API: /recipe/session/verify
METHOD: POST
CHANGE:
    - recipeUserId in returned session object
    - accessToken payload should contain recipeUserId

API: /recipe/session/refresh
METHOD: POST
CHANGE:
    - recipeUserId in returned session object
    - accessToken payload should contain recipeUserId
    - During token theft detection error, the session object should also have recipeUserId

API: /recipe/session/user
METHOD: GET
CHANGE:
    - input userId can be either primaryUserId or recipeUserId

API: /recipe/session/regenerate
METHOD: POST
CHANGE:
    - recipeUserId in returned session object

API: /users
METHOD: GET
CHANGE:
    - user object is changed. check account linking PR

API: /user/remove
METHOD: POST
CHANGE:
    - input userId can be either primaryUserId or recipeUserId
    - new input boolean removeAllLinkedAccounts

API: /recipe/user/email/verify/token
METHOD: POST
CHANGE:
    - input userId must be either a recipeUserId
  • For API that links account, the core will NOT do checks that are done in the canLinkAccount function cause we want the user to be able to override the canLinkAccount function to define their own logic if they wish to, and having checks in the core will essentially make their override void.
route: /recipe/accountlinking/users
method: get
query: {
    primaryUserIds: string[]
} | {
    recipeUserIds: string[]
}
response: {
    status: OK,
    userIdMapping: {
        [recipeUserId: string]: string | null
    }
} | {
    status: OK,
    userIdMapping: {
        [primaryUserId: string]: string[]
    }
}
details:
    - if primaryUserIds is passed, the keys in userIdMapping will be primaryUserIds
    - if recipeUserIds is passed, the keys in userIdMapping will be recipeUserIds
    - if recipeUserIds is passed and there is no primaryUserId found for any recipeUserId, the value for that recipeUserId in userIdMapping would be null
    - if recipeUserIds is passed, for any recipeUserId that doesn't really exists, the recipeUserId will not be present in the userIdMapping
    - if primaryUserIds is passed and there is no recipeUserId found for any primaryUserId, the value for that primaryUserId in userIdMapping would be an empty array
    - if primaryUserIds is passed, for any primaryUserId that doesn't really exists, the primaryUserId will not be present in the userIdMapping

-----

route: /recipe/accountlinking/user
method: put
body: {
    recipeUserId: string
    recipeId: string
    timeJoined: number
}
response: {
    status: ok
    createdNewEntry: boolean
}
details:
    - insert into a new table which used to account linking purpose
    - if the recipeUserId already exists in the table, createdNewEntry will be false, else true

-----

route: /users
method: get
update:
    - type of user object returned

-----

route: /recipe/accountlinking/user/primary
method: post
body: {
    recipeUserId
}
response:  {
    status: "OK";
    user: User;
} | {
    status:
        | "RECIPE_USER_ID_ALREADY_LINKED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR"
        | "ACCOUNT_INFO_ALREADY_LINKED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR";
    primaryUserId: string;
    description: string;
}
details:
    - for a given recipeUserId, create a new primaryUserId (primaryUserId will be equal to recipeUserId)
    - if primaryUserId is not created due to any reason, return the recipeUser

-----

route: /recipe/accountlinking/user/link
method: post
body: {
    recipeUserId: string;
    primaryUserId: string
}
response:  {
    status: "OK" | string;
}
details:
    - for a given recipeUserId and primaryUserId, update the row in new account linking table (add primaryUserId for recipeUserId)
    - if recipeUserId | primaryUserId doesn't exist or the linking was not done, return a string status stating what went wrong

-----

route: /recipe/accountlinking/user/unlink
method: post
body: {
    recipeUserId: string;
    primaryUserId: string;
}
response:  {
    status: "OK"
}
details:
    - for a given recipeUserId and primaryUserId, update the row in new account linking table (remove primaryUserId for recipeUserId)
    - if recipeUserId | primaryUserId doesn't exist or the unlinking was not done, return a string status stating what went wrong

-----

route: /recipe/accountlinking/user
method: get
query: {
    userId: string
}
response: {
    status: "OK,
    user: User
}
details:
    - fetch user object for given userId
    - userId can be primaryUserId or recipeuserId. First treat it as primaryUserId. if no user is found, treat it as recipeUserId


route: /users/accountinfo
method: get
query: {
    recipeId?: "emailpassword" | "passwordless";
    email: string;
} | {
    recipeId?: "thirdparty";
    thirdpartyId: string;
    thirdpartyUserId: string;
} | {
    recipeId?: "passwordless";
    phoneNumber: string;
}
response: {
    status: "OK,
    users: User[]
}
details:
    - fetch user object for given info. If recipeId is passed, on get account for that recipeId

route: /user/remove
method: post
update:
    - if the recipe user is the only acount linked to a specific primaryUserId, remove all data related to the primaryUserId


route: /recipe/accountlinking/user/linked_or_linkable
method: get
query: {
    userId: string // recipeUserId
}
response: {
    status: "OK,
    user?: User
}
details:
    - for input recipeUserId, get primary user which is linked to the recipeUserId or which is supposed to be linked to recipeUserId or can be linked (because of the identifying info associated with the recipeUserId)
    - if no primaryUser found, return user will be undefined

route: /recipe/user/password/reset/token/consume
method: post
body: {
    token: string
} 
response: {
   status: "OK";
   userId: string;
   email: string;
} | { status: "RESET_PASSWORD_INVALID_TOKEN_ERROR" }
details:
    - this will only consume the token in the core and not change the password

@bhumilsarvaiya
Copy link
Contributor

Functions name change discussion (node SDK)

function name 1:

  • getUserByAccountInfo
  • getUserByAccountInfoWithLoginMethod

@nkshah2
Copy link
Contributor

nkshah2 commented May 1, 2023

List of TODO (ignore this checklist and see the one at the top of this issue)

These are the list of TODOs that came out of initial implementation discussions (original notes can be found here: https://jamboard.google.com/d/1uWMgs1rnw3Z-IDV7fkQ3J4rnZW8XMdRLYXBK2HKUdZk/edit):

Points to note

  • In the sign up with existing flow, link accounts step:
    • First link the accounts and then mark the email as verified (this is shown in the wrong order in the helm chart)

Discussion points

  • How will account linking work with multi-tenancy
  • What logic/functionality should exist in the backend SDKs vs the Core
  • Confirm how recursion in the api interface will work with overrides in the node SDK (discuss with porcellus)

Node SDK

  • Search for TODO: Nemi and remove resolve all those todos
  • Remove createPrimaryUserIdOrLinkAccountsAfterEmailVerification function entierly and replace with call to createPrimaryUserIdOrLinkAccounts
  • The status in the return types of create and canCreate primaryUser should be thought about more to remove redundant and confusing status values. We could have an additional boolean in status:"OK" to indicate whether the account was already linked. This should also be changed in canLinkAccounts
  • Think about all the places where post link accounts should get called
  • Unlink accounts should return a status instead of throwing an error
  • Can doPostSignUp... just take the recipe level as the input?
  • linkAccounts should just mark emails as verified if required. Blocks that call linkAccounts should not do this in their logic
  • doPostSignUp... should mark the current email as verified if other login methods have the same email and is verified
  • The api that calls accountLinkPostSignInViaSession should return an invalid claims error for email verification if accountLinkPostSignInViaSession returns NEEDS_TO_BE_VERIFIED
  • listAccountByInfo should just accept the user object and forward that to the core. Let the core decide what recipe login info to prioritise
  • accountLinkPostSignUp... should not check for email verification when creating a recipe user. We also dont need addClaim when returning createRecipeUser: true
  • doPostSignUp... should call getPrimaryThatCanBeLinked... instead of listUsersByInfo
  • Whatever block is calling createPrimaryAfterVerification should handle adding the session claim and not createPrimaryAfterVerification itself
  • createPrimaryAfterVerification should check for email verification
  • ACCOUNT_INFO_ALREADY_LINKED... should be renamed to ACCOUNT_INFO_ALREADY_ASSOCIATED...
  • Is AddNewRecipeUserIdWithoutPrimary required? Can it be removed from the recipe interface?
  • SignUpPost can return an accountLinkingStatus property instead of retuning createdNewUser: boolean to make the interface less confusing
  • Does accountLinkingPost... need isNotVerified boolean?
  • Maybe we should create a function in all recipes to create a user without signing up so that if the developer has overridden signUp it wont get called during account linking
  • AccountLinkingPost should never have an empty description in the returned response
  • SignUp should use getUser... instead of overwriting the id
  • For all places in the flow where we throw errors, can we return status instead? This will improve the general DX of the flow
  • The backend SDK does not need to mark emails as verified post account linking, that can happen in the core

Core changes

  • During normal sign up (before linking) store the recipe user id in some table (this should be in the same transaction as the one that creates the recipe user id). If shouldDoAccountLinking returns false or linkAccounts succeeds remove the recpe user id from the table. During sign in is the user id is present in the table, do account linking for that recipe user id.
  • changeEmailOrPassword should check if the email is already associated with a primary user id (This should not happen in the API in the backend SDK)
  • [ ]

@LukasKnuth
Copy link

What is the status on this feature and when can we expect this to ship?

@rishabhpoddar
Copy link
Member Author

It's being actively worked on - but as you can see, the first comment on this PR, the checklist is HUGE. So it may take a while unfortunately. In the timeline of a few months i'd say.

@hiteshjoshi
Copy link

Hello, how far are we with account linking?

@rishabhpoddar
Copy link
Member Author

Will be released for node SDK in the coming week

@rishabhpoddar
Copy link
Member Author

About creation of primary user and account linking during sign in

Consider the following situation:

  • App does not use account linking
  • User signs up with google login with email e1 -> recipe user with user id u1
  • User uses the app and adds data to it.
  • App now enables account linking
  • User signs up with github with email e1 -> user id is u2

Now the github account will become the primary user and the google one will be linked to it. This will cause a change in the user ID from u1 to u2 which might cause loss of data.

To prevent this, users can:

  • Make the google user a primary user before switching on auto account linking.
  • Implement shouldDoAutomaticAccountLinking in such a way that when github is going to become the primary user, they should reject that cause there is already data associated with the google account that has the same email.

An alternative would be that instead of making the github user the primary one, we make the google one the primary one. This however, is assuming that the first account was the one in which the user has the most data. What if the following happens?

  • account linking off
  • google sign in
  • github sign in
  • user uses github account
  • account linking on
  • github sign in

In this case, if we make google the primary user, then there will also be data loss since github is the actual preferred method of the user.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

5 participants