Closed rishabhpoddar closed 1 year ago
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
}]
verifiedIdentifyingId
and unverifiedIdentifyingId
can either be an email or phone number.onAccountLinked
and onAccountUnlinked
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.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?
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:
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.
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
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.
all_auth_recipe_users
tableall_auth_recipe_users
account with their recipe IDThe 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):
delete from session_info where primaryUserId = ".." OR recipeUserId = "..."
During email verification, we want to upgrade the user's session in the following case:
~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.
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
~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:
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
// 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 }>
id
is the primary user ID. This is true even if the input recipe user ID != primary idEven 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>
}
})
postAccountLinked
callback which they should use instead of post sign up (only applicable when account linking is enabled).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}>
})
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.
~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.
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.
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;
}[]
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;
postAccountLink
callback where they can do data migrationa
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.
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.
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
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
Decided: We can do this at a later time. For now, it's not needed
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.
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.
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:
linkAccounts
manuallyAccount unlinking callback:
onAccountUnlinked(primaryUser: User, unlinkedAccount: RecipeLevelUser & {recipeId: "..."}) => Promise<void>
This is called in the unlinkAccounts
function
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.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);
primary_user_id
can be NULL.POST /user/account/link
-> requires a session + info about other account)
~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
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.
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)~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
:
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
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
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):
createPrimaryUserIdOrLinkAccountsAfterEmailVerification
function entierly and replace with call to createPrimaryUserIdOrLinkAccounts
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 canLinkAccountsdoPostSignUp...
just take the recipe level as the input?createRecipeUser: true
createdNewUser: boolean
to make the interface less confusingstatus
instead? This will improve the general DX of the flowWhat is the status on this feature and when can we expect this to ship?
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.
Hello, how far are we with account linking?
Will be released for node SDK in the coming week
Consider the following situation:
e1
-> recipe user with user id u1
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:
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?
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.
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:
linkAccountToExistingAccountPOST
API on backend SDKlinkAccountsWithUserFromSession
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)?Due to howlinkAccountsWithUserFromSession
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.linkAccountsWithUserFromSession
SignUpPost can return an accountLinkingStatus property instead of retuning createdNewUser: boolean to make the interface less confusingDuring 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 reasonlinkAccountsWithUserFromSession
, there is a part where we returnNEW_ACCOUNT_NEEDS_TO_BE_VERIFIED_ERROR
. Right before that, we have a todo for this should never happen.. solve itlinkAccountToExistingAccountPOST
, we verify the credentials properly if the user already exists.linkAccountToExistingAccountPOST
wherein we want to link an existing user to the session, but with wrong credentials of the existing user.linkAccountToExistingAccountPOST
where the current session's email is not verified, then it results in email verification claim failurelinkAccountToExistingAccountPOST
should it matter if the session's account is verified or not (if it's a primary user already)?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)..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)linkAccountToExistingAccountPOST
if the new account is a primary user, then we should not allow linking.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.shouldRequireVerification
is true. But where should this happen?updateEmailOrPassword
, we should check for password policy thing as well (even in dashboard password change API)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)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 oflinkEmailPasswordAccountsWithUserFromSession
to all auth recipes.Test that emailpassword.linkEmailPasswordAccountsWithUserFromSession works fine (along with other recipe functions).purgeSessionOfAccountLinkingClaimIfRequired
in account linking index.ts?userEmailVerifyGet
takes a recipeUserId instead of userIduserEmailVerifyPut
takes a recipeUserId instead of userIduserEmailVerifyTokenPost
takes a recipeUserId instead of userIdIf the AccountLinkingClaim is kept in the session even after linking, is that OK?Check that calling the email verification APIs should remove this claimDoes 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?linkAccountsWithUserFromSession
is of the right structure and that it sends back a 403createAndSendCustomEmail
from all recipes (all deprecated ways of sending emails)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 thathasSameEmailAs
type functions are used when comparing emails etc for users?validateAccessTokenStructure
recipeUserId
instead ofuserId
orid
. 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)MOCK=true
from the.github/workflows/tests.yml
fileImplementgetPasswordResetTokenInfo
in the coreImplementgetEmailVerificationTokenInfo
in the corerevokeSessionsForLinkedAccounts
in implementation ofrevokeAllSessionsForUser
in core.fetchSessionsForAllLinkedAccounts
in implementation ofgetAllSessionHandlesForUser
in core.allowLinking: false,
when callingisSignUpAllowed
. 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?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)"If a user is shared across tenants, and then they use the same email on another tenant, they should have their account linked automatically.removeAllLinkedAccounts
should be false when deleting recipe level users, and true when deleting the main user.userId
torecipeUserId
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.EMAIL_CHANGE_NOT_ALLOWED_ERROR
status (whoseerror
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)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.user.thirdParty.length > 0
. Need to fixlistUsersByAccountInfo
gets multiple inputs - like email and third party id or email and phone number?disable account linking in email verification api by checking if email verification is optional or notonAccountLinked
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
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 get rid of combination recipes on the backend? If yes, this will require a change in the routing logic based on rid.createEmailVerificationToken
function does not normalise the user context when passing it togetEmailForRecipeUserId
session
arg fromshouldDoAutomaticAccountLinking
if we have totally commented out the linkAccountWithSession functions.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.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).createdNewRecipeUser