diff --git a/lib/ts/recipe/webauthn/api/emailExists.ts b/lib/ts/recipe/webauthn/api/emailExists.ts new file mode 100644 index 000000000..3ed5ecab6 --- /dev/null +++ b/lib/ts/recipe/webauthn/api/emailExists.ts @@ -0,0 +1,51 @@ +/* Copyright (c) 2021, VRAI Labs and/or its affiliates. All rights reserved. + * + * This software is licensed under the Apache License, Version 2.0 (the + * "License") as published by the Apache Software Foundation. + * + * You may not use this file except in compliance with the License. You may + * obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +import { send200Response } from "../../../utils"; +import STError from "../error"; +import { APIInterface, APIOptions } from "../"; +import { UserContext } from "../../../types"; + +export default async function emailExists( + apiImplementation: APIInterface, + tenantId: string, + options: APIOptions, + userContext: UserContext +): Promise { + // Logic as per https://github.com/supertokens/supertokens-node/issues/47#issue-751571692 + + if (apiImplementation.emailExistsGET === undefined) { + return false; + } + + let email = options.req.getKeyValueFromQuery("email"); + + if (email === undefined || typeof email !== "string") { + throw new STError({ + type: STError.BAD_INPUT_ERROR, + message: "Please provide the email as a GET param", + }); + } + + let result = await apiImplementation.emailExistsGET({ + email, + tenantId, + options, + userContext, + }); + + send200Response(options.res, result); + return true; +} diff --git a/lib/ts/recipe/webauthn/api/generateRecoverAccountToken.ts b/lib/ts/recipe/webauthn/api/generateRecoverAccountToken.ts new file mode 100644 index 000000000..4fe1ef211 --- /dev/null +++ b/lib/ts/recipe/webauthn/api/generateRecoverAccountToken.ts @@ -0,0 +1,50 @@ +/* Copyright (c) 2021, VRAI Labs and/or its affiliates. All rights reserved. + * + * This software is licensed under the Apache License, Version 2.0 (the + * "License") as published by the Apache Software Foundation. + * + * You may not use this file except in compliance with the License. You may + * obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +import { send200Response } from "../../../utils"; +import { APIInterface, APIOptions } from "../"; +import { UserContext } from "../../../types"; +import STError from "../error"; + +export default async function generateRecoverAccountToken( + apiImplementation: APIInterface, + tenantId: string, + options: APIOptions, + userContext: UserContext +): Promise { + if (apiImplementation.generateRecoverAccountTokenPOST === undefined) { + return false; + } + + const requestBody = await options.req.getJSONBody(); + const email = requestBody.email; + + if (email === undefined || typeof email !== "string") { + throw new STError({ + type: STError.BAD_INPUT_ERROR, + message: "Please provide the email", + }); + } + + let result = await apiImplementation.generateRecoverAccountTokenPOST({ + email, + tenantId, + options, + userContext, + }); + + send200Response(options.res, result); + return true; +} diff --git a/lib/ts/recipe/webauthn/api/implementation.ts b/lib/ts/recipe/webauthn/api/implementation.ts index 2408114ff..dc87e0e0b 100644 --- a/lib/ts/recipe/webauthn/api/implementation.ts +++ b/lib/ts/recipe/webauthn/api/implementation.ts @@ -1,68 +1,130 @@ import { APIInterface, APIOptions } from ".."; import { GeneralErrorResponse, User, UserContext } from "../../../types"; import AccountLinking from "../../accountlinking/recipe"; +import EmailVerification from "../../emailverification/recipe"; import { AuthUtils } from "../../../authUtils"; import { isFakeEmail } from "../../thirdparty/utils"; import { SessionContainerInterface } from "../../session/types"; import { - DEFAULT_REGISTER_ATTESTATION, + DEFAULT_REGISTER_OPTIONS_ATTESTATION, DEFAULT_REGISTER_OPTIONS_TIMEOUT, + DEFAULT_REGISTER_OPTIONS_REQUIRE_RESIDENT_KEY, + DEFAULT_REGISTER_OPTIONS_RESIDENT_KEY, + DEFAULT_REGISTER_OPTIONS_USER_VERIFICATION, DEFAULT_SIGNIN_OPTIONS_TIMEOUT, + DEFAULT_SIGNIN_OPTIONS_USER_VERIFICATION, } from "../constants"; +import RecipeUserId from "../../../recipeUserId"; +import { getRecoverAccountLink } from "../utils"; +import { logDebugMessage } from "../../../logger"; +import { RecipeLevelUser } from "../../accountlinking/types"; +import { getUser } from "../../.."; +import { CredentialPayload } from "../types"; export default function getAPIImplementation(): APIInterface { return { - signInOptionsPOST: async function ({ + registerOptionsPOST: async function ({ tenantId, options, userContext, + ...props }: { tenantId: string; options: APIOptions; userContext: UserContext; - }): Promise< + } & ({ email: string } | { recoverAccountToken: string })): Promise< | { status: "OK"; webauthnGeneratedOptionsId: string; + rp: { + id: string; + name: string; + }; + user: { + id: string; + name: string; + displayName: string; + }; challenge: string; timeout: number; - userVerification: "required" | "preferred" | "discouraged"; + excludeCredentials: { + id: string; + type: "public-key"; + transports: ("ble" | "hybrid" | "internal" | "nfc" | "usb")[]; + }[]; + attestation: "none" | "indirect" | "direct" | "enterprise"; + pubKeyCredParams: { + alg: number; + type: "public-key"; + }[]; + authenticatorSelection: { + requireResidentKey: boolean; + residentKey: "required" | "preferred" | "discouraged"; + userVerification: "required" | "preferred" | "discouraged"; + }; } - | GeneralErrorResponse + | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } + | { status: "EMAIL_MISSING_ERROR" } > { - // todo move to recipe implementation - const timeout = DEFAULT_SIGNIN_OPTIONS_TIMEOUT; + const relyingPartyId = await options.config.relyingPartyId({ + tenantId, + request: options.req, + userContext, + }); + const relyingPartyName = await options.config.relyingPartyName({ + tenantId, + userContext, + }); - const relyingPartyId = options.config.relyingPartyId({ request: options.req, userContext: userContext }); + const origin = await options.config.getOrigin({ + tenantId, + request: options.req, + userContext, + }); - // use this to get the full url instead of only the domain url - const origin = options.appInfo - .getOrigin({ request: options.req, userContext: userContext }) - .getAsStringDangerous(); + const timeout = DEFAULT_REGISTER_OPTIONS_TIMEOUT; + const attestation = DEFAULT_REGISTER_OPTIONS_ATTESTATION; + const requireResidentKey = DEFAULT_REGISTER_OPTIONS_REQUIRE_RESIDENT_KEY; + const residentKey = DEFAULT_REGISTER_OPTIONS_RESIDENT_KEY; + const userVerification = DEFAULT_REGISTER_OPTIONS_USER_VERIFICATION; - let response = await options.recipeImplementation.signInOptions({ + let response = await options.recipeImplementation.registerOptions({ + ...props, + attestation, + requireResidentKey, + residentKey, + userVerification, origin, relyingPartyId, + relyingPartyName, timeout, tenantId, userContext, }); + if (response.status !== "OK") { + return response; + } + return { status: "OK", webauthnGeneratedOptionsId: response.webauthnGeneratedOptionsId, challenge: response.challenge, timeout: response.timeout, - userVerification: response.userVerification, + attestation: response.attestation, + pubKeyCredParams: response.pubKeyCredParams, + excludeCredentials: response.excludeCredentials, + rp: response.rp, + user: response.user, + authenticatorSelection: response.authenticatorSelection, }; }, - registerOptionsPOST: async function ({ - email, + + signInOptionsPOST: async function ({ tenantId, options, userContext, }: { - email: string; tenantId: string; options: APIOptions; userContext: UserContext; @@ -70,74 +132,50 @@ export default function getAPIImplementation(): APIInterface { | { status: "OK"; webauthnGeneratedOptionsId: string; - rp: { - id: string; - name: string; - }; - user: { - id: string; - name: string; - displayName: string; - }; challenge: string; timeout: number; - excludeCredentials: { - id: string; - type: string; - transports: ("ble" | "hybrid" | "internal" | "nfc" | "usb")[]; - }[]; - attestation: "none" | "indirect" | "direct" | "enterprise"; - pubKeyCredParams: { - alg: number; - type: string; - }[]; - authenticatorSelection: { - requireResidentKey: boolean; - residentKey: "required" | "preferred" | "discouraged"; - userVerification: "required" | "preferred" | "discouraged"; - }; + userVerification: "required" | "preferred" | "discouraged"; } | GeneralErrorResponse > { - // todo move to recipe implementation - const timeout = DEFAULT_REGISTER_OPTIONS_TIMEOUT; - // todo move to recipe implementation - const attestation = DEFAULT_REGISTER_ATTESTATION; + const relyingPartyId = await options.config.relyingPartyId({ + tenantId, + request: options.req, + userContext, + }); - const relyingPartyId = options.config.relyingPartyId({ request: options.req, userContext: userContext }); - const relyingPartyName = options.config.relyingPartyName({ + // use this to get the full url instead of only the domain url + const origin = await options.config.getOrigin({ + tenantId, request: options.req, - userContext: userContext, + userContext, }); - const origin = options.appInfo - .getOrigin({ request: options.req, userContext: userContext }) - .getAsStringDangerous(); + const timeout = DEFAULT_SIGNIN_OPTIONS_TIMEOUT; + const userVerification = DEFAULT_SIGNIN_OPTIONS_USER_VERIFICATION; - let response = await options.recipeImplementation.registerOptions({ - email, - attestation, + let response = await options.recipeImplementation.signInOptions({ + userVerification, origin, relyingPartyId, - relyingPartyName, timeout, tenantId, userContext, }); + if (response.status !== "OK") { + return response; + } + return { status: "OK", webauthnGeneratedOptionsId: response.webauthnGeneratedOptionsId, challenge: response.challenge, timeout: response.timeout, - attestation: response.attestation, - pubKeyCredParams: response.pubKeyCredParams, - excludeCredentials: response.excludeCredentials, - rp: response.rp, - user: response.user, - authenticatorSelection: response.authenticatorSelection, + userVerification: response.userVerification, }; }, + signUpPOST: async function ({ email, webauthnGeneratedOptionsId, @@ -150,19 +188,7 @@ export default function getAPIImplementation(): APIInterface { }: { email: string; webauthnGeneratedOptionsId: string; - credential: { - id: string; - rawId: string; - response: { - clientDataJSON: string; - attestationObject: string; - transports?: ("ble" | "cable" | "hybrid" | "internal" | "nfc" | "smart-card" | "usb")[]; - userHandle: string; - }; - authenticatorAttachment: "platform" | "cross-platform"; - clientExtensionResults: Record; - type: "public-key"; - }; + credential: CredentialPayload; tenantId: string; session?: SessionContainerInterface; shouldTryLinkingWithSessionUser: boolean | undefined; @@ -178,14 +204,18 @@ export default function getAPIImplementation(): APIInterface { status: "SIGN_UP_NOT_ALLOWED"; reason: string; } - | { - status: "EMAIL_ALREADY_EXISTS_ERROR"; - } + | { status: "WRONG_CREDENTIALS_ERROR" } + | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string } + | { status: "EMAIL_ALREADY_EXISTS_ERROR" } | GeneralErrorResponse > { const errorCodeMap = { SIGN_UP_NOT_ALLOWED: "Cannot sign up due to security reasons. Please try logging in, use a different login method or contact support. (ERR_CODE_007)", + INVALID_AUTHENTICATOR_ERROR: { + // TODO: add more cases + }, + WRONG_CREDENTIALS_ERROR: "The sign up credentials are incorrect. Please use a different authenticator.", LINKING_TO_SESSION_USER_FAILED: { EMAIL_VERIFICATION_REQUIRED: "Cannot sign in / up due to security reasons. Please contact support. (ERR_CODE_013)", @@ -274,7 +304,7 @@ export default function getAPIImplementation(): APIInterface { authenticatedUser: signUpResponse.user, recipeUserId: signUpResponse.recipeUserId, isSignUp: true, - factorId: "emailpassword", + factorId: "webauthn", session, req: options.req, res: options.res, @@ -307,19 +337,7 @@ export default function getAPIImplementation(): APIInterface { userContext, }: { webauthnGeneratedOptionsId: string; - credential: { - id: string; - rawId: string; - response: { - clientDataJSON: string; - attestationObject: string; - transports?: ("ble" | "cable" | "hybrid" | "internal" | "nfc" | "smart-card" | "usb")[]; - userHandle: string; - }; - authenticatorAttachment: "platform" | "cross-platform"; - clientExtensionResults: Record; - type: "public-key"; - }; + credential: CredentialPayload; tenantId: string; session?: SessionContainerInterface; shouldTryLinkingWithSessionUser: boolean | undefined; @@ -342,7 +360,7 @@ export default function getAPIImplementation(): APIInterface { > { const errorCodeMap = { SIGN_IN_NOT_ALLOWED: - "Cannot sign in due to security reasons. Please try resetting your password, use a different login method or contact support. (ERR_CODE_008)", + "Cannot sign in due to security reasons. Please try recovering your account, use a different login method or contact support. (ERR_CODE_008)", LINKING_TO_SESSION_USER_FAILED: { EMAIL_VERIFICATION_REQUIRED: "Cannot sign in / up due to security reasons. Please contact support. (ERR_CODE_009)", @@ -368,10 +386,19 @@ export default function getAPIImplementation(): APIInterface { return verifyCredentialsResponse.status === "OK"; }; - // todo check if this is the correct way to retrieve the email + // doing it like this because the email is only available after verifyCredentials is called let email: string; if (verifyCredentialsResponse.status == "OK") { - email = verifyCredentialsResponse.user.emails[0]; + const loginMethod = verifyCredentialsResponse.user.loginMethods.find((lm) => lm.recipeId === recipeId); + // there should be a webauthn login method and an email when trying to sign in using webauthn + if (!loginMethod || !loginMethod.email) { + return AuthUtils.getErrorStatusResponseWithReason( + verifyCredentialsResponse, + errorCodeMap, + "SIGN_IN_NOT_ALLOWED" + ); + } + email = loginMethod?.email; } else { return { status: "WRONG_CREDENTIALS_ERROR", @@ -402,7 +429,7 @@ export default function getAPIImplementation(): APIInterface { recipeId, email, }, - factorIds: ["webauthn"], + factorIds: [recipeId], isSignUp: false, authenticatingUser: authenticatingUser?.user, isVerified, @@ -447,7 +474,7 @@ export default function getAPIImplementation(): APIInterface { authenticatedUser: signInResponse.user, recipeUserId: signInResponse.recipeUserId, isSignUp: false, - factorId: "webauthn", + factorId: recipeId, session, req: options.req, res: options.res, @@ -466,594 +493,596 @@ export default function getAPIImplementation(): APIInterface { }; }, - // emailExistsGET: async function ({ - // email, - // tenantId, - // userContext, - // }: { - // email: string; - // tenantId: string; - // options: APIOptions; - // userContext: UserContext; - // }): Promise< - // | { - // status: "OK"; - // exists: boolean; - // } - // | GeneralErrorResponse - // > { - // // even if the above returns true, we still need to check if there - // // exists an email password user with the same email cause the function - // // above does not check for that. - // let users = await AccountLinking.getInstance().recipeInterfaceImpl.listUsersByAccountInfo({ - // tenantId, - // accountInfo: { - // email, - // }, - // doUnionOfAccountInfo: false, - // userContext, - // }); - // let emailPasswordUserExists = - // users.find((u) => { - // return ( - // u.loginMethods.find((lm) => lm.recipeId === "emailpassword" && lm.hasSameEmailAs(email)) !== - // undefined - // ); - // }) !== undefined; - - // return { - // status: "OK", - // exists: emailPasswordUserExists, - // }; - // }, - // generatePasswordResetTokenPOST: async function ({ - // formFields, - // tenantId, - // options, - // userContext, - // }): Promise< - // | { - // status: "OK"; - // } - // | { status: "PASSWORD_RESET_NOT_ALLOWED"; reason: string } - // | GeneralErrorResponse - // > { - // // NOTE: Check for email being a non-string value. This check will likely - // // never evaluate to `true` as there is an upper-level check for the type - // // in validation but kept here to be safe. - // const emailAsUnknown = formFields.filter((f) => f.id === "email")[0].value; - // if (typeof emailAsUnknown !== "string") - // throw new Error( - // "Should never come here since we already check that the email value is a string in validateFormFieldsOrThrowError" - // ); - // const email: string = emailAsUnknown; - - // // this function will be reused in different parts of the flow below.. - // async function generateAndSendPasswordResetToken( - // primaryUserId: string, - // recipeUserId: RecipeUserId | undefined - // ): Promise< - // | { - // status: "OK"; - // } - // | { status: "PASSWORD_RESET_NOT_ALLOWED"; reason: string } - // | GeneralErrorResponse - // > { - // // the user ID here can be primary or recipe level. - // let response = await options.recipeImplementation.createResetPasswordToken({ - // tenantId, - // userId: recipeUserId === undefined ? primaryUserId : recipeUserId.getAsString(), - // email, - // userContext, - // }); - // if (response.status === "UNKNOWN_USER_ID_ERROR") { - // logDebugMessage( - // `Password reset email not sent, unknown user id: ${ - // recipeUserId === undefined ? primaryUserId : recipeUserId.getAsString() - // }` - // ); - // return { - // status: "OK", - // }; - // } - - // let passwordResetLink = getPasswordResetLink({ - // appInfo: options.appInfo, - // token: response.token, - // tenantId, - // request: options.req, - // userContext, - // }); - - // logDebugMessage(`Sending password reset email to ${email}`); - // await options.emailDelivery.ingredientInterfaceImpl.sendEmail({ - // tenantId, - // type: "PASSWORD_RESET", - // user: { - // id: primaryUserId, - // recipeUserId, - // email, - // }, - // passwordResetLink, - // userContext, - // }); - - // return { - // status: "OK", - // }; - // } - - // /** - // * check if primaryUserId is linked with this email - // */ - // let users = await AccountLinking.getInstance().recipeInterfaceImpl.listUsersByAccountInfo({ - // tenantId, - // accountInfo: { - // email, - // }, - // doUnionOfAccountInfo: false, - // userContext, - // }); - - // // we find the recipe user ID of the email password account from the user's list - // // for later use. - // let emailPasswordAccount: RecipeLevelUser | undefined = undefined; - // for (let i = 0; i < users.length; i++) { - // let emailPasswordAccountTmp = users[i].loginMethods.find( - // (l) => l.recipeId === "emailpassword" && l.hasSameEmailAs(email) - // ); - // if (emailPasswordAccountTmp !== undefined) { - // emailPasswordAccount = emailPasswordAccountTmp; - // break; - // } - // } - - // // we find the primary user ID from the user's list for later use. - // let primaryUserAssociatedWithEmail = users.find((u) => u.isPrimaryUser); - - // // first we check if there even exists a primary user that has the input email - // // if not, then we do the regular flow for password reset. - // if (primaryUserAssociatedWithEmail === undefined) { - // if (emailPasswordAccount === undefined) { - // logDebugMessage(`Password reset email not sent, unknown user email: ${email}`); - // return { - // status: "OK", - // }; - // } - // return await generateAndSendPasswordResetToken( - // emailPasswordAccount.recipeUserId.getAsString(), - // emailPasswordAccount.recipeUserId - // ); - // } - - // // Next we check if there is any login method in which the input email is verified. - // // If that is the case, then it's proven that the user owns the email and we can - // // trust linking of the email password account. - // let emailVerified = - // primaryUserAssociatedWithEmail.loginMethods.find((lm) => { - // return lm.hasSameEmailAs(email) && lm.verified; - // }) !== undefined; - - // // finally, we check if the primary user has any other email / phone number - // // associated with this account - and if it does, then it means that - // // there is a risk of account takeover, so we do not allow the token to be generated - // let hasOtherEmailOrPhone = - // primaryUserAssociatedWithEmail.loginMethods.find((lm) => { - // // we do the extra undefined check below cause - // // hasSameEmailAs returns false if the lm.email is undefined, and - // // we want to check that the email is different as opposed to email - // // not existing in lm. - // return (lm.email !== undefined && !lm.hasSameEmailAs(email)) || lm.phoneNumber !== undefined; - // }) !== undefined; - - // if (!emailVerified && hasOtherEmailOrPhone) { - // return { - // status: "PASSWORD_RESET_NOT_ALLOWED", - // reason: - // "Reset password link was not created because of account take over risk. Please contact support. (ERR_CODE_001)", - // }; - // } - - // let shouldDoAccountLinkingResponse = await AccountLinking.getInstance().config.shouldDoAutomaticAccountLinking( - // emailPasswordAccount !== undefined - // ? emailPasswordAccount - // : { - // recipeId: "emailpassword", - // email, - // }, - // primaryUserAssociatedWithEmail, - // undefined, - // tenantId, - // userContext - // ); - - // // Now we need to check that if there exists any email password user at all - // // for the input email. If not, then it implies that when the token is consumed, - // // then we will create a new user - so we should only generate the token if - // // the criteria for the new user is met. - // if (emailPasswordAccount === undefined) { - // // this means that there is no email password user that exists for the input email. - // // So we check for the sign up condition and only go ahead if that condition is - // // met. - - // // But first we must check if account linking is enabled at all - cause if it's - // // not, then the new email password user that will be created in password reset - // // code consume cannot be linked to the primary user - therefore, we should - // // not generate a password reset token - // if (!shouldDoAccountLinkingResponse.shouldAutomaticallyLink) { - // logDebugMessage( - // `Password reset email not sent, since email password user didn't exist, and account linking not enabled` - // ); - // return { - // status: "OK", - // }; - // } - - // let isSignUpAllowed = await AccountLinking.getInstance().isSignUpAllowed({ - // newUser: { - // recipeId: "emailpassword", - // email, - // }, - // isVerified: true, // cause when the token is consumed, we will mark the email as verified - // session: undefined, - // tenantId, - // userContext, - // }); - // if (isSignUpAllowed) { - // // notice that we pass in the primary user ID here. This means that - // // we will be creating a new email password account when the token - // // is consumed and linking it to this primary user. - // return await generateAndSendPasswordResetToken(primaryUserAssociatedWithEmail.id, undefined); - // } else { - // logDebugMessage( - // `Password reset email not sent, isSignUpAllowed returned false for email: ${email}` - // ); - // return { - // status: "OK", - // }; - // } - // } - - // // At this point, we know that some email password user exists with this email - // // and also some primary user ID exist. We now need to find out if they are linked - // // together or not. If they are linked together, then we can just generate the token - // // else we check for more security conditions (since we will be linking them post token generation) - // let areTheTwoAccountsLinked = - // primaryUserAssociatedWithEmail.loginMethods.find((lm) => { - // return lm.recipeUserId.getAsString() === emailPasswordAccount!.recipeUserId.getAsString(); - // }) !== undefined; - - // if (areTheTwoAccountsLinked) { - // return await generateAndSendPasswordResetToken( - // primaryUserAssociatedWithEmail.id, - // emailPasswordAccount.recipeUserId - // ); - // } - - // // Here we know that the two accounts are NOT linked. We now need to check for an - // // extra security measure here to make sure that the input email in the primary user - // // is verified, and if not, we need to make sure that there is no other email / phone number - // // associated with the primary user account. If there is, then we do not proceed. - - // /* - // This security measure helps prevent the following attack: - // An attacker has email A and they create an account using TP and it doesn't matter if A is verified or not. Now they create another account using EP with email A and verifies it. Both these accounts are linked. Now the attacker changes the email for EP recipe to B which makes the EP account unverified, but it's still linked. - - // If the real owner of B tries to signup using EP, it will say that the account already exists so they may try to reset password which should be denied because then they will end up getting access to attacker's account and verify the EP account. - - // The problem with this situation is if the EP account is verified, it will allow further sign-ups with email B which will also be linked to this primary account (that the attacker had created with email A). - - // It is important to realize that the attacker had created another account with A because if they hadn't done that, then they wouldn't have access to this account after the real user resets the password which is why it is important to check there is another non-EP account linked to the primary such that the email is not the same as B. - - // Exception to the above is that, if there is a third recipe account linked to the above two accounts and has B as verified, then we should allow reset password token generation because user has already proven that the owns the email B - // */ - - // // But first, this only matters it the user cares about checking for email verification status.. - - // if (!shouldDoAccountLinkingResponse.shouldAutomaticallyLink) { - // // here we will go ahead with the token generation cause - // // even when the token is consumed, we will not be linking the accounts - // // so no need to check for anything - // return await generateAndSendPasswordResetToken( - // emailPasswordAccount.recipeUserId.getAsString(), - // emailPasswordAccount.recipeUserId - // ); - // } - - // if (!shouldDoAccountLinkingResponse.shouldRequireVerification) { - // // the checks below are related to email verification, and if the user - // // does not care about that, then we should just continue with token generation - // return await generateAndSendPasswordResetToken( - // primaryUserAssociatedWithEmail.id, - // emailPasswordAccount.recipeUserId - // ); - // } - - // return await generateAndSendPasswordResetToken( - // primaryUserAssociatedWithEmail.id, - // emailPasswordAccount.recipeUserId - // ); - // }, - // passwordResetPOST: async function ({ - // formFields, - // token, - // tenantId, - // options, - // userContext, - // }: { - // formFields: { - // id: string; - // value: unknown; - // }[]; - // token: string; - // tenantId: string; - // options: APIOptions; - // userContext: UserContext; - // }): Promise< - // | { - // status: "OK"; - // user: User; - // email: string; - // } - // | { status: "RESET_PASSWORD_INVALID_TOKEN_ERROR" } - // | { status: "PASSWORD_POLICY_VIOLATED_ERROR"; failureReason: string } - // | GeneralErrorResponse - // > { - // async function markEmailAsVerified(recipeUserId: RecipeUserId, email: string) { - // const emailVerificationInstance = EmailVerification.getInstance(); - // if (emailVerificationInstance) { - // const tokenResponse = await emailVerificationInstance.recipeInterfaceImpl.createEmailVerificationToken( - // { - // tenantId, - // recipeUserId, - // email, - // userContext, - // } - // ); - - // if (tokenResponse.status === "OK") { - // await emailVerificationInstance.recipeInterfaceImpl.verifyEmailUsingToken({ - // tenantId, - // token: tokenResponse.token, - // attemptAccountLinking: false, // we pass false here cause - // // we anyway do account linking in this API after this function is - // // called. - // userContext, - // }); - // } - // } - // } - - // async function doUpdatePasswordAndVerifyEmailAndTryLinkIfNotPrimary( - // recipeUserId: RecipeUserId - // ): Promise< - // | { - // status: "OK"; - // user: User; - // email: string; - // } - // | { status: "RESET_PASSWORD_INVALID_TOKEN_ERROR" } - // | { status: "PASSWORD_POLICY_VIOLATED_ERROR"; failureReason: string } - // | GeneralErrorResponse - // > { - // let updateResponse = await options.recipeImplementation.updateEmailOrPassword({ - // tenantIdForPasswordPolicy: tenantId, - // // we can treat userIdForWhomTokenWasGenerated as a recipe user id cause - // // whenever this function is called, - // recipeUserId, - // password: newPassword, - // userContext, - // }); - // if ( - // updateResponse.status === "EMAIL_ALREADY_EXISTS_ERROR" || - // updateResponse.status === "EMAIL_CHANGE_NOT_ALLOWED_ERROR" - // ) { - // throw new Error("This should never come here because we are not updating the email"); - // } else if (updateResponse.status === "UNKNOWN_USER_ID_ERROR") { - // // This should happen only cause of a race condition where the user - // // might be deleted before token creation and consumption. - // return { - // status: "RESET_PASSWORD_INVALID_TOKEN_ERROR", - // }; - // } else if (updateResponse.status === "PASSWORD_POLICY_VIOLATED_ERROR") { - // return { - // status: "PASSWORD_POLICY_VIOLATED_ERROR", - // failureReason: updateResponse.failureReason, - // }; - // } else { - // // status: "OK" - - // // If the update was successful, we try to mark the email as verified. - // // We do this because we assume that the password reset token was delivered by email (and to the appropriate email address) - // // so consuming it means that the user actually has access to the emails we send. - - // // We only do this if the password update was successful, otherwise the following scenario is possible: - // // 1. User M: signs up using the email of user V with their own password. They can't validate the email, because it is not their own. - // // 2. User A: tries signing up but sees the email already exists message - // // 3. User A: resets their password, but somehow this fails (e.g.: password policy issue) - // // If we verified (and linked) the existing user with the original password, User M would get access to the current user and any linked users. - // await markEmailAsVerified(recipeUserId, emailForWhomTokenWasGenerated); - // // We refresh the user information here, because the verification status may be updated, which is used during linking. - // const updatedUserAfterEmailVerification = await getUser(recipeUserId.getAsString(), userContext); - // if (updatedUserAfterEmailVerification === undefined) { - // throw new Error("Should never happen - user deleted after during password reset"); - // } - - // if (updatedUserAfterEmailVerification.isPrimaryUser) { - // // If the user is already primary, we do not need to do any linking - // return { - // status: "OK", - // email: emailForWhomTokenWasGenerated, - // user: updatedUserAfterEmailVerification, - // }; - // } - - // // If the user was not primary: - - // // Now we try and link the accounts. - // // The function below will try and also create a primary user of the new account, this can happen if: - // // 1. the user was unverified and linking requires verification - // // We do not take try linking by session here, since this is supposed to be called without a session - // // Still, the session object is passed around because it is a required input for shouldDoAutomaticAccountLinking - // const linkRes = await AccountLinking.getInstance().tryLinkingByAccountInfoOrCreatePrimaryUser({ - // tenantId, - // inputUser: updatedUserAfterEmailVerification, - // session: undefined, - // userContext, - // }); - // const userAfterWeTriedLinking = - // linkRes.status === "OK" ? linkRes.user : updatedUserAfterEmailVerification; - - // return { - // status: "OK", - // email: emailForWhomTokenWasGenerated, - // user: userAfterWeTriedLinking, - // }; - // } - // } - - // // NOTE: Check for password being a non-string value. This check will likely - // // never evaluate to `true` as there is an upper-level check for the type - // // in validation but kept here to be safe. - // const newPasswordAsUnknown = formFields.filter((f) => f.id === "password")[0].value; - // if (typeof newPasswordAsUnknown !== "string") - // throw new Error( - // "Should never come here since we already check that the password value is a string in validateFormFieldsOrThrowError" - // ); - // let newPassword: string = newPasswordAsUnknown; - - // let tokenConsumptionResponse = await options.recipeImplementation.consumePasswordResetToken({ - // token, - // tenantId, - // userContext, - // }); - - // if (tokenConsumptionResponse.status === "RESET_PASSWORD_INVALID_TOKEN_ERROR") { - // return tokenConsumptionResponse; - // } - - // let userIdForWhomTokenWasGenerated = tokenConsumptionResponse.userId; - // let emailForWhomTokenWasGenerated = tokenConsumptionResponse.email; - - // let existingUser = await getUser(tokenConsumptionResponse.userId, userContext); - - // if (existingUser === undefined) { - // // This should happen only cause of a race condition where the user - // // might be deleted before token creation and consumption. - // // Also note that this being undefined doesn't mean that the email password - // // user does not exist, but it means that there is no recipe or primary user - // // for whom the token was generated. - // return { - // status: "RESET_PASSWORD_INVALID_TOKEN_ERROR", - // }; - // } - - // // We start by checking if the existingUser is a primary user or not. If it is, - // // then we will try and create a new email password user and link it to the primary user (if required) - - // if (existingUser.isPrimaryUser) { - // // If this user contains an email password account for whom the token was generated, - // // then we update that user's password. - // let emailPasswordUserIsLinkedToExistingUser = - // existingUser.loginMethods.find((lm) => { - // // we check based on user ID and not email because the only time - // // the primary user ID is used for token generation is if the email password - // // user did not exist - in which case the value of emailPasswordUserExists will - // // resolve to false anyway, and that's what we want. - - // // there is an edge case where if the email password recipe user was created - // // after the password reset token generation, and it was linked to the - // // primary user id (userIdForWhomTokenWasGenerated), in this case, - // // we still don't allow password update, cause the user should try again - // // and the token should be regenerated for the right recipe user. - // return ( - // lm.recipeUserId.getAsString() === userIdForWhomTokenWasGenerated && - // lm.recipeId === "emailpassword" - // ); - // }) !== undefined; - - // if (emailPasswordUserIsLinkedToExistingUser) { - // return doUpdatePasswordAndVerifyEmailAndTryLinkIfNotPrimary( - // new RecipeUserId(userIdForWhomTokenWasGenerated) - // ); - // } else { - // // this means that the existingUser does not have an emailpassword user associated - // // with it. It could now mean that no emailpassword user exists, or it could mean that - // // the the ep user exists, but it's not linked to the current account. - // // If no ep user doesn't exists, we will create one, and link it to the existing account. - // // If ep user exists, then it means there is some race condition cause - // // then the token should have been generated for that user instead of the primary user, - // // and it shouldn't have come into this branch. So we can simply send a password reset - // // invalid error and the user can try again. - - // // NOTE: We do not ask the dev if we should do account linking or not here - // // cause we already have asked them this when generating an password reset token. - // // In the edge case that the dev changes account linking allowance from true to false - // // when it comes here, only a new recipe user id will be created and not linked - // // cause createPrimaryUserIdOrLinkAccounts will disallow linking. This doesn't - // // really cause any security issue. - - // let createUserResponse = await options.recipeImplementation.createNewRecipeUser({ - // tenantId, - // email: tokenConsumptionResponse.email, - // password: newPassword, - // userContext, - // }); - // if (createUserResponse.status === "EMAIL_ALREADY_EXISTS_ERROR") { - // // this means that the user already existed and we can just return an invalid - // // token (see the above comment) - // return { - // status: "RESET_PASSWORD_INVALID_TOKEN_ERROR", - // }; - // } else { - // // we mark the email as verified because password reset also requires - // // access to the email to work.. This has a good side effect that - // // any other login method with the same email in existingAccount will also get marked - // // as verified. - // await markEmailAsVerified( - // createUserResponse.user.loginMethods[0].recipeUserId, - // tokenConsumptionResponse.email - // ); - // const updatedUser = await getUser(createUserResponse.user.id, userContext); - // if (updatedUser === undefined) { - // throw new Error("Should never happen - user deleted after during password reset"); - // } - // createUserResponse.user = updatedUser; - // // Now we try and link the accounts. The function below will try and also - // // create a primary user of the new account, and if it does that, it's OK.. - // // But in most cases, it will end up linking to existing account since the - // // email is shared. - // // We do not take try linking by session here, since this is supposed to be called without a session - // // Still, the session object is passed around because it is a required input for shouldDoAutomaticAccountLinking - // const linkRes = await AccountLinking.getInstance().tryLinkingByAccountInfoOrCreatePrimaryUser({ - // tenantId, - // inputUser: createUserResponse.user, - // session: undefined, - // userContext, - // }); - // const userAfterLinking = linkRes.status === "OK" ? linkRes.user : createUserResponse.user; - // if (linkRes.status === "OK" && linkRes.user.id !== existingUser.id) { - // // this means that the account we just linked to - // // was not the one we had expected to link it to. This can happen - // // due to some race condition or the other.. Either way, this - // // is not an issue and we can just return OK - // } - // return { - // status: "OK", - // email: tokenConsumptionResponse.email, - // user: userAfterLinking, - // }; - // } - // } - // } else { - // // This means that the existing user is not a primary account, which implies that - // // it must be a non linked email password account. In this case, we simply update the password. - // // Linking to an existing account will be done after the user goes through the email - // // verification flow once they log in (if applicable). - // return doUpdatePasswordAndVerifyEmailAndTryLinkIfNotPrimary( - // new RecipeUserId(userIdForWhomTokenWasGenerated) - // ); - // } - // }, + emailExistsGET: async function ({ + email, + tenantId, + userContext, + }: { + email: string; + tenantId: string; + options: APIOptions; + userContext: UserContext; + }): Promise< + | { + status: "OK"; + exists: boolean; + } + | GeneralErrorResponse + > { + // even if the above returns true, we still need to check if there + // exists an webauthn user with the same email cause the function + // above does not check for that. + let users = await AccountLinking.getInstance().recipeInterfaceImpl.listUsersByAccountInfo({ + tenantId, + accountInfo: { + email, + }, + doUnionOfAccountInfo: false, + userContext, + }); + let webauthnUserExists = + users.find((u) => { + return ( + u.loginMethods.find((lm) => lm.recipeId === "webauthn" && lm.hasSameEmailAs(email)) !== + undefined + ); + }) !== undefined; + + return { + status: "OK", + exists: webauthnUserExists, + }; + }, + + generateRecoverAccountTokenPOST: async function ({ + email, + tenantId, + options, + userContext, + }): Promise< + | { + status: "OK"; + } + | { status: "ACCOUNT_RECOVERY_NOT_ALLOWED"; reason: string } + | GeneralErrorResponse + > { + // NOTE: Check for email being a non-string value. This check will likely + // never evaluate to `true` as there is an upper-level check for the type + // in validation but kept here to be safe. + if (typeof email !== "string") + throw new Error( + "Should never come here since we already check that the email value is a string in validateFormFieldsOrThrowError" + ); + + // this function will be reused in different parts of the flow below.. + async function generateAndSendRecoverAccountToken( + primaryUserId: string, + recipeUserId: RecipeUserId | undefined + ): Promise< + | { + status: "OK"; + } + | { status: "ACCOUNT_RECOVERY_NOT_ALLOWED"; reason: string } + | GeneralErrorResponse + > { + // the user ID here can be primary or recipe level. + let response = await options.recipeImplementation.generateRecoverAccountToken({ + tenantId, + userId: recipeUserId === undefined ? primaryUserId : recipeUserId.getAsString(), + email, + userContext, + }); + + if (response.status === "UNKNOWN_USER_ID_ERROR") { + logDebugMessage( + `Account recovery email not sent, unknown user id: ${ + recipeUserId === undefined ? primaryUserId : recipeUserId.getAsString() + }` + ); + return { + status: "OK", + }; + } + + let recoverAccountLink = getRecoverAccountLink({ + appInfo: options.appInfo, + token: response.token, + tenantId, + request: options.req, + userContext, + }); + + logDebugMessage(`Sending account recovery email to ${email}`); + await options.emailDelivery.ingredientInterfaceImpl.sendEmail({ + tenantId, + type: "RECOVER_ACCOUNT", + user: { + id: primaryUserId, + recipeUserId, + email, + }, + recoverAccountLink, + userContext, + }); + + return { + status: "OK", + }; + } + + /** + * check if primaryUserId is linked with this email + */ + let users = await AccountLinking.getInstance().recipeInterfaceImpl.listUsersByAccountInfo({ + tenantId, + accountInfo: { + email, + }, + doUnionOfAccountInfo: false, + userContext, + }); + + // we find the recipe user ID of the webauthn account from the user's list + // for later use. + let webauthnAccount: RecipeLevelUser | undefined = undefined; + for (let i = 0; i < users.length; i++) { + let webauthnAccountTmp = users[i].loginMethods.find( + (l) => l.recipeId === "webauthn" && l.hasSameEmailAs(email) + ); + if (webauthnAccountTmp !== undefined) { + webauthnAccount = webauthnAccountTmp; + break; + } + } + + // we find the primary user ID from the user's list for later use. + let primaryUserAssociatedWithEmail = users.find((u) => u.isPrimaryUser); + + // first we check if there even exists a primary user that has the input email + // if not, then we do the regular flow for account recovery + if (primaryUserAssociatedWithEmail === undefined) { + if (webauthnAccount === undefined) { + logDebugMessage(`Account recovery email not sent, unknown user email: ${email}`); + return { + status: "OK", + }; + } + return await generateAndSendRecoverAccountToken( + webauthnAccount.recipeUserId.getAsString(), + webauthnAccount.recipeUserId + ); + } + + // Next we check if there is any login method in which the input email is verified. + // If that is the case, then it's proven that the user owns the email and we can + // trust linking of the webauthn account. + let emailVerified = + primaryUserAssociatedWithEmail.loginMethods.find((lm) => { + return lm.hasSameEmailAs(email) && lm.verified; + }) !== undefined; + + // finally, we check if the primary user has any other email / phone number + // associated with this account - and if it does, then it means that + // there is a risk of account takeover, so we do not allow the token to be generated + let hasOtherEmailOrPhone = + primaryUserAssociatedWithEmail.loginMethods.find((lm) => { + // we do the extra undefined check below cause + // hasSameEmailAs returns false if the lm.email is undefined, and + // we want to check that the email is different as opposed to email + // not existing in lm. + return (lm.email !== undefined && !lm.hasSameEmailAs(email)) || lm.phoneNumber !== undefined; + }) !== undefined; + + if (!emailVerified && hasOtherEmailOrPhone) { + return { + status: "ACCOUNT_RECOVERY_NOT_ALLOWED", + reason: + "Account recovery link was not created because of account take over risk. Please contact support. (ERR_CODE_001)", + }; + } + + let shouldDoAccountLinkingResponse = await AccountLinking.getInstance().config.shouldDoAutomaticAccountLinking( + webauthnAccount !== undefined + ? webauthnAccount + : { + recipeId: "webauthn", + email, + }, + primaryUserAssociatedWithEmail, + undefined, + tenantId, + userContext + ); + + // Now we need to check that if there exists any webauthn user at all + // for the input email. If not, then it implies that when the token is consumed, + // then we will create a new user - so we should only generate the token if + // the criteria for the new user is met. + if (webauthnAccount === undefined) { + // this means that there is no webauthn user that exists for the input email. + // So we check for the sign up condition and only go ahead if that condition is + // met. + + // But first we must check if account linking is enabled at all - cause if it's + // not, then the new webauthn user that will be created in account recovery + // code consume cannot be linked to the primary user - therefore, we should + // not generate a account recovery reset token + if (!shouldDoAccountLinkingResponse.shouldAutomaticallyLink) { + logDebugMessage( + `Account recovery email not sent, since webauthn user didn't exist, and account linking not enabled` + ); + return { + status: "OK", + }; + } + + let isSignUpAllowed = await AccountLinking.getInstance().isSignUpAllowed({ + newUser: { + recipeId: "webauthn", + email, + }, + isVerified: true, // cause when the token is consumed, we will mark the email as verified + session: undefined, + tenantId, + userContext, + }); + if (isSignUpAllowed) { + // notice that we pass in the primary user ID here. This means that + // we will be creating a new webauthn account when the token + // is consumed and linking it to this primary user. + return await generateAndSendRecoverAccountToken(primaryUserAssociatedWithEmail.id, undefined); + } else { + logDebugMessage( + `Account recovery email not sent, isSignUpAllowed returned false for email: ${email}` + ); + return { + status: "OK", + }; + } + } + + // At this point, we know that some webauthn user exists with this email + // and also some primary user ID exist. We now need to find out if they are linked + // together or not. If they are linked together, then we can just generate the token + // else we check for more security conditions (since we will be linking them post token generation) + let areTheTwoAccountsLinked = + primaryUserAssociatedWithEmail.loginMethods.find((lm) => { + return lm.recipeUserId.getAsString() === webauthnAccount!.recipeUserId.getAsString(); + }) !== undefined; + + if (areTheTwoAccountsLinked) { + return await generateAndSendRecoverAccountToken( + primaryUserAssociatedWithEmail.id, + webauthnAccount.recipeUserId + ); + } + + // Here we know that the two accounts are NOT linked. We now need to check for an + // extra security measure here to make sure that the input email in the primary user + // is verified, and if not, we need to make sure that there is no other email / phone number + // associated with the primary user account. If there is, then we do not proceed. + + /* + This security measure helps prevent the following attack: + An attacker has email A and they create an account using TP and it doesn't matter if A is verified or not. Now they create another account using the webauthn with email A and verifies it. Both these accounts are linked. Now the attacker changes the email for webauthn recipe to B which makes the webauthn account unverified, but it's still linked. + + If the real owner of B tries to signup using webauthn, it will say that the account already exists so they may try to recover the account which should be denied because then they will end up getting access to attacker's account and verify the webauthn account. + + The problem with this situation is if the webauthn account is verified, it will allow further sign-ups with email B which will also be linked to this primary account (that the attacker had created with email A). + + It is important to realize that the attacker had created another account with A because if they hadn't done that, then they wouldn't have access to this account after the real user recovers the account which is why it is important to check there is another non-webauthn account linked to the primary such that the email is not the same as B. + + Exception to the above is that, if there is a third recipe account linked to the above two accounts and has B as verified, then we should allow account recovery token generation because user has already proven that the owns the email B + */ + + // But first, this only matters it the user cares about checking for email verification status.. + + if (!shouldDoAccountLinkingResponse.shouldAutomaticallyLink) { + // here we will go ahead with the token generation cause + // even when the token is consumed, we will not be linking the accounts + // so no need to check for anything + return await generateAndSendRecoverAccountToken( + webauthnAccount.recipeUserId.getAsString(), + webauthnAccount.recipeUserId + ); + } + + if (!shouldDoAccountLinkingResponse.shouldRequireVerification) { + // the checks below are related to email verification, and if the user + // does not care about that, then we should just continue with token generation + return await generateAndSendRecoverAccountToken( + primaryUserAssociatedWithEmail.id, + webauthnAccount.recipeUserId + ); + } + + return await generateAndSendRecoverAccountToken( + primaryUserAssociatedWithEmail.id, + webauthnAccount.recipeUserId + ); + }, + recoverAccountTokenPOST: async function ({ + webauthnGeneratedOptionsId, + credential, + token, + tenantId, + options, + userContext, + }: { + token: string; + webauthnGeneratedOptionsId: string; + credential: CredentialPayload; + tenantId: string; + options: APIOptions; + userContext: UserContext; + }): Promise< + | { + status: "OK"; + user: User; + email: string; + } + | GeneralErrorResponse + | { + status: "CONSUME_RECOVER_ACCOUNT_TOKEN_NOT_ALLOWED"; + reason: string; + } + | { status: "WRONG_CREDENTIALS_ERROR" } + | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string } + | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } + > { + async function markEmailAsVerified(recipeUserId: RecipeUserId, email: string) { + const emailVerificationInstance = EmailVerification.getInstance(); + if (emailVerificationInstance) { + const tokenResponse = await emailVerificationInstance.recipeInterfaceImpl.createEmailVerificationToken( + { + tenantId, + recipeUserId, + email, + userContext, + } + ); + + if (tokenResponse.status === "OK") { + await emailVerificationInstance.recipeInterfaceImpl.verifyEmailUsingToken({ + tenantId, + token: tokenResponse.token, + attemptAccountLinking: false, // we pass false here cause + // we anyway do account linking in this API after this function is + // called. + userContext, + }); + } + } + } + + async function doRegisterCredentialAndVerifyEmailAndTryLinkIfNotPrimary( + recipeUserId: RecipeUserId + ): Promise< + | { + status: "OK"; + user: User; + email: string; + } + | { status: "WRONG_CREDENTIALS_ERROR" } + | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string } + | GeneralErrorResponse + > { + let updateResponse = await options.recipeImplementation.registerCredential({ + recipeUserId, + webauthnGeneratedOptionsId, + tenantId, + credential, + userContext, + }); + + // todo decide how to handle these + if (updateResponse.status === "INVALID_AUTHENTICATOR_ERROR") { + // This should happen only cause of a race condition where the user + // might be deleted before token creation and consumption. + return { + status: "INVALID_AUTHENTICATOR_ERROR", + reason: updateResponse.reason, + }; + } else if (updateResponse.status === "WRONG_CREDENTIALS_ERROR") { + return { + status: "WRONG_CREDENTIALS_ERROR", + }; + } else { + // status: "OK" + + // If the update was successful, we try to mark the email as verified. + // We do this because we assume that the account recovery token was delivered by email (and to the appropriate email address) + // so consuming it means that the user actually has access to the emails we send. + + // We only do this if the account recovery was successful, otherwise the following scenario is possible: + // 1. User M: signs up using the email of user V with their own credential. They can't validate the email, because it is not their own. + // 2. User A: tries signing up but sees the email already exists message + // 3. User A: recovers the account, but somehow this fails + // If we verified (and linked) the existing user with the original credential, User M would get access to the current user and any linked users. + await markEmailAsVerified(recipeUserId, emailForWhomTokenWasGenerated); + // We refresh the user information here, because the verification status may be updated, which is used during linking. + const updatedUserAfterEmailVerification = await getUser(recipeUserId.getAsString(), userContext); + if (updatedUserAfterEmailVerification === undefined) { + throw new Error("Should never happen - user deleted after during account recovery"); + } + + if (updatedUserAfterEmailVerification.isPrimaryUser) { + // If the user is already primary, we do not need to do any linking + return { + status: "OK", + email: emailForWhomTokenWasGenerated, + user: updatedUserAfterEmailVerification, + }; + } + + // If the user was not primary: + + // Now we try and link the accounts. + // The function below will try and also create a primary user of the new account, this can happen if: + // 1. the user was unverified and linking requires verification + // We do not take try linking by session here, since this is supposed to be called without a session + // Still, the session object is passed around because it is a required input for shouldDoAutomaticAccountLinking + const linkRes = await AccountLinking.getInstance().tryLinkingByAccountInfoOrCreatePrimaryUser({ + tenantId, + inputUser: updatedUserAfterEmailVerification, + session: undefined, + userContext, + }); + const userAfterWeTriedLinking = + linkRes.status === "OK" ? linkRes.user : updatedUserAfterEmailVerification; + + return { + status: "OK", + email: emailForWhomTokenWasGenerated, + user: userAfterWeTriedLinking, + }; + } + } + + let tokenConsumptionResponse = await options.recipeImplementation.consumeRecoverAccountToken({ + token, + tenantId, + userContext, + }); + + // todo decide how to handle these + if (tokenConsumptionResponse.status === "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR") { + return tokenConsumptionResponse; + } else if (tokenConsumptionResponse.status === "WRONG_CREDENTIALS_ERROR") { + return tokenConsumptionResponse; + } else if (tokenConsumptionResponse.status === "INVALID_AUTHENTICATOR_ERROR") { + return tokenConsumptionResponse; + } + + let userIdForWhomTokenWasGenerated = tokenConsumptionResponse.userId; + let emailForWhomTokenWasGenerated = tokenConsumptionResponse.email; + + let existingUser = await getUser(tokenConsumptionResponse.userId, userContext); + + if (existingUser === undefined) { + // This should happen only cause of a race condition where the user + // might be deleted before token creation and consumption. + // Also note that this being undefined doesn't mean that the webauthn + // user does not exist, but it means that there is no recipe or primary user + // for whom the token was generated. + return { + status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR", + }; + } + + // We start by checking if the existingUser is a primary user or not. If it is, + // then we will try and create a new webauthn user and link it to the primary user (if required) + + if (existingUser.isPrimaryUser) { + // If this user contains an webauthn account for whom the token was generated, + // then we update that user's credential. + let webauthnUserIsLinkedToExistingUser = + existingUser.loginMethods.find((lm) => { + // we check based on user ID and not email because the only time + // the primary user ID is used for token generation is if the webauthn + // user did not exist - in which case the value of emailPasswordUserExists will + // resolve to false anyway, and that's what we want. + + // there is an edge case where if the webauthn recipe user was created + // after the account recovery token generation, and it was linked to the + // primary user id (userIdForWhomTokenWasGenerated), in this case, + // we still don't allow credntials update, cause the user should try again + // and the token should be regenerated for the right recipe user. + return ( + lm.recipeUserId.getAsString() === userIdForWhomTokenWasGenerated && + lm.recipeId === "webauthn" + ); + }) !== undefined; + + if (webauthnUserIsLinkedToExistingUser) { + return doRegisterCredentialAndVerifyEmailAndTryLinkIfNotPrimary( + new RecipeUserId(userIdForWhomTokenWasGenerated) + ); + } else { + // this means that the existingUser does not have an webauthn user associated + // with it. It could now mean that no webauthn user exists, or it could mean that + // the the webauthn user exists, but it's not linked to the current account. + // If no webauthn user doesn't exists, we will create one, and link it to the existing account. + // If webauthn user exists, then it means there is some race condition cause + // then the token should have been generated for that user instead of the primary user, + // and it shouldn't have come into this branch. So we can simply send a recover account + // invalid error and the user can try again. + + // NOTE: We do not ask the dev if we should do account linking or not here + // cause we already have asked them this when generating an account recovery reset token. + // In the edge case that the dev changes account linking allowance from true to false + // when it comes here, only a new recipe user id will be created and not linked + // cause createPrimaryUserIdOrLinkAccounts will disallow linking. This doesn't + // really cause any security issue. + + let createUserResponse = await options.recipeImplementation.createNewRecipeUser({ + tenantId, + webauthnGeneratedOptionsId, + credential, + userContext, + }); + + // todo decide how to handle these + if (createUserResponse.status === "WRONG_CREDENTIALS_ERROR") { + return createUserResponse; + } else if (createUserResponse.status === "INVALID_AUTHENTICATOR_ERROR") { + return createUserResponse; + } else if (createUserResponse.status === "EMAIL_ALREADY_EXISTS_ERROR") { + // this means that the user already existed and we can just return an invalid + // token (see the above comment) + return { + status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR", + }; + } else { + // we mark the email as verified because account recovery also requires + // access to the email to work.. This has a good side effect that + // any other login method with the same email in existingAccount will also get marked + // as verified. + await markEmailAsVerified( + createUserResponse.user.loginMethods[0].recipeUserId, + tokenConsumptionResponse.email + ); + const updatedUser = await getUser(createUserResponse.user.id, userContext); + if (updatedUser === undefined) { + throw new Error("Should never happen - user deleted after during account recovery"); + } + createUserResponse.user = updatedUser; + // Now we try and link the accounts. The function below will try and also + // create a primary user of the new account, and if it does that, it's OK.. + // But in most cases, it will end up linking to existing account since the + // email is shared. + // We do not take try linking by session here, since this is supposed to be called without a session + // Still, the session object is passed around because it is a required input for shouldDoAutomaticAccountLinking + const linkRes = await AccountLinking.getInstance().tryLinkingByAccountInfoOrCreatePrimaryUser({ + tenantId, + inputUser: createUserResponse.user, + session: undefined, + userContext, + }); + const userAfterLinking = linkRes.status === "OK" ? linkRes.user : createUserResponse.user; + if (linkRes.status === "OK" && linkRes.user.id !== existingUser.id) { + // this means that the account we just linked to + // was not the one we had expected to link it to. This can happen + // due to some race condition or the other.. Either way, this + // is not an issue and we can just return OK + } + + return { + status: "OK", + email: tokenConsumptionResponse.email, + user: userAfterLinking, + }; + } + } + } else { + // This means that the existing user is not a primary account, which implies that + // it must be a non linked webauthn account. In this case, we simply update the credential. + // Linking to an existing account will be done after the user goes through the email + // verification flow once they log in (if applicable). + return doRegisterCredentialAndVerifyEmailAndTryLinkIfNotPrimary( + new RecipeUserId(userIdForWhomTokenWasGenerated) + ); + } + }, }; } diff --git a/lib/ts/recipe/webauthn/api/recoverAccount.ts b/lib/ts/recipe/webauthn/api/recoverAccount.ts new file mode 100644 index 000000000..9475beb36 --- /dev/null +++ b/lib/ts/recipe/webauthn/api/recoverAccount.ts @@ -0,0 +1,72 @@ +/* Copyright (c) 2021, VRAI Labs and/or its affiliates. All rights reserved. + * + * This software is licensed under the Apache License, Version 2.0 (the + * "License") as published by the Apache Software Foundation. + * + * You may not use this file except in compliance with the License. You may + * obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +import { send200Response } from "../../../utils"; +import { validateCredentialOrThrowError, validatewebauthnGeneratedOptionsIdOrThrowError } from "./utils"; +import STError from "../error"; +import { APIInterface, APIOptions } from "../"; +import { UserContext } from "../../../types"; + +export default async function recoverAccount( + apiImplementation: APIInterface, + tenantId: string, + options: APIOptions, + userContext: UserContext +): Promise { + // Logic as per https://github.com/supertokens/supertokens-node/issues/22#issuecomment-710512442 + + if (apiImplementation.recoverAccountTokenPOST === undefined) { + return false; + } + + const requestBody = await options.req.getJSONBody(); + let webauthnGeneratedOptionsId = await validatewebauthnGeneratedOptionsIdOrThrowError( + requestBody.webauthnGeneratedOptionsId + ); + let credential = await validateCredentialOrThrowError(requestBody.credential); + let token = requestBody.token; + + if (token === undefined) { + throw new STError({ + type: STError.BAD_INPUT_ERROR, + message: "Please provide the account recovery token", + }); + } + if (typeof token !== "string") { + throw new STError({ + type: STError.BAD_INPUT_ERROR, + message: "The account recovery token must be a string", + }); + } + + let result = await apiImplementation.recoverAccountTokenPOST({ + webauthnGeneratedOptionsId, + credential, + token, + tenantId, + options, + userContext, + }); + + send200Response( + options.res, + result.status === "OK" + ? { + status: "OK", + } + : result + ); + return true; +} diff --git a/lib/ts/recipe/webauthn/api/registerOptions.ts b/lib/ts/recipe/webauthn/api/registerOptions.ts index 74d309a48..1eb7e4da1 100644 --- a/lib/ts/recipe/webauthn/api/registerOptions.ts +++ b/lib/ts/recipe/webauthn/api/registerOptions.ts @@ -31,15 +31,21 @@ export default async function registerOptions( const requestBody = await options.req.getJSONBody(); let email = requestBody.email; - if (email === undefined || typeof email !== "string") { + let recoverAccountToken = requestBody.recoverAccountToken; + + if ( + (email === undefined || typeof email !== "string") && + (recoverAccountToken === undefined || typeof recoverAccountToken !== "string") + ) { throw new STError({ type: STError.BAD_INPUT_ERROR, - message: "Please provide the email", + message: "Please provide the email or the recover account token", }); } let result = await apiImplementation.registerOptionsPOST({ email, + recoverAccountToken, tenantId, options, userContext, diff --git a/lib/ts/recipe/webauthn/api/signInOptions.ts b/lib/ts/recipe/webauthn/api/signInOptions.ts index 2e2736aa5..9cf292f9f 100644 --- a/lib/ts/recipe/webauthn/api/signInOptions.ts +++ b/lib/ts/recipe/webauthn/api/signInOptions.ts @@ -34,5 +34,6 @@ export default async function signInOptions( }); send200Response(options.res, result); + return true; } diff --git a/lib/ts/recipe/webauthn/api/signin.ts b/lib/ts/recipe/webauthn/api/signin.ts index b834ecb84..1d4eca98c 100644 --- a/lib/ts/recipe/webauthn/api/signin.ts +++ b/lib/ts/recipe/webauthn/api/signin.ts @@ -29,7 +29,6 @@ export default async function signInAPI( options: APIOptions, userContext: UserContext ): Promise { - // Logic as per https://github.com/supertokens/supertokens-node/issues/20#issuecomment-710346362 if (apiImplementation.signInPOST === undefined) { return false; } diff --git a/lib/ts/recipe/webauthn/constants.ts b/lib/ts/recipe/webauthn/constants.ts index d23bf8dd7..a94993d0f 100644 --- a/lib/ts/recipe/webauthn/constants.ts +++ b/lib/ts/recipe/webauthn/constants.ts @@ -13,12 +13,6 @@ * under the License. */ -export const DEFAULT_REGISTER_ATTESTATION = "none"; - -export const DEFAULT_REGISTER_OPTIONS_TIMEOUT = 5000; - -export const DEFAULT_SIGNIN_OPTIONS_TIMEOUT = 5000; - export const REGISTER_OPTIONS_API = "/webauthn/options/register"; export const SIGNIN_OPTIONS_API = "/webauthn/options/signin"; @@ -32,3 +26,15 @@ export const GENERATE_RECOVER_ACCOUNT_TOKEN_API = "/user/webauthn/reset/token"; export const RECOVER_ACCOUNT_API = "/user/webauthn/reset"; export const SIGNUP_EMAIL_EXISTS_API = "/webauthn/email/exists"; + +// defaults that can be overridden by the developer +export const DEFAULT_REGISTER_OPTIONS_ATTESTATION = "none"; +export const DEFAULT_REGISTER_OPTIONS_REQUIRE_RESIDENT_KEY = false; +export const DEFAULT_REGISTER_OPTIONS_RESIDENT_KEY = "required"; +export const DEFAULT_REGISTER_OPTIONS_USER_VERIFICATION = "preferred"; + +export const DEFAULT_SIGNIN_OPTIONS_USER_VERIFICATION = "preferred"; + +export const DEFAULT_REGISTER_OPTIONS_TIMEOUT = 5000; + +export const DEFAULT_SIGNIN_OPTIONS_TIMEOUT = 5000; diff --git a/lib/ts/recipe/webauthn/index.ts b/lib/ts/recipe/webauthn/index.ts index 78ad6385b..932ef4997 100644 --- a/lib/ts/recipe/webauthn/index.ts +++ b/lib/ts/recipe/webauthn/index.ts @@ -15,14 +15,21 @@ import Recipe from "./recipe"; import SuperTokensError from "./error"; -import { RecipeInterface, APIOptions, APIInterface, TypeWebauthnEmailDeliveryInput } from "./types"; +import { RecipeInterface, APIOptions, APIInterface, TypeWebauthnEmailDeliveryInput, CredentialPayload } from "./types"; import RecipeUserId from "../../recipeUserId"; import { DEFAULT_TENANT_ID } from "../multitenancy/constants"; -import { getPasswordResetLink } from "./utils"; +import { getRecoverAccountLink } from "./utils"; import { getRequestFromUserContext, getUser } from "../.."; import { getUserContext } from "../../utils"; import { SessionContainerInterface } from "../session/types"; import { User, UserContext } from "../../types"; +import { + DEFAULT_REGISTER_OPTIONS_REQUIRE_RESIDENT_KEY, + DEFAULT_REGISTER_OPTIONS_RESIDENT_KEY, + DEFAULT_REGISTER_OPTIONS_USER_VERIFICATION, + DEFAULT_SIGNIN_OPTIONS_USER_VERIFICATION, +} from "./constants"; +import { updateEmailOrPassword } from "../emailpassword/index"; export default class Wrapper { static init = Recipe.init; @@ -38,37 +45,44 @@ export default class Wrapper { attestation: "none" | "indirect" | "direct" | "enterprise" = "none", tenantId: string, userContext: Record - ): Promise<{ - status: "OK"; - webauthnGeneratedOptionsId: string; - rp: { - id: string; - name: string; - }; - user: { - id: string; - name: string; - displayName: string; - }; - challenge: string; - timeout: number; - excludeCredentials: { - id: string; - type: string; - transports: ("ble" | "hybrid" | "internal" | "nfc" | "usb")[]; - }[]; - attestation: "none" | "indirect" | "direct" | "enterprise"; - pubKeyCredParams: { - alg: number; - type: string; - }[]; - authenticatorSelection: { - requireResidentKey: boolean; - residentKey: "required" | "preferred" | "discouraged"; - userVerification: "required" | "preferred" | "discouraged"; - }; - }> { + ): Promise< + | { + status: "OK"; + webauthnGeneratedOptionsId: string; + rp: { + id: string; + name: string; + }; + user: { + id: string; + name: string; + displayName: string; + }; + challenge: string; + timeout: number; + excludeCredentials: { + id: string; + type: "public-key"; + transports: ("ble" | "hybrid" | "internal" | "nfc" | "usb")[]; + }[]; + attestation: "none" | "indirect" | "direct" | "enterprise"; + pubKeyCredParams: { + alg: number; + type: "public-key"; + }[]; + authenticatorSelection: { + requireResidentKey: boolean; + residentKey: "required" | "preferred" | "discouraged"; + userVerification: "required" | "preferred" | "discouraged"; + }; + } + | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } + | { status: "EMAIL_MISSING_ERROR" } + > { return Recipe.getInstanceOrThrowError().recipeInterfaceImpl.registerOptions({ + requireResidentKey: DEFAULT_REGISTER_OPTIONS_REQUIRE_RESIDENT_KEY, + residentKey: DEFAULT_REGISTER_OPTIONS_RESIDENT_KEY, + userVerification: DEFAULT_REGISTER_OPTIONS_USER_VERIFICATION, email, relyingPartyId, relyingPartyName, @@ -94,6 +108,7 @@ export default class Wrapper { userVerification: "required" | "preferred" | "discouraged"; }> { return Recipe.getInstanceOrThrowError().recipeInterfaceImpl.signInOptions({ + userVerification: DEFAULT_SIGNIN_OPTIONS_USER_VERIFICATION, relyingPartyId, origin, timeout, @@ -102,259 +117,256 @@ export default class Wrapper { }); } - // static signIn( - // tenantId: string, - // email: string, - // password: string, - // session?: undefined, - // userContext?: Record - // ): Promise<{ status: "OK"; user: User; recipeUserId: RecipeUserId } | { status: "WRONG_CREDENTIALS_ERROR" }>; - // static signIn( - // tenantId: string, - // email: string, - // password: string, - // session: SessionContainerInterface, - // userContext?: Record - // ): Promise< - // | { status: "OK"; user: User; recipeUserId: RecipeUserId } - // | { status: "WRONG_CREDENTIALS_ERROR" } - // | { - // status: "LINKING_TO_SESSION_USER_FAILED"; - // reason: - // | "EMAIL_VERIFICATION_REQUIRED" - // | "RECIPE_USER_ID_ALREADY_LINKED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR" - // | "ACCOUNT_INFO_ALREADY_ASSOCIATED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR" - // | "SESSION_USER_ACCOUNT_INFO_ALREADY_ASSOCIATED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR"; - // } - // >; - // static signIn( - // tenantId: string, - // email: string, - // password: string, - // session?: SessionContainerInterface, - // userContext?: Record - // ): Promise< - // | { status: "OK"; user: User; recipeUserId: RecipeUserId } - // | { status: "WRONG_CREDENTIALS_ERROR" } - // | { - // status: "LINKING_TO_SESSION_USER_FAILED"; - // reason: - // | "EMAIL_VERIFICATION_REQUIRED" - // | "RECIPE_USER_ID_ALREADY_LINKED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR" - // | "ACCOUNT_INFO_ALREADY_ASSOCIATED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR" - // | "SESSION_USER_ACCOUNT_INFO_ALREADY_ASSOCIATED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR"; - // } - // > { - // return Recipe.getInstanceOrThrowError().recipeInterfaceImpl.signIn({ - // email, - // password, - // session, - // shouldTryLinkingWithSessionUser: !!session, - // tenantId: tenantId === undefined ? DEFAULT_TENANT_ID : tenantId, - // userContext: getUserContext(userContext), - // }); - // } - - // static async verifyCredentials( - // tenantId: string, - // email: string, - // password: string, - // userContext?: Record - // ): Promise<{ status: "OK" | "WRONG_CREDENTIALS_ERROR" }> { - // const resp = await Recipe.getInstanceOrThrowError().recipeInterfaceImpl.verifyCredentials({ - // email, - // password, - // tenantId: tenantId === undefined ? DEFAULT_TENANT_ID : tenantId, - // userContext: getUserContext(userContext), - // }); - - // // Here we intentionally skip the user and recipeUserId props, because we do not want apps to accidentally use this to sign in - // return { - // status: resp.status, - // }; - // } - - // /** - // * We do not make email optional here cause we want to - // * allow passing in primaryUserId. If we make email optional, - // * and if the user provides a primaryUserId, then it may result in two problems: - // * - there is no recipeUserId = input primaryUserId, in this case, - // * this function will throw an error - // * - There is a recipe userId = input primaryUserId, but that recipe has no email, - // * or has wrong email compared to what the user wanted to generate a reset token for. - // * - // * And we want to allow primaryUserId being passed in. - // */ - // static createResetPasswordToken( - // tenantId: string, - // userId: string, - // email: string, - // userContext?: Record - // ): Promise<{ status: "OK"; token: string } | { status: "UNKNOWN_USER_ID_ERROR" }> { - // return Recipe.getInstanceOrThrowError().recipeInterfaceImpl.createResetPasswordToken({ - // userId, - // email, - // tenantId: tenantId === undefined ? DEFAULT_TENANT_ID : tenantId, - // userContext: getUserContext(userContext), - // }); - // } - - // static async resetPasswordUsingToken( - // tenantId: string, - // token: string, - // newPassword: string, - // userContext?: Record - // ): Promise< - // | { - // status: "OK" | "UNKNOWN_USER_ID_ERROR" | "RESET_PASSWORD_INVALID_TOKEN_ERROR"; - // } - // | { status: "PASSWORD_POLICY_VIOLATED_ERROR"; failureReason: string } - // > { - // const consumeResp = await Wrapper.consumePasswordResetToken(tenantId, token, userContext); - - // if (consumeResp.status !== "OK") { - // return consumeResp; - // } - - // let result = await Wrapper.updateEmailOrPassword({ - // recipeUserId: new RecipeUserId(consumeResp.userId), - // email: consumeResp.email, - // password: newPassword, - // tenantIdForPasswordPolicy: tenantId, - // userContext, - // }); - - // if (result.status === "EMAIL_ALREADY_EXISTS_ERROR" || result.status === "EMAIL_CHANGE_NOT_ALLOWED_ERROR") { - // throw new global.Error("Should never come here cause we are not updating email"); - // } - // if (result.status === "PASSWORD_POLICY_VIOLATED_ERROR") { - // return { - // status: "PASSWORD_POLICY_VIOLATED_ERROR", - // failureReason: result.failureReason, - // }; - // } - // return { - // status: result.status, - // }; - // } - - // static consumePasswordResetToken( - // tenantId: string, - // token: string, - // userContext?: Record - // ): Promise< - // | { - // status: "OK"; - // email: string; - // userId: string; - // } - // | { status: "RESET_PASSWORD_INVALID_TOKEN_ERROR" } - // > { - // return Recipe.getInstanceOrThrowError().recipeInterfaceImpl.consumePasswordResetToken({ - // token, - // tenantId: tenantId === undefined ? DEFAULT_TENANT_ID : tenantId, - // userContext: getUserContext(userContext), - // }); - // } - - // static updateEmailOrPassword(input: { - // recipeUserId: RecipeUserId; - // email?: string; - // password?: string; - // userContext?: Record; - // applyPasswordPolicy?: boolean; - // tenantIdForPasswordPolicy?: string; - // }): Promise< - // | { - // status: "OK" | "UNKNOWN_USER_ID_ERROR" | "EMAIL_ALREADY_EXISTS_ERROR"; - // } - // | { - // status: "EMAIL_CHANGE_NOT_ALLOWED_ERROR"; - // reason: string; - // } - // | { status: "PASSWORD_POLICY_VIOLATED_ERROR"; failureReason: string } - // > { - // return Recipe.getInstanceOrThrowError().recipeInterfaceImpl.updateEmailOrPassword({ - // ...input, - // userContext: getUserContext(input.userContext), - // tenantIdForPasswordPolicy: - // input.tenantIdForPasswordPolicy === undefined ? DEFAULT_TENANT_ID : input.tenantIdForPasswordPolicy, - // }); - // } - - // static async createResetPasswordLink( - // tenantId: string, - // userId: string, - // email: string, - // userContext?: Record - // ): Promise<{ status: "OK"; link: string } | { status: "UNKNOWN_USER_ID_ERROR" }> { - // const ctx = getUserContext(userContext); - // let token = await createResetPasswordToken(tenantId, userId, email, ctx); - // if (token.status === "UNKNOWN_USER_ID_ERROR") { - // return token; - // } - - // const recipeInstance = Recipe.getInstanceOrThrowError(); - // return { - // status: "OK", - // link: getPasswordResetLink({ - // appInfo: recipeInstance.getAppInfo(), - // token: token.token, - // tenantId: tenantId === undefined ? DEFAULT_TENANT_ID : tenantId, - // request: getRequestFromUserContext(ctx), - // userContext: ctx, - // }), - // }; - // } - - // static async sendResetPasswordEmail( - // tenantId: string, - // userId: string, - // email: string, - // userContext?: Record - // ): Promise<{ status: "OK" | "UNKNOWN_USER_ID_ERROR" }> { - // const user = await getUser(userId, userContext); - // if (!user) { - // return { status: "UNKNOWN_USER_ID_ERROR" }; - // } - - // const loginMethod = user.loginMethods.find((m) => m.recipeId === "emailpassword" && m.hasSameEmailAs(email)); - // if (!loginMethod) { - // return { status: "UNKNOWN_USER_ID_ERROR" }; - // } - - // let link = await createResetPasswordLink(tenantId, userId, email, userContext); - // if (link.status === "UNKNOWN_USER_ID_ERROR") { - // return link; - // } - - // await sendEmail({ - // passwordResetLink: link.link, - // type: "PASSWORD_RESET", - // user: { - // id: user.id, - // recipeUserId: loginMethod.recipeUserId, - // email: loginMethod.email!, - // }, - // tenantId, - // userContext, - // }); - - // return { - // status: "OK", - // }; - // } - - // static async sendEmail( - // input: TypeWebauthnEmailDeliveryInput & { userContext?: Record } - // ): Promise { - // let recipeInstance = Recipe.getInstanceOrThrowError(); - // return await recipeInstance.emailDelivery.ingredientInterfaceImpl.sendEmail({ - // ...input, - // tenantId: input.tenantId === undefined ? DEFAULT_TENANT_ID : input.tenantId, - // userContext: getUserContext(input.userContext), - // }); - // } + static signIn( + tenantId: string, + webauthnGeneratedOptionsId: string, + credential: CredentialPayload, + session?: undefined, + userContext?: Record + ): Promise< + | { status: "OK"; user: User; recipeUserId: RecipeUserId } + | { status: "WRONG_CREDENTIALS_ERROR" } + | { status: "INVALID_AUTHENTICATOR_ERROR" } + >; + static signIn( + tenantId: string, + webauthnGeneratedOptionsId: string, + credential: CredentialPayload, + session: SessionContainerInterface, + userContext?: Record + ): Promise< + | { status: "OK"; user: User; recipeUserId: RecipeUserId } + | { status: "WRONG_CREDENTIALS_ERROR" } + | { status: "INVALID_AUTHENTICATOR_ERROR" } + | { + status: "LINKING_TO_SESSION_USER_FAILED"; + reason: + | "EMAIL_VERIFICATION_REQUIRED" + | "RECIPE_USER_ID_ALREADY_LINKED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR" + | "ACCOUNT_INFO_ALREADY_ASSOCIATED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR" + | "SESSION_USER_ACCOUNT_INFO_ALREADY_ASSOCIATED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR"; + } + >; + static signIn( + tenantId: string, + webauthnGeneratedOptionsId: string, + credential: CredentialPayload, + session?: SessionContainerInterface, + userContext?: Record + ): Promise< + | { status: "OK"; user: User; recipeUserId: RecipeUserId } + | { status: "WRONG_CREDENTIALS_ERROR" } + | { status: "INVALID_AUTHENTICATOR_ERROR" } + | { + status: "LINKING_TO_SESSION_USER_FAILED"; + reason: + | "EMAIL_VERIFICATION_REQUIRED" + | "RECIPE_USER_ID_ALREADY_LINKED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR" + | "ACCOUNT_INFO_ALREADY_ASSOCIATED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR" + | "SESSION_USER_ACCOUNT_INFO_ALREADY_ASSOCIATED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR"; + } + > { + return Recipe.getInstanceOrThrowError().recipeInterfaceImpl.signIn({ + webauthnGeneratedOptionsId, + credential, + session, + shouldTryLinkingWithSessionUser: !!session, + tenantId: tenantId === undefined ? DEFAULT_TENANT_ID : tenantId, + userContext: getUserContext(userContext), + }); + } + + static async verifyCredentials( + tenantId: string, + webauthnGeneratedOptionsId: string, + credential: CredentialPayload, + userContext?: Record + ): Promise<{ status: "OK" } | { status: "WRONG_CREDENTIALS_ERROR" } | { status: "INVALID_AUTHENTICATOR_ERROR" }> { + const resp = await Recipe.getInstanceOrThrowError().recipeInterfaceImpl.verifyCredentials({ + webauthnGeneratedOptionsId, + credential, + tenantId: tenantId === undefined ? DEFAULT_TENANT_ID : tenantId, + userContext: getUserContext(userContext), + }); + + // Here we intentionally skip the user and recipeUserId props, because we do not want apps to accidentally use this to sign in + return { + status: resp.status, + }; + } + + /** + * We do not make email optional here cause we want to + * allow passing in primaryUserId. If we make email optional, + * and if the user provides a primaryUserId, then it may result in two problems: + * - there is no recipeUserId = input primaryUserId, in this case, + * this function will throw an error + * - There is a recipe userId = input primaryUserId, but that recipe has no email, + * or has wrong email compared to what the user wanted to generate a reset token for. + * + * And we want to allow primaryUserId being passed in. + */ + static generateRecoverAccountToken( + tenantId: string, + userId: string, + email: string, + userContext?: Record + ): Promise<{ status: "OK"; token: string } | { status: "UNKNOWN_USER_ID_ERROR" }> { + return Recipe.getInstanceOrThrowError().recipeInterfaceImpl.generateRecoverAccountToken({ + userId, + email, + tenantId: tenantId === undefined ? DEFAULT_TENANT_ID : tenantId, + userContext: getUserContext(userContext), + }); + } + + static async recoverAccountUsingToken( + tenantId: string, + webauthnGeneratedOptionsId: string, + token: string, + credential: CredentialPayload, + userContext?: Record + ): Promise< + | { + status: "OK" | "WRONG_CREDENTIALS_ERROR" | "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR"; + } + | { status: "INVALID_AUTHENTICATOR_ERROR"; failureReason: string } + > { + const consumeResp = await Wrapper.consumeRecoverAccountToken(tenantId, token, userContext); + + if (consumeResp.status !== "OK") { + return consumeResp; + } + + let result = await Wrapper.registerCredential({ + recipeUserId: new RecipeUserId(consumeResp.userId), + webauthnGeneratedOptionsId, + credential, + tenantId, + userContext, + }); + + if (result.status === "INVALID_AUTHENTICATOR_ERROR") { + return { + status: "INVALID_AUTHENTICATOR_ERROR", + failureReason: result.reason, + }; + } + return { + status: result.status, + }; + } + + static consumeRecoverAccountToken( + tenantId: string, + token: string, + userContext?: Record + ): Promise< + | { + status: "OK"; + email: string; + userId: string; + } + | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } + > { + return Recipe.getInstanceOrThrowError().recipeInterfaceImpl.consumeRecoverAccountToken({ + token, + tenantId: tenantId === undefined ? DEFAULT_TENANT_ID : tenantId, + userContext: getUserContext(userContext), + }); + } + + static registerCredential(input: { + recipeUserId: RecipeUserId; + tenantId: string; + webauthnGeneratedOptionsId: string; + credential: CredentialPayload; + userContext?: Record; + }): Promise< + | { + status: "OK" | "WRONG_CREDENTIALS_ERROR"; + } + | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string } + > { + return Recipe.getInstanceOrThrowError().recipeInterfaceImpl.registerCredential({ + ...input, + userContext: getUserContext(input.userContext), + }); + } + + static async createRecoverAccountLink( + tenantId: string, + userId: string, + email: string, + userContext?: Record + ): Promise<{ status: "OK"; link: string } | { status: "UNKNOWN_USER_ID_ERROR" }> { + const ctx = getUserContext(userContext); + let token = await this.generateRecoverAccountToken(tenantId, userId, email, ctx); + if (token.status === "UNKNOWN_USER_ID_ERROR") { + return token; + } + + const recipeInstance = Recipe.getInstanceOrThrowError(); + return { + status: "OK", + link: getRecoverAccountLink({ + appInfo: recipeInstance.getAppInfo(), + token: token.token, + tenantId: tenantId === undefined ? DEFAULT_TENANT_ID : tenantId, + request: getRequestFromUserContext(ctx), + userContext: ctx, + }), + }; + } + + static async sendRecoverAccountEmail( + tenantId: string, + userId: string, + email: string, + userContext?: Record + ): Promise<{ status: "OK" | "UNKNOWN_USER_ID_ERROR" }> { + const user = await getUser(userId, userContext); + if (!user) { + return { status: "UNKNOWN_USER_ID_ERROR" }; + } + + const loginMethod = user.loginMethods.find((m) => m.recipeId === "webauthn" && m.hasSameEmailAs(email)); + if (!loginMethod) { + return { status: "UNKNOWN_USER_ID_ERROR" }; + } + + let link = await this.createRecoverAccountLink(tenantId, userId, email, userContext); + if (link.status === "UNKNOWN_USER_ID_ERROR") { + return link; + } + + await sendEmail({ + recoverAccountLink: link.link, + type: "RECOVER_ACCOUNT", + user: { + id: user.id, + recipeUserId: loginMethod.recipeUserId, + email: loginMethod.email!, + }, + tenantId, + userContext, + }); + + return { + status: "OK", + }; + } + + static async sendEmail( + input: TypeWebauthnEmailDeliveryInput & { userContext?: Record } + ): Promise { + let recipeInstance = Recipe.getInstanceOrThrowError(); + return await recipeInstance.emailDelivery.ingredientInterfaceImpl.sendEmail({ + ...input, + tenantId: input.tenantId === undefined ? DEFAULT_TENANT_ID : input.tenantId, + userContext: getUserContext(input.userContext), + }); + } } export let init = Wrapper.init; @@ -365,22 +377,22 @@ export let registerOptions = Wrapper.registerOptions; export let signInOptions = Wrapper.signInOptions; -// export let signIn = Wrapper.signIn; +export let signIn = Wrapper.signIn; -// export let verifyCredentials = Wrapper.verifyCredentials; +export let verifyCredentials = Wrapper.verifyCredentials; -// export let createResetPasswordToken = Wrapper.createResetPasswordToken; +export let generateRecoverAccountToken = Wrapper.generateRecoverAccountToken; -// export let resetPasswordUsingToken = Wrapper.resetPasswordUsingToken; +export let recoverAccountUsingToken = Wrapper.recoverAccountUsingToken; -// export let consumePasswordResetToken = Wrapper.consumePasswordResetToken; +export let consumeRecoverAccountToken = Wrapper.consumeRecoverAccountToken; -// export let updateEmailOrPassword = Wrapper.updateEmailOrPassword; +export let registerCredential = Wrapper.registerCredential; export type { RecipeInterface, APIOptions, APIInterface }; -// export let createResetPasswordLink = Wrapper.createResetPasswordLink; +export let createRecoverAccountLink = Wrapper.createRecoverAccountLink; -// export let sendResetPasswordEmail = Wrapper.sendResetPasswordEmail; +export let sendRecoverAccountEmail = Wrapper.sendRecoverAccountEmail; -// export let sendEmail = Wrapper.sendEmail; +export let sendEmail = Wrapper.sendEmail; diff --git a/lib/ts/recipe/webauthn/recipe.ts b/lib/ts/recipe/webauthn/recipe.ts index 779855fc6..6e0b94302 100644 --- a/lib/ts/recipe/webauthn/recipe.ts +++ b/lib/ts/recipe/webauthn/recipe.ts @@ -19,11 +19,22 @@ import { NormalisedAppinfo, APIHandled, HTTPMethod, RecipeListFunction, UserCont import STError from "./error"; import { validateAndNormaliseUserInput } from "./utils"; import NormalisedURLPath from "../../normalisedURLPath"; -import { SIGN_UP_API, SIGN_IN_API, REGISTER_OPTIONS_API, SIGNIN_OPTIONS_API } from "./constants"; +import { + SIGN_UP_API, + SIGN_IN_API, + REGISTER_OPTIONS_API, + SIGNIN_OPTIONS_API, + GENERATE_RECOVER_ACCOUNT_TOKEN_API, + RECOVER_ACCOUNT_API, + SIGNUP_EMAIL_EXISTS_API, +} from "./constants"; import signUpAPI from "./api/signup"; import signInAPI from "./api/signin"; import registerOptionsAPI from "./api/registerOptions"; import signInOptionsAPI from "./api/signInOptions"; +import generateRecoverAccountTokenAPI from "./api/generateRecoverAccountToken"; +import recoverAccountAPI from "./api/recoverAccount"; +import emailExistsAPI from "./api/emailExists"; import { isTestEnv, send200Response } from "../../utils"; import RecipeImplementation from "./recipeImplementation"; import APIImplementation from "./api/implementation"; @@ -86,6 +97,7 @@ export default class Recipe extends RecipeModule { ? new EmailDeliveryIngredient(this.config.getEmailDeliveryConfig(this.isInServerlessEnv)) : ingredients.emailDelivery; + // todo check correctness PostSuperTokensInitCallbacks.addPostInitCallback(() => { const mfaInstance = MultiFactorAuthRecipe.getInstance(); if (mfaInstance !== undefined) { @@ -190,8 +202,6 @@ export default class Recipe extends RecipeModule { ]; } - // todo how to implement this? - // If the list is empty we generate an email address to make the flow where the user is never asked for // an email address easier to implement. In many cases when the user adds an email-password factor, they // actually only want to add a password and do not care about the associated email address. @@ -203,7 +213,7 @@ export default class Recipe extends RecipeModule { return { status: "OK", factorIdToEmailsMap: { - emailpassword: result, + webauthn: result, }, }; }); @@ -273,24 +283,24 @@ export default class Recipe extends RecipeModule { disabled: this.apiImpl.signInPOST === undefined, }, - // { - // method: "post", - // pathWithoutApiBasePath: new NormalisedURLPath(GENERATE_RECOVER_ACCOUNT_TOKEN_API), - // id: GENERATE_RECOVER_ACCOUNT_TOKEN_API, - // disabled: this.apiImpl.generateRecoverAccountTokenPOST === undefined, - // }, - // { - // method: "post", - // pathWithoutApiBasePath: new NormalisedURLPath(RECOVER_ACCOUNT_API), - // id: RECOVER_ACCOUNT_API, - // disabled: this.apiImpl.recoverAccountPOST === undefined, - // }, - // { - // method: "get", - // pathWithoutApiBasePath: new NormalisedURLPath(SIGNUP_EMAIL_EXISTS_API), - // id: SIGNUP_EMAIL_EXISTS_API, - // disabled: this.apiImpl.emailExistsGET === undefined, - // }, + { + method: "post", + pathWithoutApiBasePath: new NormalisedURLPath(GENERATE_RECOVER_ACCOUNT_TOKEN_API), + id: GENERATE_RECOVER_ACCOUNT_TOKEN_API, + disabled: this.apiImpl.generateRecoverAccountTokenPOST === undefined, + }, + { + method: "post", + pathWithoutApiBasePath: new NormalisedURLPath(RECOVER_ACCOUNT_API), + id: RECOVER_ACCOUNT_API, + disabled: this.apiImpl.recoverAccountPOST === undefined, + }, + { + method: "get", + pathWithoutApiBasePath: new NormalisedURLPath(SIGNUP_EMAIL_EXISTS_API), + id: SIGNUP_EMAIL_EXISTS_API, + disabled: this.apiImpl.emailExistsGET === undefined, + }, ]; }; @@ -321,15 +331,13 @@ export default class Recipe extends RecipeModule { return await signUpAPI(this.apiImpl, tenantId, options, userContext); } else if (id === SIGN_IN_API) { return await signInAPI(this.apiImpl, tenantId, options, userContext); - } - //else if (id === GENERATE_RECOVER_ACCOUNT_TOKEN_API) { - // return await generateRecoverAccountTokenAPI(this.apiImpl, tenantId, options, userContext); - // } else if (id === RECOVER_ACCOUNT_API) { - // return await recoverAccountAPI(this.apiImpl, tenantId, options, userContext); - // } else if (id === SIGNUP_EMAIL_EXISTS_API) { - // return await emailExistsAPI(this.apiImpl, tenantId, options, userContext); - // } - else return false; + } else if (id === GENERATE_RECOVER_ACCOUNT_TOKEN_API) { + return await generateRecoverAccountTokenAPI(this.apiImpl, tenantId, options, userContext); + } else if (id === RECOVER_ACCOUNT_API) { + return await recoverAccountAPI(this.apiImpl, tenantId, options, userContext); + } else if (id === SIGNUP_EMAIL_EXISTS_API) { + return await emailExistsAPI(this.apiImpl, tenantId, options, userContext); + } else return false; }; handleError = async (err: STError, _request: BaseRequest, response: BaseResponse): Promise => { diff --git a/lib/ts/recipe/webauthn/recipeImplementation.ts b/lib/ts/recipe/webauthn/recipeImplementation.ts index 026e6b63a..f5e23fb40 100644 --- a/lib/ts/recipe/webauthn/recipeImplementation.ts +++ b/lib/ts/recipe/webauthn/recipeImplementation.ts @@ -1,4 +1,4 @@ -import { RecipeInterface, TypeNormalisedInput } from "./types"; +import { CredentialPayload, RecipeInterface, TypeNormalisedInput } from "./types"; import AccountLinking from "../accountlinking/recipe"; import { Querier } from "../../querier"; import NormalisedURLPath from "../../normalisedURLPath"; @@ -8,6 +8,7 @@ import { DEFAULT_TENANT_ID } from "../multitenancy/constants"; import { UserContext, User as UserType } from "../../types"; import { LoginMethod, User } from "../../user"; import { AuthUtils } from "../../authUtils"; +import * as jose from "jose"; export default function getRecipeInterface( querier: Querier, @@ -15,7 +16,6 @@ export default function getRecipeInterface( ): RecipeInterface { return { registerOptions: async function ({ - email, relyingPartyId, relyingPartyName, origin, @@ -23,46 +23,94 @@ export default function getRecipeInterface( attestation = "none", tenantId, userContext, + ...rest }: { - email: string; - timeout: number; - attestation: "none" | "indirect" | "direct" | "enterprise"; relyingPartyId: string; relyingPartyName: string; origin: string; + requireResidentKey: boolean | undefined; // should default to false in order to allow multiple authenticators to be used; see https://auth0.com/blog/a-look-at-webauthn-resident-credentials/ + // default to 'required' in order store the private key locally on the device and not on the server + residentKey: "required" | "preferred" | "discouraged" | undefined; + // default to 'preferred' in order to verify the user (biometrics, pin, etc) based on the device preferences + userVerification: "required" | "preferred" | "discouraged" | undefined; + // default to 'none' in order to allow any authenticator and not verify attestation + attestation: "none" | "indirect" | "direct" | "enterprise" | undefined; + // default to 5 seconds + timeout: number | undefined; tenantId: string; userContext: UserContext; - }): Promise<{ - status: "OK"; - webauthnGeneratedOptionsId: string; - rp: { - id: string; - name: string; - }; - user: { - id: string; - name: string; - displayName: string; - }; - challenge: string; - timeout: number; - excludeCredentials: { - id: string; - type: string; - transports: ("ble" | "hybrid" | "internal" | "nfc" | "usb")[]; - }[]; - attestation: "none" | "indirect" | "direct" | "enterprise"; - pubKeyCredParams: { - alg: number; - type: string; - }[]; - authenticatorSelection: { - requireResidentKey: boolean; - residentKey: "required" | "preferred" | "discouraged"; - userVerification: "required" | "preferred" | "discouraged"; - }; - }> { - // the input user ID can be a recipe or a primary user ID. + } & ( + | { + recoverAccountToken: string; + } + | { + email: string; + } + )): Promise< + | { + status: "OK"; + webauthnGeneratedOptionsId: string; + rp: { + id: string; + name: string; + }; + user: { + id: string; + name: string; + displayName: string; + }; + challenge: string; + timeout: number; + excludeCredentials: { + id: string; + type: "public-key"; + transports: ("ble" | "hybrid" | "internal" | "nfc" | "usb")[]; + }[]; + attestation: "none" | "indirect" | "direct" | "enterprise"; + pubKeyCredParams: { + alg: number; + type: "public-key"; + }[]; + authenticatorSelection: { + requireResidentKey: boolean; + residentKey: "required" | "preferred" | "discouraged"; + userVerification: "required" | "preferred" | "discouraged"; + }; + } + | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } + | { status: "EMAIL_MISSING_ERROR" } + > { + let email = "email" in rest ? rest.email : undefined; + const recoverAccountToken = "recoverAccountToken" in rest ? rest.recoverAccountToken : undefined; + if (email === undefined && recoverAccountToken === undefined) { + return { + status: "EMAIL_MISSING_ERROR", + }; + } + + // todo check if should decode using Core or using sdk; atm decided on usinng the sdk so to not make another roundtrip to the server + // the actual verification will be done during consumeRecoverAccountToken + if (recoverAccountToken !== undefined) { + let decoded: jose.JWTPayload | undefined; + try { + decoded = await jose.decodeJwt(recoverAccountToken); + } catch (e) { + console.error(e); + + return { + status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR", + }; + } + + email = decoded?.email as string | undefined; + } + + if (!email) { + return { + status: "EMAIL_MISSING_ERROR", + }; + } + return await querier.sendPostRequest( new NormalisedURLPath( `/${tenantId === undefined ? DEFAULT_TENANT_ID : tenantId}/recipe/webauthn/options/register` @@ -88,7 +136,8 @@ export default function getRecipeInterface( }: { relyingPartyId: string; origin: string; - timeout: number; + userVerification: "required" | "preferred" | "discouraged" | undefined; // see register options + timeout: number | undefined; tenantId: string; userContext: UserContext; }): Promise<{ @@ -98,7 +147,6 @@ export default function getRecipeInterface( timeout: number; userVerification: "required" | "preferred" | "discouraged"; }> { - // todo crrectly retrieve relying party id and origin // the input user ID can be a recipe or a primary user ID. return await querier.sendPostRequest( new NormalisedURLPath( @@ -123,6 +171,9 @@ export default function getRecipeInterface( recipeUserId: RecipeUserId; } | { status: "EMAIL_ALREADY_EXISTS_ERROR" } + | { status: "WRONG_CREDENTIALS_ERROR" } + | { status: "EMAIL_ALREADY_EXISTS_ERROR" } + | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string } | { status: "LINKING_TO_SESSION_USER_FAILED"; reason: @@ -167,19 +218,7 @@ export default function getRecipeInterface( createNewRecipeUser: async function (input: { tenantId: string; - credential: { - id: string; - rawId: string; - response: { - clientDataJSON: string; - attestationObject: string; - transports?: ("ble" | "cable" | "hybrid" | "internal" | "nfc" | "smart-card" | "usb")[]; - userHandle: string; - }; - authenticatorAttachment: "platform" | "cross-platform"; - clientExtensionResults: Record; - type: "public-key"; - }; + credential: CredentialPayload; webauthnGeneratedOptionsId: string; userContext: UserContext; }): Promise< @@ -188,6 +227,9 @@ export default function getRecipeInterface( user: User; recipeUserId: RecipeUserId; } + | { status: "WRONG_CREDENTIALS_ERROR" } + // when the attestation is checked and is not valid or other cases in whcih the authenticator is not correct + | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string } | { status: "EMAIL_ALREADY_EXISTS_ERROR" } > { const resp = await querier.sendPostRequest( @@ -304,73 +346,55 @@ export default function getRecipeInterface( return response; }, - // generateRecoverAccountToken: async function ({ - // userId, - // email, - // tenantId, - // userContext, - // }: { - // userId: string; - // email: string; - // tenantId: string; - // userContext: UserContext; - // }): Promise<{ status: "OK"; token: string } | { status: "UNKNOWN_USER_ID_ERROR" }> { - // // the input user ID can be a recipe or a primary user ID. - // return await querier.sendPostRequest( - // new NormalisedURLPath( - // `/${tenantId === undefined ? DEFAULT_TENANT_ID : tenantId}/recipe/webauthn/user/recover/token` - // ), - // { - // userId, - // email, - // }, - // userContext - // ); - // }, + generateRecoverAccountToken: async function ({ + userId, + email, + tenantId, + userContext, + }: { + userId: string; + email: string; + tenantId: string; + userContext: UserContext; + }): Promise<{ status: "OK"; token: string } | { status: "UNKNOWN_USER_ID_ERROR" }> { + // the input user ID can be a recipe or a primary user ID. + return await querier.sendPostRequest( + new NormalisedURLPath( + `/${tenantId === undefined ? DEFAULT_TENANT_ID : tenantId}/recipe/webauthn/user/recover/token` + ), + { + userId, + email, + }, + userContext + ); + }, - // consumeRecoverAccountToken: async function ({ - // token, - // webauthnGeneratedOptionsId, - // credential, - // tenantId, - // userContext, - // }: { - // token: string; - // webauthnGeneratedOptionsId: string; - // credential: { - // id: string; - // rawId: string; - // response: { - // clientDataJSON: string; - // attestationObject: string; - // transports?: ("ble" | "cable" | "hybrid" | "internal" | "nfc" | "smart-card" | "usb")[]; - // userHandle: string; - // }; - // authenticatorAttachment: "platform" | "cross-platform"; - // clientExtensionResults: Record; - // type: "public-key"; - // }; - // tenantId: string; - // userContext: UserContext; - // }): Promise< - // | { - // status: "OK"; - // userId: string; - // email: string; - // } - // | { status: "RECOVER_ACCOUNT_INVALID_TOKEN_ERROR" } - // > { - // return await querier.sendPostRequest( - // new NormalisedURLPath( - // `/${tenantId === undefined ? DEFAULT_TENANT_ID : tenantId}/recipe/paskey/user/recover/token/consume` - // ), - // { - // webauthnGeneratedOptionsId, - // credential, - // token, - // }, - // userContext - // ); - // }, + consumeRecoverAccountToken: async function ({ + token, + tenantId, + userContext, + }: { + token: string; + tenantId: string; + userContext: UserContext; + }): Promise< + | { + status: "OK"; + userId: string; + email: string; + } + | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } + > { + return await querier.sendPostRequest( + new NormalisedURLPath( + `/${tenantId === undefined ? DEFAULT_TENANT_ID : tenantId}/recipe/paskey/user/recover/token/consume` + ), + { + token, + }, + userContext + ); + }, }; } diff --git a/lib/ts/recipe/webauthn/types.ts b/lib/ts/recipe/webauthn/types.ts index 3503aea4c..5ea126719 100644 --- a/lib/ts/recipe/webauthn/types.ts +++ b/lib/ts/recipe/webauthn/types.ts @@ -41,14 +41,15 @@ export type TypeNormalisedInput = { }; export type TypeNormalisedInputRelyingPartyId = (input: { + tenantId: string; request: BaseRequest | undefined; userContext: UserContext; -}) => string; // should return the domain of the origin +}) => Promise; // should return the domain of the origin export type TypeNormalisedInputRelyingPartyName = (input: { tenantId: string; userContext: UserContext; -}) => Promise; // should return the app name +}) => Promise; export type TypeNormalisedInputGetOrigin = (input: { tenantId: string; @@ -105,11 +106,9 @@ type SignUpErrorResponse = CreateNewRecipeUserErrorResponse; type SignInErrorResponse = VerifyCredentialsErrorResponse; -type GenerateRecoverAccountTokenErrorResponse = { status: "UNKNOWN_USER_ID_ERROR" } | { status: "UNKNOWN_EMAIL_ERROR" }; +type GenerateRecoverAccountTokenErrorResponse = { status: "UNKNOWN_USER_ID_ERROR" }; -type ConsumeRecoverAccountTokenErrorResponse = - | RegisterCredentialErrorResponse - | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" }; +type ConsumeRecoverAccountTokenErrorResponse = { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" }; type RemoveCredentialErrorResponse = { status: "CREDENTIAL_NOT_FOUND_ERROR" }; @@ -196,19 +195,7 @@ export type RecipeInterface = { signUp(input: { webauthnGeneratedOptionsId: string; - credential: { - id: string; - rawId: string; - response: { - clientDataJSON: string; - attestationObject: string; - transports?: ("ble" | "cable" | "hybrid" | "internal" | "nfc" | "smart-card" | "usb")[]; - userHandle: string; - }; - authenticatorAttachment: "platform" | "cross-platform"; - clientExtensionResults: Record; - type: "public-key"; - }; + credential: CredentialPayload; session: SessionContainerInterface | undefined; shouldTryLinkingWithSessionUser: boolean | undefined; tenantId: string; @@ -232,19 +219,7 @@ export type RecipeInterface = { signIn(input: { webauthnGeneratedOptionsId: string; - credential: { - id: string; - rawId: string; - response: { - clientDataJSON: string; - attestationObject: string; - transports?: ("ble" | "cable" | "hybrid" | "internal" | "nfc" | "smart-card" | "usb")[]; - userHandle: string; - }; - authenticatorAttachment: "platform" | "cross-platform"; - clientExtensionResults: Record; - type: "public-key"; - }; + credential: CredentialPayload; session: SessionContainerInterface | undefined; shouldTryLinkingWithSessionUser: boolean | undefined; tenantId: string; @@ -277,20 +252,6 @@ export type RecipeInterface = { // make sure the email maps to options email consumeRecoverAccountToken(input: { token: string; - webauthnGeneratedOptionsId: string; - credential: { - id: string; - rawId: string; - response: { - clientDataJSON: string; - attestationObject: string; - transports?: ("ble" | "cable" | "hybrid" | "internal" | "nfc" | "smart-card" | "usb")[]; - userHandle: string; - }; - authenticatorAttachment: "platform" | "cross-platform"; - clientExtensionResults: Record; - type: "public-key"; - }; tenantId: string; userContext: UserContext; }): Promise< @@ -303,19 +264,7 @@ export type RecipeInterface = { >; decodeCredential(input: { - credential: { - id: string; - rawId: string; - response: { - clientDataJSON: string; - attestationObject: string; - transports?: ("ble" | "cable" | "hybrid" | "internal" | "nfc" | "smart-card" | "usb")[]; - userHandle: string; - }; - authenticatorAttachment: "platform" | "cross-platform"; - clientExtensionResults: Record; - type: "public-key"; - }; + credential: CredentialPayload; }): Promise< | { status: "OK"; @@ -380,19 +329,7 @@ export type RecipeInterface = { // (in consumeRecoverAccountToken invalidating the token and in registerOptions for storing the email in the generated options) registerCredential(input: { webauthnGeneratedOptionsId: string; - credential: { - id: string; - rawId: string; - response: { - clientDataJSON: string; - attestationObject: string; - transports?: ("ble" | "cable" | "hybrid" | "internal" | "nfc" | "smart-card" | "usb")[]; - userHandle: string; - }; - authenticatorAttachment: "platform" | "cross-platform"; - clientExtensionResults: Record; - type: "public-key"; - }; + credential: CredentialPayload; tenantId: string; userContext: UserContext; recipeUserId: RecipeUserId; @@ -409,19 +346,7 @@ export type RecipeInterface = { // called during operations like creating a user during password reset flow. createNewRecipeUser(input: { webauthnGeneratedOptionsId: string; - credential: { - id: string; - rawId: string; - response: { - clientDataJSON: string; - attestationObject: string; - transports?: ("ble" | "cable" | "hybrid" | "internal" | "nfc" | "smart-card" | "usb")[]; - userHandle: string; - }; - authenticatorAttachment: "platform" | "cross-platform"; - clientExtensionResults: Record; - type: "public-key"; - }; + credential: CredentialPayload; tenantId: string; userContext: UserContext; }): Promise< @@ -435,19 +360,7 @@ export type RecipeInterface = { verifyCredentials(input: { webauthnGeneratedOptionsId: string; - credential: { - id: string; - rawId: string; - response: { - clientDataJSON: string; - attestationObject: string; - transports?: ("ble" | "cable" | "hybrid" | "internal" | "nfc" | "smart-card" | "usb")[]; - userHandle: string; - }; - authenticatorAttachment: "platform" | "cross-platform"; - clientExtensionResults: Record; - type: "public-key"; - }; + credential: CredentialPayload; tenantId: string; userContext: UserContext; }): Promise<{ status: "OK"; user: User; recipeUserId: RecipeUserId } | VerifyCredentialsErrorResponse>; @@ -568,6 +481,13 @@ type GetCredentialGETErrorResponse = { reason: string; }; +type RecoverAccountTokenPOSTErrorResponse = + | { + status: "CONSUME_RECOVER_ACCOUNT_TOKEN_NOT_ALLOWED"; + reason: string; + } + | ConsumeRecoverAccountTokenErrorResponse; + export type APIInterface = { registerOptionsPOST: | undefined @@ -576,7 +496,7 @@ export type APIInterface = { tenantId: string; options: APIOptions; userContext: UserContext; - } & ({ email: string } | { recoverAccountToken: string } | { session: SessionContainerInterface }) + } & ({ email: string } | { recoverAccountToken: string }) ) => Promise< | { status: "OK"; @@ -633,25 +553,15 @@ export type APIInterface = { signUpPOST: | undefined | ((input: { + email: string; webauthnGeneratedOptionsId: string; - credential: { - id: string; - rawId: string; - response: { - clientDataJSON: string; - attestationObject: string; - transports?: ("ble" | "cable" | "hybrid" | "internal" | "nfc" | "smart-card" | "usb")[]; - userHandle: string; - }; - authenticatorAttachment: "platform" | "cross-platform"; - clientExtensionResults: Record; - type: "public-key"; - }; + credential: CredentialPayload; tenantId: string; session: SessionContainerInterface | undefined; shouldTryLinkingWithSessionUser: boolean | undefined; options: APIOptions; userContext: UserContext; + // should also have the email or recoverAccountToken in order to do the preauth checks }) => Promise< | { status: "OK"; @@ -666,19 +576,7 @@ export type APIInterface = { | undefined | ((input: { webauthnGeneratedOptionsId: string; - credential: { - id: string; - rawId: string; - response: { - clientDataJSON: string; - attestationObject: string; - transports?: ("ble" | "cable" | "hybrid" | "internal" | "nfc" | "smart-card" | "usb")[]; - userHandle: string; - }; - authenticatorAttachment: "platform" | "cross-platform"; - clientExtensionResults: Record; - type: "public-key"; - }; + credential: CredentialPayload; tenantId: string; session: SessionContainerInterface | undefined; shouldTryLinkingWithSessionUser: boolean | undefined; @@ -713,19 +611,7 @@ export type APIInterface = { | undefined | ((input: { webauthnGeneratedOptionsId: string; - credential: { - id: string; - rawId: string; - response: { - clientDataJSON: string; - attestationObject: string; - transports?: ("ble" | "cable" | "hybrid" | "internal" | "nfc" | "smart-card" | "usb")[]; - userHandle: string; - }; - authenticatorAttachment: "platform" | "cross-platform"; - clientExtensionResults: Record; - type: "public-key"; - }; + credential: CredentialPayload; token: string; tenantId: string; options: APIOptions; @@ -740,6 +626,25 @@ export type APIInterface = { | GeneralErrorResponse >); + recoverAccountTokenPOST: + | undefined + | ((input: { + token: string; + webauthnGeneratedOptionsId: string; + credential: CredentialPayload; + tenantId: string; + options: APIOptions; + userContext: UserContext; + }) => Promise< + | { + status: "OK"; + user: User; + email: string; + } + | GeneralErrorResponse + | RecoverAccountTokenPOSTErrorResponse + >); + // used for checking if the email already exists before generating the credential emailExistsGET: | undefined @@ -761,19 +666,7 @@ export type APIInterface = { | undefined | ((input: { webauthnGeneratedOptionsId: string; - credential: { - id: string; - rawId: string; - response: { - clientDataJSON: string; - attestationObject: string; - transports?: ("ble" | "cable" | "hybrid" | "internal" | "nfc" | "smart-card" | "usb")[]; - userHandle: string; - }; - authenticatorAttachment: "platform" | "cross-platform"; - clientExtensionResults: Record; - type: "public-key"; - }; + credential: CredentialPayload; tenantId: string; session: SessionContainerInterface; options: APIOptions; @@ -856,3 +749,17 @@ export type TypeWebauthnRecoverAccountEmailDeliveryInput = { }; export type TypeWebauthnEmailDeliveryInput = TypeWebauthnRecoverAccountEmailDeliveryInput; + +export type CredentialPayload = { + id: string; + rawId: string; + response: { + clientDataJSON: string; + attestationObject: string; + transports?: ("ble" | "cable" | "hybrid" | "internal" | "nfc" | "smart-card" | "usb")[]; + userHandle: string; + }; + authenticatorAttachment: "platform" | "cross-platform"; + clientExtensionResults: Record; + type: "public-key"; +}; diff --git a/lib/ts/recipe/webauthn/utils.ts b/lib/ts/recipe/webauthn/utils.ts index 278da3a9b..f58fc2e4d 100644 --- a/lib/ts/recipe/webauthn/utils.ts +++ b/lib/ts/recipe/webauthn/utils.ts @@ -89,11 +89,13 @@ function validateAndNormaliseRelyingPartyIdConfig( ): TypeNormalisedInputRelyingPartyId { return (props) => { if (typeof relyingPartyIdConfig === "string") { - return relyingPartyIdConfig; + return Promise.resolve(relyingPartyIdConfig); } else if (typeof relyingPartyIdConfig === "function") { return relyingPartyIdConfig(props); } else { - return __.getOrigin({ request: props.request, userContext: props.userContext }).getAsStringDangerous(); + return Promise.resolve( + __.getOrigin({ request: props.request, userContext: props.userContext }).getAsStringDangerous() + ); } }; } @@ -105,11 +107,11 @@ function validateAndNormaliseRelyingPartyNameConfig( ): TypeNormalisedInputRelyingPartyName { return (props) => { if (typeof relyingPartyNameConfig === "string") { - return relyingPartyNameConfig; + return Promise.resolve(relyingPartyNameConfig); } else if (typeof relyingPartyNameConfig === "function") { return relyingPartyNameConfig(props); } else { - return __.appName; + return Promise.resolve(__.appName); } }; } @@ -123,7 +125,9 @@ function validateAndNormaliseGetOriginConfig( if (typeof getOriginConfig === "function") { return getOriginConfig(props); } else { - return __.getOrigin({ request: props.request, userContext: props.userContext }).getAsStringDangerous(); + return Promise.resolve( + __.getOrigin({ request: props.request, userContext: props.userContext }).getAsStringDangerous() + ); } }; } @@ -148,7 +152,7 @@ export async function defaultEmailValidator(value: any) { return undefined; } -export function getPasswordResetLink(input: { +export function getRecoverAccountLink(input: { appInfo: NormalisedAppinfo; token: string; tenantId: string; @@ -163,7 +167,7 @@ export function getPasswordResetLink(input: { }) .getAsStringDangerous() + input.appInfo.websiteBasePath.getAsStringDangerous() + - "/reset-password?token=" + + "/recover-account?token=" + input.token + "&tenantId=" + input.tenantId