From c57b645e8bc0abef1bd61ae71a5726fc4ddad0ea Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Thu, 10 Oct 2024 15:47:16 +0300 Subject: [PATCH 01/25] add initial passkey types --- lib/ts/recipe/passkey/types.ts | 473 +++++++++++++++++++++++++++++++++ 1 file changed, 473 insertions(+) create mode 100644 lib/ts/recipe/passkey/types.ts diff --git a/lib/ts/recipe/passkey/types.ts b/lib/ts/recipe/passkey/types.ts new file mode 100644 index 000000000..c04f27aae --- /dev/null +++ b/lib/ts/recipe/passkey/types.ts @@ -0,0 +1,473 @@ +/* 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 type { BaseRequest, BaseResponse } from "../../framework"; +import OverrideableBuilder from "supertokens-js-override"; +import { SessionContainerInterface } from "../session/types"; +import { + TypeInput as EmailDeliveryTypeInput, + TypeInputWithService as EmailDeliveryTypeInputWithService, +} from "../../ingredients/emaildelivery/types"; +import EmailDeliveryIngredient from "../../ingredients/emaildelivery"; +import { GeneralErrorResponse, NormalisedAppinfo, User, UserContext } from "../../types"; +import RecipeUserId from "../../recipeUserId"; + +// default implementation for the TypeInput +// todo update this ??? +export type TypeNormalisedInput = { + validateEmail: (value: any, tenantId: string, userContext: UserContext) => Promise; + relyingPartyId: (input: { request: BaseRequest | undefined; userContext: UserContext }) => string; // should return the domain of the origin + relyingPartyName: (input: { request: BaseRequest | undefined; userContext: UserContext }) => string; // should return the app name + getEmailDeliveryConfig: ( + isInServerlessEnv: boolean + ) => EmailDeliveryTypeInputWithService; + override: { + functions: ( + originalImplementation: RecipeInterface, + builder?: OverrideableBuilder + ) => RecipeInterface; + apis: (originalImplementation: APIInterface, builder?: OverrideableBuilder) => APIInterface; + }; +}; + +export type TypeInput = { + emailDelivery?: EmailDeliveryTypeInput; + validateEmail?: (value: any, tenantId: string, userContext: UserContext) => Promise; + relyingPartyId?: string | ((input: { request: BaseRequest | undefined; userContext: UserContext }) => string); + relyingPartyName?: string | ((input: { request: BaseRequest | undefined; userContext: UserContext }) => string); + override?: { + functions?: ( + originalImplementation: RecipeInterface, + builder?: OverrideableBuilder + ) => RecipeInterface; + apis?: (originalImplementation: APIInterface, builder?: OverrideableBuilder) => APIInterface; + }; +}; + +export type RecipeInterface = { + registerPasskeyOptions(input: { + email: string; + password: string; + session: SessionContainerInterface | undefined; + tenantId: string; + userContext: UserContext; + }): Promise<{ + status: "OK"; + passkeyGeneratedOptionsId: 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"; + }; + }>; + + signInPasskeyOptions(input: { + session: SessionContainerInterface | undefined; + tenantId: string; + userContext: UserContext; + }): Promise<{ + status: "OK"; + passkeyGeneratedOptionsId: string; + challenge: string; + timeout: number; + userVerification: "required" | "preferred" | "discouraged"; + }>; + + signUp(input: { + email: string | undefined; + passkeyGeneratedOptionsId: string; + passkey: { + 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"; + }; + session: SessionContainerInterface | undefined; + shouldTryLinkingWithSessionUser: boolean | undefined; + tenantId: string; + userContext: UserContext; + }): Promise< + | { + status: "OK"; + user: User; + recipeUserId: RecipeUserId; + } + | { status: "EMAIL_ALREADY_EXISTS_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"; + } + >; + + signIn(input: { + passkeyGeneratedOptionsId: string; + passkey: { + 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"; + }; + session: SessionContainerInterface | undefined; + shouldTryLinkingWithSessionUser: boolean | undefined; + tenantId: string; + userContext: UserContext; + }): 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"; + } + >; + + /** + * We pass in the email as well to this function cause the input userId + * may not be associated with an passkey account. In this case, we + * need to know which email to use to create an passkey account later on. + */ + generateRecoverAccountToken(input: { + userId: string; // the id can be either recipeUserId or primaryUserId + email: string; + tenantId: string; + userContext: UserContext; + }): Promise<{ status: "OK"; token: string } | { status: "UNKNOWN_USER_ID_ERROR" }>; + + consumeRecoverAccountToken(input: { + token: string; + passkey: { + 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"; + email: string; + userId: string; + } + | { status: "RECOVER_ACCOUNT_INVALID_TOKEN_ERROR" } + >; + + // this function is meant only for creating the recipe in the core and nothing else. + // we added this even though signUp exists cause devs may override signup expecting it + // to be called just during sign up. But we also need a version of signing up which can be + // called during operations like creating a user during password reset flow. + createNewRecipeUser(input: { + email: string; + passkeyGeneratedOptionsId: string; + passkey: { + 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"; + user: User; + recipeUserId: RecipeUserId; + } + | { status: "EMAIL_ALREADY_EXISTS_ERROR" } + >; +}; + +export type APIOptions = { + recipeImplementation: RecipeInterface; + appInfo: NormalisedAppinfo; + config: TypeNormalisedInput; + recipeId: string; + isInServerlessEnv: boolean; + req: BaseRequest; + res: BaseResponse; + emailDelivery: EmailDeliveryIngredient; +}; + +export type APIInterface = { + registerPasskeyOptionsPOST: + | undefined + | ((input: { + email: string | undefined; + tenantId: string; + options: APIOptions; + userContext: UserContext; + }) => Promise< + | { + status: "OK"; + passkeyGeneratedOptionsId: 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"; + }; + } + | GeneralErrorResponse + >); + + signInPasskeyOptionsPOST: + | undefined + | ((input: { + tenantId: string; + options: APIOptions; + userContext: UserContext; + }) => Promise< + | { + status: "OK"; + passkeyGeneratedOptionsId: string; + challenge: string; + timeout: number; + userVerification: "required" | "preferred" | "discouraged"; + } + | GeneralErrorResponse + >); + + signUpPOST: + | undefined + | ((input: { + email: string; + passkeyGeneratedOptionsId: string; + passkey: { + 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; + session: SessionContainerInterface | undefined; + shouldTryLinkingWithSessionUser: boolean | undefined; + options: APIOptions; + userContext: UserContext; + }) => Promise< + | { + status: "OK"; + user: User; + session: SessionContainerInterface; + } + | { + status: "SIGN_UP_NOT_ALLOWED"; + reason: string; + } + | { + status: "EMAIL_ALREADY_EXISTS_ERROR"; + } + | GeneralErrorResponse + >); + + signInPOST: + | undefined + | ((input: { + passkeyGeneratedOptionsId: string; + passkey: { + 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; + session: SessionContainerInterface | undefined; + shouldTryLinkingWithSessionUser: boolean | undefined; + options: APIOptions; + userContext: UserContext; + }) => Promise< + | { + status: "OK"; + user: User; + session: SessionContainerInterface; + } + | { + status: "SIGN_IN_NOT_ALLOWED"; + reason: string; + } + | { + status: "WRONG_CREDENTIALS_ERROR"; + } + | GeneralErrorResponse + >); + + generateRecoverAccountTokenPOST: + | undefined + | ((input: { + email: string; + tenantId: string; + options: APIOptions; + userContext: UserContext; + }) => Promise< + | { + status: "OK"; + } + | { + status: "ACCOUNT_RECOVERY_NOT_ALLOWED"; + reason: string; + } + | GeneralErrorResponse + >); + + recoverAccountPOST: + | undefined + | ((input: { + passkey: { + 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"; + }; + token: string; + tenantId: string; + options: APIOptions; + userContext: UserContext; + }) => Promise< + | { + status: "OK"; + email: string; + user: User; + } + | { + status: "RECOVER_ACCOUNT_TOKEN_INVALID_TOKEN_ERROR"; + } + | GeneralErrorResponse + >); + + // used for checking if the email already exists before generating the passkey + emailExistsGET: + | undefined + | ((input: { + email: string; + tenantId: string; + options: APIOptions; + userContext: UserContext; + }) => Promise< + | { + status: "OK"; + exists: boolean; + } + | GeneralErrorResponse + >); +}; +// todo update this ??? +export type TypeEmailPasswordPasswordResetEmailDeliveryInput = { + type: "PASSWORD_RESET"; + user: { + id: string; + recipeUserId: RecipeUserId | undefined; + email: string; + }; + passwordResetLink: string; + tenantId: string; +}; + +export type TypeEmailPasswordEmailDeliveryInput = TypeEmailPasswordPasswordResetEmailDeliveryInput; From 56422f35d7907d3ab907268440a36970d79ed58b Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Fri, 11 Oct 2024 09:46:18 +0300 Subject: [PATCH 02/25] passkey types cleanup --- lib/ts/recipe/passkey/types.ts | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/lib/ts/recipe/passkey/types.ts b/lib/ts/recipe/passkey/types.ts index c04f27aae..cae369644 100644 --- a/lib/ts/recipe/passkey/types.ts +++ b/lib/ts/recipe/passkey/types.ts @@ -24,15 +24,13 @@ import EmailDeliveryIngredient from "../../ingredients/emaildelivery"; import { GeneralErrorResponse, NormalisedAppinfo, User, UserContext } from "../../types"; import RecipeUserId from "../../recipeUserId"; -// default implementation for the TypeInput -// todo update this ??? export type TypeNormalisedInput = { validateEmail: (value: any, tenantId: string, userContext: UserContext) => Promise; relyingPartyId: (input: { request: BaseRequest | undefined; userContext: UserContext }) => string; // should return the domain of the origin relyingPartyName: (input: { request: BaseRequest | undefined; userContext: UserContext }) => string; // should return the app name getEmailDeliveryConfig: ( isInServerlessEnv: boolean - ) => EmailDeliveryTypeInputWithService; + ) => EmailDeliveryTypeInputWithService; override: { functions: ( originalImplementation: RecipeInterface, @@ -43,7 +41,7 @@ export type TypeNormalisedInput = { }; export type TypeInput = { - emailDelivery?: EmailDeliveryTypeInput; + emailDelivery?: EmailDeliveryTypeInput; validateEmail?: (value: any, tenantId: string, userContext: UserContext) => Promise; relyingPartyId?: string | ((input: { request: BaseRequest | undefined; userContext: UserContext }) => string); relyingPartyName?: string | ((input: { request: BaseRequest | undefined; userContext: UserContext }) => string); @@ -59,8 +57,6 @@ export type TypeInput = { export type RecipeInterface = { registerPasskeyOptions(input: { email: string; - password: string; - session: SessionContainerInterface | undefined; tenantId: string; userContext: UserContext; }): Promise<{ @@ -253,7 +249,7 @@ export type APIOptions = { isInServerlessEnv: boolean; req: BaseRequest; res: BaseResponse; - emailDelivery: EmailDeliveryIngredient; + emailDelivery: EmailDeliveryIngredient; }; export type APIInterface = { @@ -458,16 +454,16 @@ export type APIInterface = { | GeneralErrorResponse >); }; -// todo update this ??? -export type TypeEmailPasswordPasswordResetEmailDeliveryInput = { - type: "PASSWORD_RESET"; + +export type TypePasskeyRecoverAccountEmailDeliveryInput = { + type: "RECOVER_ACCOUNT"; user: { id: string; recipeUserId: RecipeUserId | undefined; email: string; }; - passwordResetLink: string; + recoverAccountLink: string; tenantId: string; }; -export type TypeEmailPasswordEmailDeliveryInput = TypeEmailPasswordPasswordResetEmailDeliveryInput; +export type TypePasskeyEmailDeliveryInput = TypePasskeyRecoverAccountEmailDeliveryInput; From f102128d1de238d47131b75eb6672f951aa28664 Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Wed, 16 Oct 2024 18:07:39 +0300 Subject: [PATCH 03/25] added untested support for options, sign in and sign up methods --- lib/ts/recipe/accountlinking/types.ts | 2 +- lib/ts/recipe/multifactorauth/types.ts | 1 + lib/ts/recipe/webauthn/api/implementation.ts | 1059 +++++++++++++++++ lib/ts/recipe/webauthn/api/registerOptions.ts | 50 + lib/ts/recipe/webauthn/api/signInOptions.ts | 38 + lib/ts/recipe/webauthn/api/signin.ts | 75 ++ lib/ts/recipe/webauthn/api/signup.ts | 92 ++ lib/ts/recipe/webauthn/api/utils.ts | 50 + lib/ts/recipe/webauthn/constants.ts | 34 + lib/ts/recipe/webauthn/core-mock.ts | 84 ++ lib/ts/recipe/webauthn/error.ts | 41 + lib/ts/recipe/webauthn/index.ts | 386 ++++++ lib/ts/recipe/webauthn/recipe.ts | 357 ++++++ .../recipe/webauthn/recipeImplementation.ts | 376 ++++++ lib/ts/recipe/{passkey => webauthn}/types.ts | 329 ++--- lib/ts/recipe/webauthn/utils.ts | 171 +++ 16 files changed, 3005 insertions(+), 140 deletions(-) create mode 100644 lib/ts/recipe/webauthn/api/implementation.ts create mode 100644 lib/ts/recipe/webauthn/api/registerOptions.ts create mode 100644 lib/ts/recipe/webauthn/api/signInOptions.ts create mode 100644 lib/ts/recipe/webauthn/api/signin.ts create mode 100644 lib/ts/recipe/webauthn/api/signup.ts create mode 100644 lib/ts/recipe/webauthn/api/utils.ts create mode 100644 lib/ts/recipe/webauthn/constants.ts create mode 100644 lib/ts/recipe/webauthn/core-mock.ts create mode 100644 lib/ts/recipe/webauthn/error.ts create mode 100644 lib/ts/recipe/webauthn/index.ts create mode 100644 lib/ts/recipe/webauthn/recipe.ts create mode 100644 lib/ts/recipe/webauthn/recipeImplementation.ts rename lib/ts/recipe/{passkey => webauthn}/types.ts (66%) create mode 100644 lib/ts/recipe/webauthn/utils.ts diff --git a/lib/ts/recipe/accountlinking/types.ts b/lib/ts/recipe/accountlinking/types.ts index 83aec5230..9426edbc9 100644 --- a/lib/ts/recipe/accountlinking/types.ts +++ b/lib/ts/recipe/accountlinking/types.ts @@ -195,7 +195,7 @@ export type AccountInfo = { }; export type AccountInfoWithRecipeId = { - recipeId: "emailpassword" | "thirdparty" | "passwordless"; + recipeId: "emailpassword" | "thirdparty" | "passwordless" | "webauthn"; } & AccountInfo; export type RecipeLevelUser = { diff --git a/lib/ts/recipe/multifactorauth/types.ts b/lib/ts/recipe/multifactorauth/types.ts index a7e340662..693e8e646 100644 --- a/lib/ts/recipe/multifactorauth/types.ts +++ b/lib/ts/recipe/multifactorauth/types.ts @@ -154,6 +154,7 @@ export type GetPhoneNumbersForFactorsFromOtherRecipesFunc = ( export const FactorIds = { EMAILPASSWORD: "emailpassword", + WEBAUTHN: "webauthn", OTP_EMAIL: "otp-email", OTP_PHONE: "otp-phone", LINK_EMAIL: "link-email", diff --git a/lib/ts/recipe/webauthn/api/implementation.ts b/lib/ts/recipe/webauthn/api/implementation.ts new file mode 100644 index 000000000..2408114ff --- /dev/null +++ b/lib/ts/recipe/webauthn/api/implementation.ts @@ -0,0 +1,1059 @@ +import { APIInterface, APIOptions } from ".."; +import { GeneralErrorResponse, User, UserContext } from "../../../types"; +import AccountLinking from "../../accountlinking/recipe"; +import { AuthUtils } from "../../../authUtils"; +import { isFakeEmail } from "../../thirdparty/utils"; +import { SessionContainerInterface } from "../../session/types"; +import { + DEFAULT_REGISTER_ATTESTATION, + DEFAULT_REGISTER_OPTIONS_TIMEOUT, + DEFAULT_SIGNIN_OPTIONS_TIMEOUT, +} from "../constants"; + +export default function getAPIImplementation(): APIInterface { + return { + signInOptionsPOST: async function ({ + tenantId, + options, + userContext, + }: { + tenantId: string; + options: APIOptions; + userContext: UserContext; + }): Promise< + | { + status: "OK"; + webauthnGeneratedOptionsId: string; + challenge: string; + timeout: number; + userVerification: "required" | "preferred" | "discouraged"; + } + | GeneralErrorResponse + > { + // todo move to recipe implementation + const timeout = DEFAULT_SIGNIN_OPTIONS_TIMEOUT; + + const relyingPartyId = options.config.relyingPartyId({ request: options.req, userContext: 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(); + + let response = await options.recipeImplementation.signInOptions({ + origin, + relyingPartyId, + timeout, + tenantId, + userContext, + }); + + return { + status: "OK", + webauthnGeneratedOptionsId: response.webauthnGeneratedOptionsId, + challenge: response.challenge, + timeout: response.timeout, + userVerification: response.userVerification, + }; + }, + registerOptionsPOST: async function ({ + email, + tenantId, + options, + userContext, + }: { + email: string; + tenantId: string; + options: APIOptions; + 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"; + }; + } + | GeneralErrorResponse + > { + // todo move to recipe implementation + const timeout = DEFAULT_REGISTER_OPTIONS_TIMEOUT; + // todo move to recipe implementation + const attestation = DEFAULT_REGISTER_ATTESTATION; + + const relyingPartyId = options.config.relyingPartyId({ request: options.req, userContext: userContext }); + const relyingPartyName = options.config.relyingPartyName({ + request: options.req, + userContext: userContext, + }); + + const origin = options.appInfo + .getOrigin({ request: options.req, userContext: userContext }) + .getAsStringDangerous(); + + let response = await options.recipeImplementation.registerOptions({ + email, + attestation, + origin, + relyingPartyId, + relyingPartyName, + timeout, + tenantId, + userContext, + }); + + 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, + }; + }, + signUpPOST: async function ({ + email, + webauthnGeneratedOptionsId, + credential, + tenantId, + session, + shouldTryLinkingWithSessionUser, + options, + userContext, + }: { + 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"; + }; + tenantId: string; + session?: SessionContainerInterface; + shouldTryLinkingWithSessionUser: boolean | undefined; + options: APIOptions; + userContext: UserContext; + }): Promise< + | { + status: "OK"; + session: SessionContainerInterface; + user: User; + } + | { + status: "SIGN_UP_NOT_ALLOWED"; + 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)", + LINKING_TO_SESSION_USER_FAILED: { + EMAIL_VERIFICATION_REQUIRED: + "Cannot sign in / up due to security reasons. Please contact support. (ERR_CODE_013)", + RECIPE_USER_ID_ALREADY_LINKED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR: + "Cannot sign in / up due to security reasons. Please contact support. (ERR_CODE_014)", + ACCOUNT_INFO_ALREADY_ASSOCIATED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR: + "Cannot sign in / up due to security reasons. Please contact support. (ERR_CODE_015)", + SESSION_USER_ACCOUNT_INFO_ALREADY_ASSOCIATED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR: + "Cannot sign in / up due to security reasons. Please contact support. (ERR_CODE_016)", + }, + }; + + // NOTE: Following checks will likely never throw an error as the + // check for type is done in a parent function but they are kept + // here to be on the safe side. + if (typeof email !== "string") + throw new Error( + "Should never come here since we already check that the email value is a string in validateFormFieldsOrThrowError" + ); + + const preAuthCheckRes = await AuthUtils.preAuthChecks({ + authenticatingAccountInfo: { + recipeId: "webauthn", + email, + }, + factorIds: ["webauthn"], + isSignUp: true, + isVerified: isFakeEmail(email), + signInVerifiesLoginMethod: false, + skipSessionUserUpdateInCore: false, + authenticatingUser: undefined, // since this a sign up, this is undefined + tenantId, + userContext, + session, + shouldTryLinkingWithSessionUser, + }); + + if (preAuthCheckRes.status === "SIGN_UP_NOT_ALLOWED") { + const conflictingUsers = await AccountLinking.getInstance().recipeInterfaceImpl.listUsersByAccountInfo({ + tenantId, + accountInfo: { + email, + }, + doUnionOfAccountInfo: false, + userContext, + }); + if ( + conflictingUsers.some((u) => + u.loginMethods.some((lm) => lm.recipeId === "webauthn" && lm.hasSameEmailAs(email)) + ) + ) { + return { + status: "EMAIL_ALREADY_EXISTS_ERROR", + }; + } + } + if (preAuthCheckRes.status !== "OK") { + return AuthUtils.getErrorStatusResponseWithReason(preAuthCheckRes, errorCodeMap, "SIGN_UP_NOT_ALLOWED"); + } + + if (isFakeEmail(email) && preAuthCheckRes.isFirstFactor) { + // Fake emails cannot be used as a first factor + return { + status: "EMAIL_ALREADY_EXISTS_ERROR", + }; + } + + // we are using the email from the register options + const signUpResponse = await options.recipeImplementation.signUp({ + webauthnGeneratedOptionsId, + credential, + tenantId, + session, + shouldTryLinkingWithSessionUser, + userContext, + }); + + if (signUpResponse.status === "EMAIL_ALREADY_EXISTS_ERROR") { + return signUpResponse; + } + if (signUpResponse.status !== "OK") { + return AuthUtils.getErrorStatusResponseWithReason(signUpResponse, errorCodeMap, "SIGN_UP_NOT_ALLOWED"); + } + + const postAuthChecks = await AuthUtils.postAuthChecks({ + authenticatedUser: signUpResponse.user, + recipeUserId: signUpResponse.recipeUserId, + isSignUp: true, + factorId: "emailpassword", + session, + req: options.req, + res: options.res, + tenantId, + userContext, + }); + + if (postAuthChecks.status !== "OK") { + // It should never actually come here, but we do it cause of consistency. + // If it does come here (in case there is a bug), it would make this func throw + // anyway, cause there is no SIGN_IN_NOT_ALLOWED in the errorCodeMap. + AuthUtils.getErrorStatusResponseWithReason(postAuthChecks, errorCodeMap, "SIGN_UP_NOT_ALLOWED"); + throw new Error("This should never happen"); + } + + return { + status: "OK", + session: postAuthChecks.session, + user: postAuthChecks.user, + }; + }, + + signInPOST: async function ({ + webauthnGeneratedOptionsId, + credential, + tenantId, + session, + shouldTryLinkingWithSessionUser, + options, + 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"; + }; + tenantId: string; + session?: SessionContainerInterface; + shouldTryLinkingWithSessionUser: boolean | undefined; + options: APIOptions; + userContext: UserContext; + }): Promise< + | { + status: "OK"; + session: SessionContainerInterface; + user: User; + } + | { + status: "WRONG_CREDENTIALS_ERROR"; + } + | { + status: "SIGN_IN_NOT_ALLOWED"; + reason: string; + } + | GeneralErrorResponse + > { + 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)", + LINKING_TO_SESSION_USER_FAILED: { + EMAIL_VERIFICATION_REQUIRED: + "Cannot sign in / up due to security reasons. Please contact support. (ERR_CODE_009)", + RECIPE_USER_ID_ALREADY_LINKED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR: + "Cannot sign in / up due to security reasons. Please contact support. (ERR_CODE_010)", + ACCOUNT_INFO_ALREADY_ASSOCIATED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR: + "Cannot sign in / up due to security reasons. Please contact support. (ERR_CODE_011)", + SESSION_USER_ACCOUNT_INFO_ALREADY_ASSOCIATED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR: + "Cannot sign in / up due to security reasons. Please contact support. (ERR_CODE_012)", + }, + }; + + const recipeId = "webauthn"; + + // do the verification before in order to retrieve the user email + const verifyCredentialsResponse = await options.recipeImplementation.verifyCredentials({ + credential, + webauthnGeneratedOptionsId, + tenantId, + userContext, + }); + const checkCredentialsOnTenant = async () => { + return verifyCredentialsResponse.status === "OK"; + }; + + // todo check if this is the correct way to retrieve the email + let email: string; + if (verifyCredentialsResponse.status == "OK") { + email = verifyCredentialsResponse.user.emails[0]; + } else { + return { + status: "WRONG_CREDENTIALS_ERROR", + }; + } + + const authenticatingUser = await AuthUtils.getAuthenticatingUserAndAddToCurrentTenantIfRequired({ + accountInfo: { email }, + userContext, + recipeId, + session, + tenantId, + checkCredentialsOnTenant, + }); + + const isVerified = authenticatingUser !== undefined && authenticatingUser.loginMethod!.verified; + // We check this before preAuthChecks, because that function assumes that if isSignUp is false, + // then authenticatingUser is defined. While it wouldn't technically cause any problems with + // the implementation of that function, this way we can guarantee that either isSignInAllowed or + // isSignUpAllowed will be called as expected. + if (authenticatingUser === undefined) { + return { + status: "WRONG_CREDENTIALS_ERROR", + }; + } + const preAuthChecks = await AuthUtils.preAuthChecks({ + authenticatingAccountInfo: { + recipeId, + email, + }, + factorIds: ["webauthn"], + isSignUp: false, + authenticatingUser: authenticatingUser?.user, + isVerified, + signInVerifiesLoginMethod: false, + skipSessionUserUpdateInCore: false, + tenantId, + userContext, + session, + shouldTryLinkingWithSessionUser, + }); + if (preAuthChecks.status === "SIGN_UP_NOT_ALLOWED") { + throw new Error("This should never happen: pre-auth checks should not fail for sign in"); + } + if (preAuthChecks.status !== "OK") { + return AuthUtils.getErrorStatusResponseWithReason(preAuthChecks, errorCodeMap, "SIGN_IN_NOT_ALLOWED"); + } + + if (isFakeEmail(email) && preAuthChecks.isFirstFactor) { + // Fake emails cannot be used as a first factor + return { + status: "WRONG_CREDENTIALS_ERROR", + }; + } + + const signInResponse = await options.recipeImplementation.signIn({ + webauthnGeneratedOptionsId, + credential, + session, + shouldTryLinkingWithSessionUser, + tenantId, + userContext, + }); + + if (signInResponse.status === "WRONG_CREDENTIALS_ERROR") { + return signInResponse; + } + if (signInResponse.status !== "OK") { + return AuthUtils.getErrorStatusResponseWithReason(signInResponse, errorCodeMap, "SIGN_IN_NOT_ALLOWED"); + } + + const postAuthChecks = await AuthUtils.postAuthChecks({ + authenticatedUser: signInResponse.user, + recipeUserId: signInResponse.recipeUserId, + isSignUp: false, + factorId: "webauthn", + session, + req: options.req, + res: options.res, + tenantId, + userContext, + }); + + if (postAuthChecks.status !== "OK") { + return AuthUtils.getErrorStatusResponseWithReason(postAuthChecks, errorCodeMap, "SIGN_IN_NOT_ALLOWED"); + } + + return { + status: "OK", + session: postAuthChecks.session, + user: postAuthChecks.user, + }; + }, + + // 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) + // ); + // } + // }, + }; +} diff --git a/lib/ts/recipe/webauthn/api/registerOptions.ts b/lib/ts/recipe/webauthn/api/registerOptions.ts new file mode 100644 index 000000000..74d309a48 --- /dev/null +++ b/lib/ts/recipe/webauthn/api/registerOptions.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 STError from "../error"; +import { APIInterface, APIOptions } from ".."; +import { UserContext } from "../../../types"; + +export default async function registerOptions( + apiImplementation: APIInterface, + tenantId: string, + options: APIOptions, + userContext: UserContext +): Promise { + if (apiImplementation.registerOptionsPOST === undefined) { + return false; + } + + const requestBody = await options.req.getJSONBody(); + + let 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.registerOptionsPOST({ + email, + tenantId, + options, + userContext, + }); + + send200Response(options.res, result); + return true; +} diff --git a/lib/ts/recipe/webauthn/api/signInOptions.ts b/lib/ts/recipe/webauthn/api/signInOptions.ts new file mode 100644 index 000000000..2e2736aa5 --- /dev/null +++ b/lib/ts/recipe/webauthn/api/signInOptions.ts @@ -0,0 +1,38 @@ +/* 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"; + +export default async function signInOptions( + apiImplementation: APIInterface, + tenantId: string, + options: APIOptions, + userContext: UserContext +): Promise { + if (apiImplementation.signInOptionsPOST === undefined) { + return false; + } + + let result = await apiImplementation.signInOptionsPOST({ + tenantId, + options, + userContext, + }); + + send200Response(options.res, result); + return true; +} diff --git a/lib/ts/recipe/webauthn/api/signin.ts b/lib/ts/recipe/webauthn/api/signin.ts new file mode 100644 index 000000000..b834ecb84 --- /dev/null +++ b/lib/ts/recipe/webauthn/api/signin.ts @@ -0,0 +1,75 @@ +/* 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 { + getBackwardsCompatibleUserInfo, + getNormalisedShouldTryLinkingWithSessionUserFlag, + send200Response, +} from "../../../utils"; +import { validatewebauthnGeneratedOptionsIdOrThrowError, validateCredentialOrThrowError } from "./utils"; +import { APIInterface, APIOptions } from ".."; +import { UserContext } from "../../../types"; +import { AuthUtils } from "../../../authUtils"; + +export default async function signInAPI( + apiImplementation: APIInterface, + tenantId: string, + options: APIOptions, + userContext: UserContext +): Promise { + // Logic as per https://github.com/supertokens/supertokens-node/issues/20#issuecomment-710346362 + if (apiImplementation.signInPOST === undefined) { + return false; + } + + const requestBody = await options.req.getJSONBody(); + const webauthnGeneratedOptionsId = await validatewebauthnGeneratedOptionsIdOrThrowError( + requestBody.webauthnGeneratedOptionsId + ); + const credential = await validateCredentialOrThrowError(requestBody.credential); + + const shouldTryLinkingWithSessionUser = getNormalisedShouldTryLinkingWithSessionUserFlag(options.req, requestBody); + + const session = await AuthUtils.loadSessionInAuthAPIIfNeeded( + options.req, + options.res, + shouldTryLinkingWithSessionUser, + userContext + ); + + if (session !== undefined) { + tenantId = session.getTenantId(); + } + + let result = await apiImplementation.signInPOST({ + webauthnGeneratedOptionsId, + credential, + tenantId, + session, + shouldTryLinkingWithSessionUser, + options, + userContext, + }); + + if (result.status === "OK") { + send200Response(options.res, { + status: "OK", + ...getBackwardsCompatibleUserInfo(options.req, result, userContext), + }); + } else { + send200Response(options.res, result); + } + return true; +} diff --git a/lib/ts/recipe/webauthn/api/signup.ts b/lib/ts/recipe/webauthn/api/signup.ts new file mode 100644 index 000000000..3695cbe3e --- /dev/null +++ b/lib/ts/recipe/webauthn/api/signup.ts @@ -0,0 +1,92 @@ +/* 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 { + getBackwardsCompatibleUserInfo, + getNormalisedShouldTryLinkingWithSessionUserFlag, + send200Response, +} from "../../../utils"; +import { + validateEmailOrThrowError, + validatewebauthnGeneratedOptionsIdOrThrowError, + validateCredentialOrThrowError, +} from "./utils"; +import { APIInterface, APIOptions } from ".."; +import STError from "../error"; +import { UserContext } from "../../../types"; +import { AuthUtils } from "../../../authUtils"; + +export default async function signUpAPI( + apiImplementation: APIInterface, + tenantId: string, + options: APIOptions, + userContext: UserContext +): Promise { + if (apiImplementation.signUpPOST === undefined) { + return false; + } + + const requestBody = await options.req.getJSONBody(); + const email = await validateEmailOrThrowError(requestBody.email); + const webauthnGeneratedOptionsId = await validatewebauthnGeneratedOptionsIdOrThrowError( + requestBody.webauthnGeneratedOptionsId + ); + const credential = await validateCredentialOrThrowError(requestBody.credential); + + const shouldTryLinkingWithSessionUser = getNormalisedShouldTryLinkingWithSessionUserFlag(options.req, requestBody); + + const session = await AuthUtils.loadSessionInAuthAPIIfNeeded( + options.req, + options.res, + shouldTryLinkingWithSessionUser, + userContext + ); + if (session !== undefined) { + tenantId = session.getTenantId(); + } + + let result = await apiImplementation.signUpPOST({ + email, + credential, + webauthnGeneratedOptionsId, + tenantId, + session, + shouldTryLinkingWithSessionUser, + options, + userContext: userContext, + }); + if (result.status === "OK") { + send200Response(options.res, { + status: "OK", + ...getBackwardsCompatibleUserInfo(options.req, result, userContext), + }); + } else if (result.status === "GENERAL_ERROR") { + send200Response(options.res, result); + } else if (result.status === "EMAIL_ALREADY_EXISTS_ERROR") { + throw new STError({ + type: STError.FIELD_ERROR, + payload: [ + { + id: "email", + error: "This email already exists. Please sign in instead.", + }, + ], + message: "Error in input formFields", + }); + } else { + send200Response(options.res, result); + } + return true; +} diff --git a/lib/ts/recipe/webauthn/api/utils.ts b/lib/ts/recipe/webauthn/api/utils.ts new file mode 100644 index 000000000..4b1ecaaf8 --- /dev/null +++ b/lib/ts/recipe/webauthn/api/utils.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 STError from "../error"; +import { defaultEmailValidator } from "../utils"; + +export async function validateEmailOrThrowError(email: string): Promise { + const error = await defaultEmailValidator(email); + if (error) { + throw newBadRequestError(error); + } + + return email; +} + +export async function validatewebauthnGeneratedOptionsIdOrThrowError( + webauthnGeneratedOptionsId: string +): Promise { + if (webauthnGeneratedOptionsId === undefined) { + throw newBadRequestError("webauthnGeneratedOptionsId is required"); + } + + return webauthnGeneratedOptionsId; +} + +export async function validateCredentialOrThrowError(credential: T): Promise { + if (credential === undefined) { + throw newBadRequestError("credential is required"); + } + + return credential; +} + +function newBadRequestError(message: string) { + return new STError({ + type: STError.BAD_INPUT_ERROR, + message, + }); +} diff --git a/lib/ts/recipe/webauthn/constants.ts b/lib/ts/recipe/webauthn/constants.ts new file mode 100644 index 000000000..d23bf8dd7 --- /dev/null +++ b/lib/ts/recipe/webauthn/constants.ts @@ -0,0 +1,34 @@ +/* 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. + */ + +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"; + +export const SIGN_UP_API = "/webauthn/signup"; + +export const SIGN_IN_API = "/webauthn/signin"; + +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"; diff --git a/lib/ts/recipe/webauthn/core-mock.ts b/lib/ts/recipe/webauthn/core-mock.ts new file mode 100644 index 000000000..f6e725433 --- /dev/null +++ b/lib/ts/recipe/webauthn/core-mock.ts @@ -0,0 +1,84 @@ +import NormalisedURLPath from "../../normalisedURLPath"; +import { Querier } from "../../querier"; +import { UserContext } from "../../types"; + +export const getMockQuerier = (recipeId: string) => { + const querier = Querier.getNewInstanceOrThrowError(recipeId); + + const sendPostRequest = async ( + path: NormalisedURLPath, + body: any, + userContext: UserContext + ): Promise => { + if (path.getAsStringDangerous().includes("/recipe/webauthn/options/register")) { + // @ts-ignore + return { + status: "OK", + webauthnGeneratedOptionsId: "7ab03f6a-61b8-4f65-992f-b8b8469bc18f", + rp: { id: "example.com", name: "Example App" }, + user: { id: "dummy-user-id", name: "user@example.com", displayName: "User" }, + challenge: "dummy-challenge", + timeout: 60000, + excludeCredentials: [], + attestation: "none", + pubKeyCredParams: [{ alg: -7, type: "public-key" }], + authenticatorSelection: { + requireResidentKey: false, + residentKey: "preferred", + userVerification: "preferred", + }, + }; + } else if (path.getAsStringDangerous().includes("/recipe/webauthn/options/signin")) { + // @ts-ignore + return { + status: "OK", + webauthnGeneratedOptionsId: "18302759-87c6-4d88-990d-c7cab43653cc", + challenge: "dummy-signin-challenge", + timeout: 60000, + userVerification: "preferred", + }; + // } else if (path.getAsStringDangerous().includes("/recipe/webauthn/user/recover/token")) { + // // @ts-ignore + // return { + // status: "OK", + // token: "dummy-recovery-token", + // }; + // } else if (path.getAsStringDangerous().includes("/recipe/webauthn/user/recover/token/consume")) { + // // @ts-ignore + // return { + // status: "OK", + // userId: "dummy-user-id", + // email: "user@example.com", + // }; + // } + } else if (path.getAsStringDangerous().includes("/recipe/webauthn/signup")) { + // @ts-ignore + return { + status: "OK", + user: { + id: "dummy-user-id", + email: "user@example.com", + timeJoined: Date.now(), + }, + recipeUserId: "dummy-recipe-user-id", + }; + } else if (path.getAsStringDangerous().includes("/recipe/webauthn/signin")) { + // @ts-ignore + return { + status: "OK", + user: { + id: "dummy-user-id", + email: "user@example.com", + timeJoined: Date.now(), + }, + recipeUserId: "dummy-recipe-user-id", + }; + } + + throw new Error(`Unmocked endpoint: ${path}`); + }; + + querier.sendPostRequest = sendPostRequest; + + return querier; +}; diff --git a/lib/ts/recipe/webauthn/error.ts b/lib/ts/recipe/webauthn/error.ts new file mode 100644 index 000000000..7b984cbaf --- /dev/null +++ b/lib/ts/recipe/webauthn/error.ts @@ -0,0 +1,41 @@ +/* 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 STError from "../../error"; + +export default class SessionError extends STError { + static FIELD_ERROR: "FIELD_ERROR" = "FIELD_ERROR"; + + constructor( + options: + | { + type: "FIELD_ERROR"; + payload: { + id: string; + error: string; + }[]; + message: string; + } + | { + type: "BAD_INPUT_ERROR"; + message: string; + } + ) { + super({ + ...options, + }); + this.fromRecipe = "webauthn"; + } +} diff --git a/lib/ts/recipe/webauthn/index.ts b/lib/ts/recipe/webauthn/index.ts new file mode 100644 index 000000000..78ad6385b --- /dev/null +++ b/lib/ts/recipe/webauthn/index.ts @@ -0,0 +1,386 @@ +/* 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 Recipe from "./recipe"; +import SuperTokensError from "./error"; +import { RecipeInterface, APIOptions, APIInterface, TypeWebauthnEmailDeliveryInput } from "./types"; +import RecipeUserId from "../../recipeUserId"; +import { DEFAULT_TENANT_ID } from "../multitenancy/constants"; +import { getPasswordResetLink } from "./utils"; +import { getRequestFromUserContext, getUser } from "../.."; +import { getUserContext } from "../../utils"; +import { SessionContainerInterface } from "../session/types"; +import { User, UserContext } from "../../types"; + +export default class Wrapper { + static init = Recipe.init; + + static Error = SuperTokensError; + + static registerOptions( + email: string, + relyingPartyId: string, + relyingPartyName: string, + origin: string, + timeout: number, + 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"; + }; + }> { + return Recipe.getInstanceOrThrowError().recipeInterfaceImpl.registerOptions({ + email, + relyingPartyId, + relyingPartyName, + origin, + timeout, + attestation, + tenantId: tenantId === undefined ? DEFAULT_TENANT_ID : tenantId, + userContext: getUserContext(userContext), + }); + } + + static signInOptions( + relyingPartyId: string, + origin: string, + timeout: number, + tenantId: string, + userContext: Record + ): Promise<{ + status: "OK"; + webauthnGeneratedOptionsId: string; + challenge: string; + timeout: number; + userVerification: "required" | "preferred" | "discouraged"; + }> { + return Recipe.getInstanceOrThrowError().recipeInterfaceImpl.signInOptions({ + relyingPartyId, + origin, + timeout, + tenantId: tenantId === undefined ? DEFAULT_TENANT_ID : tenantId, + userContext: getUserContext(userContext), + }); + } + + // 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), + // }); + // } +} + +export let init = Wrapper.init; + +export let Error = Wrapper.Error; + +export let registerOptions = Wrapper.registerOptions; + +export let signInOptions = Wrapper.signInOptions; + +// export let signIn = Wrapper.signIn; + +// export let verifyCredentials = Wrapper.verifyCredentials; + +// export let createResetPasswordToken = Wrapper.createResetPasswordToken; + +// export let resetPasswordUsingToken = Wrapper.resetPasswordUsingToken; + +// export let consumePasswordResetToken = Wrapper.consumePasswordResetToken; + +// export let updateEmailOrPassword = Wrapper.updateEmailOrPassword; + +export type { RecipeInterface, APIOptions, APIInterface }; + +// export let createResetPasswordLink = Wrapper.createResetPasswordLink; + +// export let sendResetPasswordEmail = Wrapper.sendResetPasswordEmail; + +// export let sendEmail = Wrapper.sendEmail; diff --git a/lib/ts/recipe/webauthn/recipe.ts b/lib/ts/recipe/webauthn/recipe.ts new file mode 100644 index 000000000..779855fc6 --- /dev/null +++ b/lib/ts/recipe/webauthn/recipe.ts @@ -0,0 +1,357 @@ +/* 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 RecipeModule from "../../recipeModule"; +import { TypeInput, TypeNormalisedInput, RecipeInterface, APIInterface } from "./types"; +import { NormalisedAppinfo, APIHandled, HTTPMethod, RecipeListFunction, UserContext } from "../../types"; +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 signUpAPI from "./api/signup"; +import signInAPI from "./api/signin"; +import registerOptionsAPI from "./api/registerOptions"; +import signInOptionsAPI from "./api/signInOptions"; +import { isTestEnv, send200Response } from "../../utils"; +import RecipeImplementation from "./recipeImplementation"; +import APIImplementation from "./api/implementation"; +import type { BaseRequest, BaseResponse } from "../../framework"; +import OverrideableBuilder from "supertokens-js-override"; +import EmailDeliveryIngredient from "../../ingredients/emaildelivery"; +import { TypeWebauthnEmailDeliveryInput } from "./types"; +import { PostSuperTokensInitCallbacks } from "../../postSuperTokensInitCallbacks"; +import MultiFactorAuthRecipe from "../multifactorauth/recipe"; +import MultitenancyRecipe from "../multitenancy/recipe"; +import { User } from "../../user"; +import { isFakeEmail } from "../thirdparty/utils"; +import { FactorIds } from "../multifactorauth"; +import { getMockQuerier } from "./core-mock"; + +export default class Recipe extends RecipeModule { + private static instance: Recipe | undefined = undefined; + static RECIPE_ID = "webauthn"; + + config: TypeNormalisedInput; + + recipeInterfaceImpl: RecipeInterface; + + apiImpl: APIInterface; + + isInServerlessEnv: boolean; + + emailDelivery: EmailDeliveryIngredient; + + constructor( + recipeId: string, + appInfo: NormalisedAppinfo, + isInServerlessEnv: boolean, + config: TypeInput | undefined, + ingredients: { + emailDelivery: EmailDeliveryIngredient | undefined; + } + ) { + super(recipeId, appInfo); + this.isInServerlessEnv = isInServerlessEnv; + this.config = validateAndNormaliseUserInput(this, appInfo, config); + { + const getWebauthnConfig = () => this.config; + // const querier = Querier.getNewInstanceOrThrowError(recipeId); + const querier = getMockQuerier(recipeId); + let builder = new OverrideableBuilder(RecipeImplementation(querier, getWebauthnConfig)); + this.recipeInterfaceImpl = builder.override(this.config.override.functions).build(); + } + { + let builder = new OverrideableBuilder(APIImplementation()); + this.apiImpl = builder.override(this.config.override.apis).build(); + } + + /** + * emailDelivery will always needs to be declared after isInServerlessEnv + * and recipeInterfaceImpl values are set + */ + this.emailDelivery = + ingredients.emailDelivery === undefined + ? new EmailDeliveryIngredient(this.config.getEmailDeliveryConfig(this.isInServerlessEnv)) + : ingredients.emailDelivery; + + PostSuperTokensInitCallbacks.addPostInitCallback(() => { + const mfaInstance = MultiFactorAuthRecipe.getInstance(); + if (mfaInstance !== undefined) { + mfaInstance.addFuncToGetAllAvailableSecondaryFactorIdsFromOtherRecipes(() => { + return ["webauthn"]; + }); + mfaInstance.addFuncToGetFactorsSetupForUserFromOtherRecipes(async (user: User) => { + for (const loginMethod of user.loginMethods) { + // We don't check for tenantId here because if we find the user + // with emailpassword loginMethod from different tenant, then + // we assume the factor is setup for this user. And as part of factor + // completion, we associate that loginMethod with the session's tenantId + if (loginMethod.recipeId === Recipe.RECIPE_ID) { + return ["webauthn"]; + } + } + return []; + }); + + mfaInstance.addFuncToGetEmailsForFactorFromOtherRecipes((user: User, sessionRecipeUserId) => { + // This function is called in the MFA info endpoint API. + // Based on https://github.com/supertokens/supertokens-node/pull/741#discussion_r1432749346 + + // preparing some reusable variables for the logic below... + let sessionLoginMethod = user.loginMethods.find((lM) => { + return lM.recipeUserId.getAsString() === sessionRecipeUserId.getAsString(); + }); + if (sessionLoginMethod === undefined) { + // this can happen maybe cause this login method + // was unlinked from the user or deleted entirely... + return { + status: "UNKNOWN_SESSION_RECIPE_USER_ID", + }; + } + + // We order the login methods based on timeJoined (oldest first) + const orderedLoginMethodsByTimeJoinedOldestFirst = user.loginMethods.sort((a, b) => { + return a.timeJoined - b.timeJoined; + }); + // Then we take the ones that belong to this recipe + const recipeLoginMethodsOrderedByTimeJoinedOldestFirst = orderedLoginMethodsByTimeJoinedOldestFirst.filter( + (lm) => lm.recipeId === Recipe.RECIPE_ID + ); + + let result: string[]; + if (recipeLoginMethodsOrderedByTimeJoinedOldestFirst.length !== 0) { + // If there are login methods belonging to this recipe, the factor is set up + // In this case we only list email addresses that have a password associated with them + result = [ + // First we take the verified real emails associated with emailpassword login methods ordered by timeJoined (oldest first) + ...recipeLoginMethodsOrderedByTimeJoinedOldestFirst + .filter((lm) => !isFakeEmail(lm.email!) && lm.verified === true) + .map((lm) => lm.email!), + // Then we take the non-verified real emails associated with emailpassword login methods ordered by timeJoined (oldest first) + ...recipeLoginMethodsOrderedByTimeJoinedOldestFirst + .filter((lm) => !isFakeEmail(lm.email!) && lm.verified === false) + .map((lm) => lm.email!), + // Lastly, fake emails associated with emailpassword login methods ordered by timeJoined (oldest first) + // We also add these into the list because they already have a password added to them so they can be a valid choice when signing in + // We do not want to remove the previously added "MFA password", because a new email password user was linked + // E.g.: + // 1. A discord user adds a password for MFA (which will use the fake email associated with the discord user) + // 2. Later they also sign up and (manually) link a full emailpassword user that they intend to use as a first factor + // 3. The next time they sign in using Discord, they could be asked for a secondary password. + // In this case, they'd be checked against the first user that they originally created for MFA, not the one later linked to the account + ...recipeLoginMethodsOrderedByTimeJoinedOldestFirst + .filter((lm) => isFakeEmail(lm.email!)) + .map((lm) => lm.email!), + ]; + // We handle moving the session email to the top of the list later + } else { + // This factor hasn't been set up, we list all emails belonging to the user + if ( + orderedLoginMethodsByTimeJoinedOldestFirst.some( + (lm) => lm.email !== undefined && !isFakeEmail(lm.email) + ) + ) { + // If there is at least one real email address linked to the user, we only suggest real addresses + result = orderedLoginMethodsByTimeJoinedOldestFirst + .filter((lm) => lm.email !== undefined && !isFakeEmail(lm.email)) + .map((lm) => lm.email!); + } else { + // Else we use the fake ones + result = orderedLoginMethodsByTimeJoinedOldestFirst + .filter((lm) => lm.email !== undefined && isFakeEmail(lm.email)) + .map((lm) => lm.email!); + } + // We handle moving the session email to the top of the list later + + // Since in this case emails are not guaranteed to be unique, we de-duplicate the results, keeping the oldest one in the list. + // The Set constructor keeps the original insertion order (OrderedByTimeJoinedOldestFirst), but de-duplicates the items, + // keeping the first one added (so keeping the older one if there are two entries with the same email) + // e.g.: [4,2,3,2,1] -> [4,2,3,1] + result = Array.from(new Set(result)); + } + + // If the loginmethod associated with the session has an email address, we move it to the top of the list (if it's already in the list) + if (sessionLoginMethod.email !== undefined && result.includes(sessionLoginMethod.email)) { + result = [ + sessionLoginMethod.email, + ...result.filter((email) => email !== sessionLoginMethod!.email), + ]; + } + + // 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. + // Custom implementations can choose to ignore this, and ask the user for the email anyway. + if (result.length === 0) { + result.push(`${sessionRecipeUserId.getAsString()}@stfakeemail.supertokens.com`); + } + + return { + status: "OK", + factorIdToEmailsMap: { + emailpassword: result, + }, + }; + }); + } + + const mtRecipe = MultitenancyRecipe.getInstance(); + if (mtRecipe !== undefined) { + mtRecipe.allAvailableFirstFactors.push(FactorIds.WEBAUTHN); + } + }); + } + + static getInstanceOrThrowError(): Recipe { + if (Recipe.instance !== undefined) { + return Recipe.instance; + } + throw new Error("Initialisation not done. Did you forget to call the Webauthn.init function?"); + } + + static init(config?: TypeInput): RecipeListFunction { + return (appInfo, isInServerlessEnv) => { + if (Recipe.instance === undefined) { + Recipe.instance = new Recipe(Recipe.RECIPE_ID, appInfo, isInServerlessEnv, config, { + emailDelivery: undefined, + }); + + return Recipe.instance; + } else { + throw new Error("Webauthn recipe has already been initialised. Please check your code for bugs."); + } + }; + } + + static reset() { + if (!isTestEnv()) { + throw new Error("calling testing function in non testing env"); + } + Recipe.instance = undefined; + } + + // abstract instance functions below............... + + getAPIsHandled = (): APIHandled[] => { + return [ + { + method: "post", + pathWithoutApiBasePath: new NormalisedURLPath(REGISTER_OPTIONS_API), + id: REGISTER_OPTIONS_API, + disabled: this.apiImpl.registerOptionsPOST === undefined, + }, + { + method: "post", + pathWithoutApiBasePath: new NormalisedURLPath(SIGNIN_OPTIONS_API), + id: SIGNIN_OPTIONS_API, + disabled: this.apiImpl.signInOptionsPOST === undefined, + }, + { + method: "post", + pathWithoutApiBasePath: new NormalisedURLPath(SIGN_UP_API), + id: SIGN_UP_API, + disabled: this.apiImpl.signUpPOST === undefined, + }, + { + method: "post", + pathWithoutApiBasePath: new NormalisedURLPath(SIGN_IN_API), + id: SIGN_IN_API, + 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, + // }, + ]; + }; + + handleAPIRequest = async ( + id: string, + tenantId: string, + req: BaseRequest, + res: BaseResponse, + _path: NormalisedURLPath, + _method: HTTPMethod, + userContext: UserContext + ): Promise => { + let options = { + config: this.config, + recipeId: this.getRecipeId(), + isInServerlessEnv: this.isInServerlessEnv, + recipeImplementation: this.recipeInterfaceImpl, + req, + res, + emailDelivery: this.emailDelivery, + appInfo: this.getAppInfo(), + }; + if (id === REGISTER_OPTIONS_API) { + return await registerOptionsAPI(this.apiImpl, tenantId, options, userContext); + } else if (id === SIGNIN_OPTIONS_API) { + return await signInOptionsAPI(this.apiImpl, tenantId, options, userContext); + } else if (id === SIGN_UP_API) { + 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; + }; + + handleError = async (err: STError, _request: BaseRequest, response: BaseResponse): Promise => { + if (err.fromRecipe === Recipe.RECIPE_ID) { + if (err.type === STError.FIELD_ERROR) { + return send200Response(response, { + status: "FIELD_ERROR", + formFields: err.payload, + }); + } else { + throw err; + } + } else { + throw err; + } + }; + + getAllCORSHeaders = (): string[] => { + return []; + }; + + isErrorFromThisRecipe = (err: any): err is STError => { + return STError.isErrorFromSuperTokens(err) && err.fromRecipe === Recipe.RECIPE_ID; + }; +} diff --git a/lib/ts/recipe/webauthn/recipeImplementation.ts b/lib/ts/recipe/webauthn/recipeImplementation.ts new file mode 100644 index 000000000..026e6b63a --- /dev/null +++ b/lib/ts/recipe/webauthn/recipeImplementation.ts @@ -0,0 +1,376 @@ +import { RecipeInterface, TypeNormalisedInput } from "./types"; +import AccountLinking from "../accountlinking/recipe"; +import { Querier } from "../../querier"; +import NormalisedURLPath from "../../normalisedURLPath"; +import { getUser } from "../.."; +import RecipeUserId from "../../recipeUserId"; +import { DEFAULT_TENANT_ID } from "../multitenancy/constants"; +import { UserContext, User as UserType } from "../../types"; +import { LoginMethod, User } from "../../user"; +import { AuthUtils } from "../../authUtils"; + +export default function getRecipeInterface( + querier: Querier, + getWebauthnConfig: () => TypeNormalisedInput +): RecipeInterface { + return { + registerOptions: async function ({ + email, + relyingPartyId, + relyingPartyName, + origin, + timeout, + attestation = "none", + tenantId, + userContext, + }: { + email: string; + timeout: number; + attestation: "none" | "indirect" | "direct" | "enterprise"; + relyingPartyId: string; + relyingPartyName: string; + origin: string; + 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. + return await querier.sendPostRequest( + new NormalisedURLPath( + `/${tenantId === undefined ? DEFAULT_TENANT_ID : tenantId}/recipe/webauthn/options/register` + ), + { + email, + relyingPartyName, + relyingPartyId, + origin, + timeout, + attestation, + }, + userContext + ); + }, + + signInOptions: async function ({ + relyingPartyId, + origin, + timeout, + tenantId, + userContext, + }: { + relyingPartyId: string; + origin: string; + timeout: number; + tenantId: string; + userContext: UserContext; + }): Promise<{ + status: "OK"; + webauthnGeneratedOptionsId: string; + challenge: string; + 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( + `/${tenantId === undefined ? DEFAULT_TENANT_ID : tenantId}/recipe/webauthn/options/signin` + ), + { + relyingPartyId, + origin, + timeout, + }, + userContext + ); + }, + + signUp: async function ( + this: RecipeInterface, + { webauthnGeneratedOptionsId, credential, tenantId, session, shouldTryLinkingWithSessionUser, userContext } + ): Promise< + | { + status: "OK"; + user: UserType; + recipeUserId: RecipeUserId; + } + | { status: "EMAIL_ALREADY_EXISTS_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"; + } + > { + const response = await this.createNewRecipeUser({ + credential, + webauthnGeneratedOptionsId, + tenantId, + userContext, + }); + if (response.status !== "OK") { + return response; + } + + let updatedUser = response.user; + + const linkResult = await AuthUtils.linkToSessionIfRequiredElseCreatePrimaryUserIdOrLinkByAccountInfo({ + tenantId, + inputUser: response.user, + recipeUserId: response.recipeUserId, + session, + shouldTryLinkingWithSessionUser, + userContext, + }); + + if (linkResult.status != "OK") { + return linkResult; + } + updatedUser = linkResult.user; + + return { + status: "OK", + user: updatedUser, + recipeUserId: response.recipeUserId, + }; + }, + + 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"; + }; + webauthnGeneratedOptionsId: string; + userContext: UserContext; + }): Promise< + | { + status: "OK"; + user: User; + recipeUserId: RecipeUserId; + } + | { status: "EMAIL_ALREADY_EXISTS_ERROR" } + > { + const resp = await querier.sendPostRequest( + new NormalisedURLPath( + `/${input.tenantId === undefined ? DEFAULT_TENANT_ID : input.tenantId}/recipe/webauthn/signup` + ), + { + webauthnGeneratedOptionsId: input.webauthnGeneratedOptionsId, + credential: input.credential, + }, + input.userContext + ); + + if (resp.status === "OK") { + return { + status: "OK", + user: new User(resp.user), + recipeUserId: new RecipeUserId(resp.recipeUserId), + }; + } + + return resp; + }, + + verifyCredentials: async function ({ + credential, + webauthnGeneratedOptionsId, + tenantId, + userContext, + }): Promise< + | { + status: "OK"; + user: User; + recipeUserId: RecipeUserId; + } + | { status: "WRONG_CREDENTIALS_ERROR" } + > { + const response = await querier.sendPostRequest( + new NormalisedURLPath( + `/${tenantId === undefined ? DEFAULT_TENANT_ID : tenantId}/recipe/webauthn/signin` + ), + { + credential, + webauthnGeneratedOptionsId, + }, + userContext + ); + + if (response.status === "OK") { + return { + status: "OK", + user: new User(response.user), + recipeUserId: new RecipeUserId(response.recipeUserId), + }; + } + + return { + status: "WRONG_CREDENTIALS_ERROR", + }; + }, + + signIn: async function ( + this: RecipeInterface, + { credential, webauthnGeneratedOptionsId, tenantId, session, shouldTryLinkingWithSessionUser, userContext } + ) { + const response = await this.verifyCredentials({ + credential, + webauthnGeneratedOptionsId, + tenantId, + userContext, + }); + + if (response.status === "OK") { + const loginMethod: LoginMethod = response.user.loginMethods.find( + (lm: LoginMethod) => lm.recipeUserId.getAsString() === response.recipeUserId.getAsString() + )!; + + if (!loginMethod.verified) { + await AccountLinking.getInstance().verifyEmailForRecipeUserIfLinkedAccountsAreVerified({ + user: response.user, + recipeUserId: response.recipeUserId, + userContext, + }); + + // Unlike in the sign up recipe function, we do not do account linking here + // cause we do not want sign in to change the potentially user ID of a user + // due to linking when this function is called by the dev in their API - + // for example in their update password API. If we did account linking + // then we would have to ask the dev to also change the session + // in such API calls. + // In the case of sign up, since we are creating a new user, it's fine + // to link there since there is no user id change really from the dev's + // point of view who is calling the sign up recipe function. + + // We do this so that we get the updated user (in case the above + // function updated the verification status) and can return that + response.user = (await getUser(response.recipeUserId!.getAsString(), userContext))!; + } + + const linkResult = await AuthUtils.linkToSessionIfRequiredElseCreatePrimaryUserIdOrLinkByAccountInfo({ + tenantId, + inputUser: response.user, + recipeUserId: response.recipeUserId, + session, + shouldTryLinkingWithSessionUser, + userContext, + }); + if (linkResult.status === "LINKING_TO_SESSION_USER_FAILED") { + return linkResult; + } + response.user = linkResult.user; + } + + 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 + // ); + // }, + + // 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 + // ); + // }, + }; +} diff --git a/lib/ts/recipe/passkey/types.ts b/lib/ts/recipe/webauthn/types.ts similarity index 66% rename from lib/ts/recipe/passkey/types.ts rename to lib/ts/recipe/webauthn/types.ts index cae369644..b068e2895 100644 --- a/lib/ts/recipe/passkey/types.ts +++ b/lib/ts/recipe/webauthn/types.ts @@ -25,12 +25,12 @@ import { GeneralErrorResponse, NormalisedAppinfo, User, UserContext } from "../. import RecipeUserId from "../../recipeUserId"; export type TypeNormalisedInput = { - validateEmail: (value: any, tenantId: string, userContext: UserContext) => Promise; - relyingPartyId: (input: { request: BaseRequest | undefined; userContext: UserContext }) => string; // should return the domain of the origin - relyingPartyName: (input: { request: BaseRequest | undefined; userContext: UserContext }) => string; // should return the app name + relyingPartyId: TypeNormalisedInputRelyingPartyId; + relyingPartyName: TypeNormalisedInputRelyingPartyName; + getOrigin: TypeNormalisedInputGetOrigin; getEmailDeliveryConfig: ( isInServerlessEnv: boolean - ) => EmailDeliveryTypeInputWithService; + ) => EmailDeliveryTypeInputWithService; override: { functions: ( originalImplementation: RecipeInterface, @@ -40,11 +40,23 @@ export type TypeNormalisedInput = { }; }; +export type TypeNormalisedInputRelyingPartyId = (input: { + request: BaseRequest | undefined; + userContext: UserContext; +}) => string; // should return the domain of the origin + +export type TypeNormalisedInputRelyingPartyName = (input: { + request: BaseRequest | undefined; + userContext: UserContext; +}) => string; // should return the app name + +export type TypeNormalisedInputGetOrigin = (input: { request: BaseRequest; userContext: UserContext }) => string; // should return the app name + export type TypeInput = { - emailDelivery?: EmailDeliveryTypeInput; - validateEmail?: (value: any, tenantId: string, userContext: UserContext) => Promise; - relyingPartyId?: string | ((input: { request: BaseRequest | undefined; userContext: UserContext }) => string); - relyingPartyName?: string | ((input: { request: BaseRequest | undefined; userContext: UserContext }) => string); + emailDelivery?: EmailDeliveryTypeInput; + relyingPartyId?: TypeInputRelyingPartyId; + relyingPartyName?: TypeInputRelyingPartyName; + getOrigin?: TypeInputGetOrigin; override?: { functions?: ( originalImplementation: RecipeInterface, @@ -54,14 +66,29 @@ export type TypeInput = { }; }; +export type TypeInputRelyingPartyId = + | string + | ((input: { request: BaseRequest | undefined; userContext: UserContext }) => string); + +export type TypeInputRelyingPartyName = + | string + | ((input: { request: BaseRequest | undefined; userContext: UserContext }) => string); + +export type TypeInputGetOrigin = (input: { request: BaseRequest; userContext: UserContext }) => string; + export type RecipeInterface = { - registerPasskeyOptions(input: { + registerOptions(input: { email: string; + relyingPartyId: string; + relyingPartyName: string; + origin: string; + timeout: number; + attestation: "none" | "indirect" | "direct" | "enterprise"; tenantId: string; userContext: UserContext; }): Promise<{ status: "OK"; - passkeyGeneratedOptionsId: string; + webauthnGeneratedOptionsId: string; rp: { id: string; name: string; @@ -90,22 +117,23 @@ export type RecipeInterface = { }; }>; - signInPasskeyOptions(input: { - session: SessionContainerInterface | undefined; + signInOptions(input: { + relyingPartyId: string; + origin: string; + timeout: number; tenantId: string; userContext: UserContext; }): Promise<{ status: "OK"; - passkeyGeneratedOptionsId: string; + webauthnGeneratedOptionsId: string; challenge: string; timeout: number; userVerification: "required" | "preferred" | "discouraged"; }>; signUp(input: { - email: string | undefined; - passkeyGeneratedOptionsId: string; - passkey: { + webauthnGeneratedOptionsId: string; + credential: { id: string; rawId: string; response: { @@ -139,9 +167,13 @@ export type RecipeInterface = { } >; - signIn(input: { - passkeyGeneratedOptionsId: string; - passkey: { + // this function is meant only for creating the recipe in the core and nothing else. + // we added this even though signUp exists cause devs may override signup expecting it + // to be called just during sign up. But we also need a version of signing up which can be + // called during operations like creating a user during password reset flow. + createNewRecipeUser(input: { + webauthnGeneratedOptionsId: string; + credential: { id: string; rawId: string; response: { @@ -154,38 +186,20 @@ export type RecipeInterface = { clientExtensionResults: Record; type: "public-key"; }; - session: SessionContainerInterface | undefined; - shouldTryLinkingWithSessionUser: boolean | undefined; tenantId: string; userContext: UserContext; }): 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"; + status: "OK"; + user: User; + recipeUserId: RecipeUserId; } + | { status: "EMAIL_ALREADY_EXISTS_ERROR" } >; - /** - * We pass in the email as well to this function cause the input userId - * may not be associated with an passkey account. In this case, we - * need to know which email to use to create an passkey account later on. - */ - generateRecoverAccountToken(input: { - userId: string; // the id can be either recipeUserId or primaryUserId - email: string; - tenantId: string; - userContext: UserContext; - }): Promise<{ status: "OK"; token: string } | { status: "UNKNOWN_USER_ID_ERROR" }>; - - consumeRecoverAccountToken(input: { - token: string; - passkey: { + verifyCredentials(input: { + webauthnGeneratedOptionsId: string; + credential: { id: string; rawId: string; response: { @@ -200,23 +214,11 @@ export type RecipeInterface = { }; tenantId: string; userContext: UserContext; - }): Promise< - | { - status: "OK"; - email: string; - userId: string; - } - | { status: "RECOVER_ACCOUNT_INVALID_TOKEN_ERROR" } - >; + }): Promise<{ status: "OK"; user: User; recipeUserId: RecipeUserId } | { status: "WRONG_CREDENTIALS_ERROR" }>; - // this function is meant only for creating the recipe in the core and nothing else. - // we added this even though signUp exists cause devs may override signup expecting it - // to be called just during sign up. But we also need a version of signing up which can be - // called during operations like creating a user during password reset flow. - createNewRecipeUser(input: { - email: string; - passkeyGeneratedOptionsId: string; - passkey: { + signIn(input: { + webauthnGeneratedOptionsId: string; + credential: { id: string; rawId: string; response: { @@ -229,16 +231,64 @@ export type RecipeInterface = { clientExtensionResults: Record; type: "public-key"; }; + session: SessionContainerInterface | undefined; + shouldTryLinkingWithSessionUser: boolean | undefined; tenantId: string; userContext: UserContext; }): Promise< + | { status: "OK"; user: User; recipeUserId: RecipeUserId } + | { status: "WRONG_CREDENTIALS_ERROR" } | { - status: "OK"; - user: User; - recipeUserId: RecipeUserId; + 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"; } - | { status: "EMAIL_ALREADY_EXISTS_ERROR" } >; + + // todo uncomment one by one once starting implementation + + /** + * We pass in the email as well to this function cause the input userId + * may not be associated with an passkey account. In this case, we + * need to know which email to use to create an passkey account later on. + */ + // generateRecoverAccountToken(input: { + // userId: string; // the id can be either recipeUserId or primaryUserId + // email: string; + // tenantId: string; + // userContext: UserContext; + // }): Promise<{ status: "OK"; token: string } | { status: "UNKNOWN_USER_ID_ERROR" }>; + + // // 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< + // | { + // status: "OK"; + // email: string; + // userId: string; + // } + // | { status: "RECOVER_ACCOUNT_INVALID_TOKEN_ERROR" } + // >; }; export type APIOptions = { @@ -249,21 +299,21 @@ export type APIOptions = { isInServerlessEnv: boolean; req: BaseRequest; res: BaseResponse; - emailDelivery: EmailDeliveryIngredient; + emailDelivery: EmailDeliveryIngredient; }; export type APIInterface = { - registerPasskeyOptionsPOST: + registerOptionsPOST: | undefined | ((input: { - email: string | undefined; + email: string; tenantId: string; options: APIOptions; userContext: UserContext; }) => Promise< | { status: "OK"; - passkeyGeneratedOptionsId: string; + webauthnGeneratedOptionsId: string; rp: { id: string; name: string; @@ -294,7 +344,7 @@ export type APIInterface = { | GeneralErrorResponse >); - signInPasskeyOptionsPOST: + signInOptionsPOST: | undefined | ((input: { tenantId: string; @@ -303,7 +353,7 @@ export type APIInterface = { }) => Promise< | { status: "OK"; - passkeyGeneratedOptionsId: string; + webauthnGeneratedOptionsId: string; challenge: string; timeout: number; userVerification: "required" | "preferred" | "discouraged"; @@ -315,8 +365,8 @@ export type APIInterface = { | undefined | ((input: { email: string; - passkeyGeneratedOptionsId: string; - passkey: { + webauthnGeneratedOptionsId: string; + credential: { id: string; rawId: string; response: { @@ -353,8 +403,8 @@ export type APIInterface = { signInPOST: | undefined | ((input: { - passkeyGeneratedOptionsId: string; - passkey: { + webauthnGeneratedOptionsId: string; + credential: { id: string; rawId: string; response: { @@ -388,74 +438,75 @@ export type APIInterface = { | GeneralErrorResponse >); - generateRecoverAccountTokenPOST: - | undefined - | ((input: { - email: string; - tenantId: string; - options: APIOptions; - userContext: UserContext; - }) => Promise< - | { - status: "OK"; - } - | { - status: "ACCOUNT_RECOVERY_NOT_ALLOWED"; - reason: string; - } - | GeneralErrorResponse - >); + // todo uncomment one by one once starting implementation + // generateRecoverAccountTokenPOST: + // | undefined + // | ((input: { + // email: string; + // tenantId: string; + // options: APIOptions; + // userContext: UserContext; + // }) => Promise< + // | { + // status: "OK"; + // } + // | { + // status: "ACCOUNT_RECOVERY_NOT_ALLOWED"; + // reason: string; + // } + // | GeneralErrorResponse + // >); - recoverAccountPOST: - | undefined - | ((input: { - passkey: { - 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"; - }; - token: string; - tenantId: string; - options: APIOptions; - userContext: UserContext; - }) => Promise< - | { - status: "OK"; - email: string; - user: User; - } - | { - status: "RECOVER_ACCOUNT_TOKEN_INVALID_TOKEN_ERROR"; - } - | GeneralErrorResponse - >); + // recoverAccountPOST: + // | undefined + // | ((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"; + // }; + // token: string; + // tenantId: string; + // options: APIOptions; + // userContext: UserContext; + // }) => Promise< + // | { + // status: "OK"; + // email: string; + // user: User; + // } + // | { + // status: "RECOVER_ACCOUNT_TOKEN_INVALID_TOKEN_ERROR"; + // } + // | GeneralErrorResponse + // >); - // used for checking if the email already exists before generating the passkey - emailExistsGET: - | undefined - | ((input: { - email: string; - tenantId: string; - options: APIOptions; - userContext: UserContext; - }) => Promise< - | { - status: "OK"; - exists: boolean; - } - | GeneralErrorResponse - >); + // // used for checking if the email already exists before generating the passkey + // emailExistsGET: + // | undefined + // | ((input: { + // email: string; + // tenantId: string; + // options: APIOptions; + // userContext: UserContext; + // }) => Promise< + // | { + // status: "OK"; + // exists: boolean; + // } + // | GeneralErrorResponse + // >); }; -export type TypePasskeyRecoverAccountEmailDeliveryInput = { +export type TypeWebauthnRecoverAccountEmailDeliveryInput = { type: "RECOVER_ACCOUNT"; user: { id: string; @@ -466,4 +517,4 @@ export type TypePasskeyRecoverAccountEmailDeliveryInput = { tenantId: string; }; -export type TypePasskeyEmailDeliveryInput = TypePasskeyRecoverAccountEmailDeliveryInput; +export type TypeWebauthnEmailDeliveryInput = TypeWebauthnRecoverAccountEmailDeliveryInput; diff --git a/lib/ts/recipe/webauthn/utils.ts b/lib/ts/recipe/webauthn/utils.ts new file mode 100644 index 000000000..278da3a9b --- /dev/null +++ b/lib/ts/recipe/webauthn/utils.ts @@ -0,0 +1,171 @@ +/* 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 Recipe from "./recipe"; +import { + TypeInput, + TypeInputGetOrigin, + TypeInputRelyingPartyId, + TypeInputRelyingPartyName, + TypeNormalisedInput, + TypeNormalisedInputGetOrigin, + TypeNormalisedInputRelyingPartyId, + TypeNormalisedInputRelyingPartyName, +} from "./types"; +import { NormalisedAppinfo, UserContext } from "../../types"; +import { RecipeInterface, APIInterface } from "./types"; +import { BaseRequest } from "../../framework"; + +export function validateAndNormaliseUserInput( + recipeInstance: Recipe, + appInfo: NormalisedAppinfo, + config?: TypeInput +): TypeNormalisedInput { + let relyingPartyId = validateAndNormaliseRelyingPartyIdConfig(recipeInstance, appInfo, config?.relyingPartyId); + let relyingPartyName = validateAndNormaliseRelyingPartyNameConfig( + recipeInstance, + appInfo, + config?.relyingPartyName + ); + let getOrigin = validateAndNormaliseGetOriginConfig(recipeInstance, appInfo, config?.getOrigin); + + let override = { + functions: (originalImplementation: RecipeInterface) => originalImplementation, + apis: (originalImplementation: APIInterface) => originalImplementation, + ...config?.override, + }; + + function getEmailDeliveryConfig(isInServerlessEnv: boolean) { + let emailService = config?.emailDelivery?.service; + /** + * If the user has not passed even that config, we use the default + * createAndSendCustomEmail implementation which calls our supertokens API + */ + // if (emailService === undefined) { + // emailService = new BackwardCompatibilityService(appInfo, isInServerlessEnv); + // } + return { + ...config?.emailDelivery, + /** + * if we do + * let emailDelivery = { + * service: emailService, + * ...config.emailDelivery, + * }; + * + * and if the user has passed service as undefined, + * it it again get set to undefined, so we + * set service at the end + */ + // todo implemenet this + service: null as any, + }; + } + return { + override, + getOrigin, + relyingPartyId, + relyingPartyName, + getEmailDeliveryConfig, + }; +} + +function validateAndNormaliseRelyingPartyIdConfig( + _: Recipe, + __: NormalisedAppinfo, + relyingPartyIdConfig: TypeInputRelyingPartyId | undefined +): TypeNormalisedInputRelyingPartyId { + return (props) => { + if (typeof relyingPartyIdConfig === "string") { + return relyingPartyIdConfig; + } else if (typeof relyingPartyIdConfig === "function") { + return relyingPartyIdConfig(props); + } else { + return __.getOrigin({ request: props.request, userContext: props.userContext }).getAsStringDangerous(); + } + }; +} + +function validateAndNormaliseRelyingPartyNameConfig( + _: Recipe, + __: NormalisedAppinfo, + relyingPartyNameConfig: TypeInputRelyingPartyName | undefined +): TypeNormalisedInputRelyingPartyName { + return (props) => { + if (typeof relyingPartyNameConfig === "string") { + return relyingPartyNameConfig; + } else if (typeof relyingPartyNameConfig === "function") { + return relyingPartyNameConfig(props); + } else { + return __.appName; + } + }; +} + +function validateAndNormaliseGetOriginConfig( + _: Recipe, + __: NormalisedAppinfo, + getOriginConfig: TypeInputGetOrigin | undefined +): TypeNormalisedInputGetOrigin { + return (props) => { + if (typeof getOriginConfig === "function") { + return getOriginConfig(props); + } else { + return __.getOrigin({ request: props.request, userContext: props.userContext }).getAsStringDangerous(); + } + }; +} + +export async function defaultEmailValidator(value: any) { + // We check if the email syntax is correct + // As per https://github.com/supertokens/supertokens-auth-react/issues/5#issuecomment-709512438 + // Regex from https://stackoverflow.com/a/46181/3867175 + + if (typeof value !== "string") { + return "Development bug: Please make sure the email field yields a string"; + } + + if ( + value.match( + /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ + ) === null + ) { + return "Email is invalid"; + } + + return undefined; +} + +export function getPasswordResetLink(input: { + appInfo: NormalisedAppinfo; + token: string; + tenantId: string; + request: BaseRequest | undefined; + userContext: UserContext; +}): string { + return ( + input.appInfo + .getOrigin({ + request: input.request, + userContext: input.userContext, + }) + .getAsStringDangerous() + + input.appInfo.websiteBasePath.getAsStringDangerous() + + "/reset-password?token=" + + input.token + + "&tenantId=" + + input.tenantId + ); +} From 40bd6e9795f8fd1be1392a201ad1465042d36064 Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Thu, 17 Oct 2024 18:03:04 +0300 Subject: [PATCH 04/25] updated types based on pr changes --- lib/ts/recipe/webauthn/types.ts | 477 +++++++++++++++++++------------- 1 file changed, 291 insertions(+), 186 deletions(-) diff --git a/lib/ts/recipe/webauthn/types.ts b/lib/ts/recipe/webauthn/types.ts index b068e2895..34108e0c7 100644 --- a/lib/ts/recipe/webauthn/types.ts +++ b/lib/ts/recipe/webauthn/types.ts @@ -45,10 +45,7 @@ export type TypeNormalisedInputRelyingPartyId = (input: { userContext: UserContext; }) => string; // should return the domain of the origin -export type TypeNormalisedInputRelyingPartyName = (input: { - request: BaseRequest | undefined; - userContext: UserContext; -}) => string; // should return the app name +export type TypeNormalisedInputRelyingPartyName = () => string; // should return the app name export type TypeNormalisedInputGetOrigin = (input: { request: BaseRequest; userContext: UserContext }) => string; // should return the app name @@ -70,66 +67,103 @@ export type TypeInputRelyingPartyId = | string | ((input: { request: BaseRequest | undefined; userContext: UserContext }) => string); -export type TypeInputRelyingPartyName = - | string - | ((input: { request: BaseRequest | undefined; userContext: UserContext }) => string); +export type TypeInputRelyingPartyName = string | (() => string); export type TypeInputGetOrigin = (input: { request: BaseRequest; userContext: UserContext }) => string; export type RecipeInterface = { - registerOptions(input: { - email: string; - relyingPartyId: string; - relyingPartyName: string; - origin: string; - timeout: number; - attestation: "none" | "indirect" | "direct" | "enterprise"; - 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"; - }; - }>; + // should have a way to access the user email: passed as a param, through session, or using recoverAccountToken + // it should have at least one of those 3 options + registerOptions( + input: { + 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; + } & ( + | { + recoverAccountToken: string; + } + | { + email: string; + } + | { + session: SessionContainerInterface; + } + ) + ): Promise< + | { + status: "OK"; + webauthnGeneratedOptionsId: string; + rp: { + id: string; + name: string; + }; + user: { + id: string; + name: string; // user email + displayName: string; //user email + }; + challenge: string; + timeout: number; + excludeCredentials: { + id: string; + type: "public-key"; + transports: ("ble" | "hybrid" | "internal" | "nfc" | "usb")[]; + }[]; + attestation: "none" | "indirect" | "direct" | "enterprise"; + pubKeyCredParams: { + // we will default to [-8, -7, -257] as supported algorithms. See https://www.iana.org/assignments/cose/cose.xhtml#algorithms + alg: number; + type: "public-key"; + }[]; + authenticatorSelection: { + requireResidentKey: boolean; + residentKey: "required" | "preferred" | "discouraged"; + userVerification: "required" | "preferred" | "discouraged"; + }; + } + | { status: "EMAIL_MISSING_ERROR" } // email is required if not using session or recoverAccountToken + | { status: "SESSION_MISSING_ERROR" } // session is required if not using email or recoverAccountToken + | { status: "RECOVER_ACCOUNT_TOKEN_MISSING_ERROR" } // recoverAccountToken is required if not using email or session + | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } + | { status: "RELYING_PARTY_ID_MISSING_ERROR" } + | { status: "RELYING_PARTY_ID_INVALID_ERROR" } // wrong format; must be domain + | { status: "RELYING_PARTY_NAME_MISSING_ERROR" } // relyingPartyName is required + | { status: "ORIGIN_MISSING_ERROR" } + | { status: "ORIGIN_INVALID_ERROR" } // wrong format; must be domain url + >; signInOptions(input: { relyingPartyId: string; origin: string; - timeout: number; + userVerification: "required" | "preferred" | "discouraged" | undefined; // see register options + timeout: number | undefined; tenantId: string; userContext: UserContext; - }): Promise<{ - status: "OK"; - webauthnGeneratedOptionsId: string; - challenge: string; - timeout: number; - userVerification: "required" | "preferred" | "discouraged"; - }>; + }): Promise< + | { + status: "OK"; + webauthnGeneratedOptionsId: string; + challenge: string; + timeout: number; + userVerification: "required" | "preferred" | "discouraged"; + } + | { status: "RELYING_PARTY_ID_MISSING_ERROR" } + | { status: "RELYING_PARTY_ID_INVALID_ERROR" } // wrong format; must be domain + | { status: "ORIGIN_MISSING_ERROR" } + | { status: "ORIGIN_INVALID_ERROR" } // wrong format; must be domain url + >; signUp(input: { webauthnGeneratedOptionsId: string; @@ -157,6 +191,9 @@ export type RecipeInterface = { recipeUserId: RecipeUserId; } | { status: "EMAIL_ALREADY_EXISTS_ERROR" } + | { status: "WRONG_CREDENTIALS_ERROR" } + | { status: "CREDENTIAL_MISSING_ERROR" } + | { status: "GENERATED_OPTIONS_ID_MISSING_ERROR" } | { status: "LINKING_TO_SESSION_USER_FAILED"; reason: @@ -167,11 +204,91 @@ export type RecipeInterface = { } >; - // this function is meant only for creating the recipe in the core and nothing else. - // we added this even though signUp exists cause devs may override signup expecting it - // to be called just during sign up. But we also need a version of signing up which can be - // called during operations like creating a user during password reset flow. - createNewRecipeUser(input: { + 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"; + }; + session: SessionContainerInterface | undefined; + shouldTryLinkingWithSessionUser: boolean | undefined; + tenantId: string; + userContext: UserContext; + }): Promise< + | { status: "OK"; user: User; recipeUserId: RecipeUserId } + | { status: "WRONG_CREDENTIALS_ERROR" } + | { status: "CREDENTIAL_MISSING_ERROR" } + | { status: "GENERATED_OPTIONS_ID_MISSING_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"; + } + >; + + /** + * We pass in the email as well to this function cause the input userId + * may not be associated with an webauthn account. In this case, we + * need to know which email to use to create an webauthn account later on. + */ + generateRecoverAccountToken(input: { + userId: string; // the id can be either recipeUserId or primaryUserId + email: string; + tenantId: string; + userContext: UserContext; + }): Promise< + { status: "OK"; token: string } | { status: "UNKNOWN_USER_ID_ERROR" } | { status: "UNKNOWN_EMAIL_ERROR" } + >; + + // 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< + | { + status: "OK"; + email: string; + userId: string; + } + | { status: "WRONG_CREDENTIALS_ERROR" } + | { status: "CREDENTIAL_MISSING_ERROR" } + | { status: "GENERATED_OPTIONS_ID_MISSING_ERROR" } + | { status: "RECOVER_ACCOUNT_TOKEN_MISSING_ERROR" } + | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } + >; + + // used internally for creating a passkey during password reset flow or when adding a credential to an existing user + // email will be taken from the options + // no need for recoverAccountToken, as that will be used upstream + // (in consumeRecoverAccountToken invalidating the token and in registerOptions for storing the email in the generated options) + registerPasskey(input: { webauthnGeneratedOptionsId: string; credential: { id: string; @@ -194,10 +311,16 @@ export type RecipeInterface = { user: User; recipeUserId: RecipeUserId; } - | { status: "EMAIL_ALREADY_EXISTS_ERROR" } + | { status: "CREDENTIAL_MISSING_ERROR" } + | { status: "WRONG_CREDENTIALS_ERROR" } + | { status: "GENERATED_OPTIONS_ID_MISSING_ERROR" } >; - verifyCredentials(input: { + // this function is meant only for creating the recipe in the core and nothing else. + // we added this even though signUp exists cause devs may override signup expecting it + // to be called just during sign up. But we also need a version of signing up which can be + // called during operations like creating a user during password reset flow. + createNewRecipeUser(input: { webauthnGeneratedOptionsId: string; credential: { id: string; @@ -214,9 +337,19 @@ export type RecipeInterface = { }; tenantId: string; userContext: UserContext; - }): Promise<{ status: "OK"; user: User; recipeUserId: RecipeUserId } | { status: "WRONG_CREDENTIALS_ERROR" }>; + }): Promise< + | { + status: "OK"; + user: User; + recipeUserId: RecipeUserId; + } + | { status: "EMAIL_ALREADY_EXISTS_ERROR" } + | { status: "WRONG_CREDENTIALS_ERROR" } + | { status: "CREDENTIAL_MISSING_ERROR" } + | { status: "GENERATED_OPTIONS_ID_MISSING_ERROR" } + >; - signIn(input: { + verifyCredentials(input: { webauthnGeneratedOptionsId: string; credential: { id: string; @@ -231,64 +364,26 @@ export type RecipeInterface = { clientExtensionResults: Record; type: "public-key"; }; - session: SessionContainerInterface | undefined; - shouldTryLinkingWithSessionUser: boolean | undefined; tenantId: string; userContext: UserContext; }): 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"; - } + | { status: "CREDENTIAL_MISSING_ERROR" } + | { status: "GENERATED_OPTIONS_ID_MISSING_ERROR" } >; - // todo uncomment one by one once starting implementation - - /** - * We pass in the email as well to this function cause the input userId - * may not be associated with an passkey account. In this case, we - * need to know which email to use to create an passkey account later on. - */ - // generateRecoverAccountToken(input: { - // userId: string; // the id can be either recipeUserId or primaryUserId - // email: string; - // tenantId: string; - // userContext: UserContext; - // }): Promise<{ status: "OK"; token: string } | { status: "UNKNOWN_USER_ID_ERROR" }>; - - // // 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< - // | { - // status: "OK"; - // email: string; - // userId: string; - // } - // | { status: "RECOVER_ACCOUNT_INVALID_TOKEN_ERROR" } - // >; + // used for retrieving the user details (email) from the recover account token + // should be used in the registerOptions function when the user recovers the account and generates the credentials + getUserFromRecoverAccountToken(input: { + token: string; + tenantId: string; + userContext: UserContext; + }): Promise< + | { status: "OK"; user: User; recipeUserId: RecipeUserId } + | { status: "RECOVER_ACCOUNT_TOKEN_MISSING_ERROR" } + | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } + >; }; export type APIOptions = { @@ -305,12 +400,13 @@ export type APIOptions = { export type APIInterface = { registerOptionsPOST: | undefined - | ((input: { - email: string; - tenantId: string; - options: APIOptions; - userContext: UserContext; - }) => Promise< + | (( + input: { + tenantId: string; + options: APIOptions; + userContext: UserContext; + } & ({ email: string } | { recoverAccountToken: string } | { session: SessionContainerInterface }) + ) => Promise< | { status: "OK"; webauthnGeneratedOptionsId: string; @@ -327,7 +423,7 @@ export type APIInterface = { timeout: number; excludeCredentials: { id: string; - type: string; + type: "public-key"; transports: ("ble" | "hybrid" | "internal" | "nfc" | "usb")[]; }[]; attestation: "none" | "indirect" | "direct" | "enterprise"; @@ -342,6 +438,15 @@ export type APIInterface = { }; } | GeneralErrorResponse + | { status: "EMAIL_MISSING_ERROR" } // email is required if not using session or recoverAccountToken + | { status: "SESSION_MISSING_ERROR" } // session is required if not using email or recoverAccountToken + | { status: "RECOVER_ACCOUNT_TOKEN_MISSING_ERROR" } // recoverAccountToken is required if not using email or session + | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } + | { status: "RELYING_PARTY_ID_MISSING_ERROR" } + | { status: "RELYING_PARTY_ID_INVALID_ERROR" } // wrong format; must be domain + | { status: "RELYING_PARTY_NAME_MISSING_ERROR" } // relyingPartyName is required + | { status: "ORIGIN_MISSING_ERROR" } + | { status: "ORIGIN_INVALID_ERROR" } // wrong format; must be domain url >); signInOptionsPOST: @@ -359,12 +464,12 @@ export type APIInterface = { userVerification: "required" | "preferred" | "discouraged"; } | GeneralErrorResponse + | { status: "SIGN_IN_OPTIONS_NOT_ALLOWED"; reason: string } >); signUpPOST: | undefined | ((input: { - email: string; webauthnGeneratedOptionsId: string; credential: { id: string; @@ -438,72 +543,72 @@ export type APIInterface = { | GeneralErrorResponse >); - // todo uncomment one by one once starting implementation - // generateRecoverAccountTokenPOST: - // | undefined - // | ((input: { - // email: string; - // tenantId: string; - // options: APIOptions; - // userContext: UserContext; - // }) => Promise< - // | { - // status: "OK"; - // } - // | { - // status: "ACCOUNT_RECOVERY_NOT_ALLOWED"; - // reason: string; - // } - // | GeneralErrorResponse - // >); + generateRecoverAccountTokenPOST: + | undefined + | ((input: { + email: string; + tenantId: string; + options: APIOptions; + userContext: UserContext; + }) => Promise< + | { + status: "OK"; + } + | { + status: "ACCOUNT_RECOVERY_NOT_ALLOWED"; + reason: string; + } + | GeneralErrorResponse + >); - // recoverAccountPOST: - // | undefined - // | ((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"; - // }; - // token: string; - // tenantId: string; - // options: APIOptions; - // userContext: UserContext; - // }) => Promise< - // | { - // status: "OK"; - // email: string; - // user: User; - // } - // | { - // status: "RECOVER_ACCOUNT_TOKEN_INVALID_TOKEN_ERROR"; - // } - // | GeneralErrorResponse - // >); + recoverAccountPOST: + | 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"; + }; + token: string; + tenantId: string; + options: APIOptions; + userContext: UserContext; + }) => Promise< + | { + status: "OK"; + email: string; + user: User; + } + | { + status: "RECOVER_ACCOUNT_TOKEN_INVALID_TOKEN_ERROR"; + } + | GeneralErrorResponse + >); - // // used for checking if the email already exists before generating the passkey - // emailExistsGET: - // | undefined - // | ((input: { - // email: string; - // tenantId: string; - // options: APIOptions; - // userContext: UserContext; - // }) => Promise< - // | { - // status: "OK"; - // exists: boolean; - // } - // | GeneralErrorResponse - // >); + // used for checking if the email already exists before generating the credential + emailExistsGET: + | undefined + | ((input: { + email: string; + tenantId: string; + options: APIOptions; + userContext: UserContext; + }) => Promise< + | { + status: "OK"; + exists: boolean; + } + | GeneralErrorResponse + >); }; export type TypeWebauthnRecoverAccountEmailDeliveryInput = { From e150fc2e242631af22fcaa1b064423c953436fc2 Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Fri, 18 Oct 2024 15:57:54 +0300 Subject: [PATCH 05/25] pr changes. removed incorrect errors and added missing ones --- lib/ts/recipe/webauthn/types.ts | 85 +++++++++++++-------------------- 1 file changed, 34 insertions(+), 51 deletions(-) diff --git a/lib/ts/recipe/webauthn/types.ts b/lib/ts/recipe/webauthn/types.ts index 34108e0c7..65ad9e66c 100644 --- a/lib/ts/recipe/webauthn/types.ts +++ b/lib/ts/recipe/webauthn/types.ts @@ -45,9 +45,16 @@ export type TypeNormalisedInputRelyingPartyId = (input: { userContext: UserContext; }) => string; // should return the domain of the origin -export type TypeNormalisedInputRelyingPartyName = () => string; // should return the app name +export type TypeNormalisedInputRelyingPartyName = (input: { + tenantId: string; + userContext: UserContext; +}) => Promise; // should return the app name -export type TypeNormalisedInputGetOrigin = (input: { request: BaseRequest; userContext: UserContext }) => string; // should return the app name +export type TypeNormalisedInputGetOrigin = (input: { + tenantId: string; + request: BaseRequest; + userContext: UserContext; +}) => Promise; // should return the app name export type TypeInput = { emailDelivery?: EmailDeliveryTypeInput; @@ -65,11 +72,17 @@ export type TypeInput = { export type TypeInputRelyingPartyId = | string - | ((input: { request: BaseRequest | undefined; userContext: UserContext }) => string); + | ((input: { tenantId: string; request: BaseRequest | undefined; userContext: UserContext }) => Promise); -export type TypeInputRelyingPartyName = string | (() => string); +export type TypeInputRelyingPartyName = + | string + | ((input: { tenantId: string; userContext: UserContext }) => Promise); -export type TypeInputGetOrigin = (input: { request: BaseRequest; userContext: UserContext }) => string; +export type TypeInputGetOrigin = (input: { + tenantId: string; + request: BaseRequest; + userContext: UserContext; +}) => Promise; export type RecipeInterface = { // should have a way to access the user email: passed as a param, through session, or using recoverAccountToken @@ -133,15 +146,7 @@ export type RecipeInterface = { userVerification: "required" | "preferred" | "discouraged"; }; } - | { status: "EMAIL_MISSING_ERROR" } // email is required if not using session or recoverAccountToken - | { status: "SESSION_MISSING_ERROR" } // session is required if not using email or recoverAccountToken - | { status: "RECOVER_ACCOUNT_TOKEN_MISSING_ERROR" } // recoverAccountToken is required if not using email or session | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } - | { status: "RELYING_PARTY_ID_MISSING_ERROR" } - | { status: "RELYING_PARTY_ID_INVALID_ERROR" } // wrong format; must be domain - | { status: "RELYING_PARTY_NAME_MISSING_ERROR" } // relyingPartyName is required - | { status: "ORIGIN_MISSING_ERROR" } - | { status: "ORIGIN_INVALID_ERROR" } // wrong format; must be domain url >; signInOptions(input: { @@ -151,19 +156,13 @@ export type RecipeInterface = { timeout: number | undefined; tenantId: string; userContext: UserContext; - }): Promise< - | { - status: "OK"; - webauthnGeneratedOptionsId: string; - challenge: string; - timeout: number; - userVerification: "required" | "preferred" | "discouraged"; - } - | { status: "RELYING_PARTY_ID_MISSING_ERROR" } - | { status: "RELYING_PARTY_ID_INVALID_ERROR" } // wrong format; must be domain - | { status: "ORIGIN_MISSING_ERROR" } - | { status: "ORIGIN_INVALID_ERROR" } // wrong format; must be domain url - >; + }): Promise<{ + status: "OK"; + webauthnGeneratedOptionsId: string; + challenge: string; + timeout: number; + userVerification: "required" | "preferred" | "discouraged"; + }>; signUp(input: { webauthnGeneratedOptionsId: string; @@ -191,9 +190,9 @@ export type RecipeInterface = { recipeUserId: RecipeUserId; } | { status: "EMAIL_ALREADY_EXISTS_ERROR" } + // when the attestation is checked and is not valid or other cases in whcih the authenticator is not correct + | { status: "INVALID_AUTHENTICATOR_ERROR" } | { status: "WRONG_CREDENTIALS_ERROR" } - | { status: "CREDENTIAL_MISSING_ERROR" } - | { status: "GENERATED_OPTIONS_ID_MISSING_ERROR" } | { status: "LINKING_TO_SESSION_USER_FAILED"; reason: @@ -226,8 +225,8 @@ export type RecipeInterface = { }): Promise< | { status: "OK"; user: User; recipeUserId: RecipeUserId } | { status: "WRONG_CREDENTIALS_ERROR" } - | { status: "CREDENTIAL_MISSING_ERROR" } - | { status: "GENERATED_OPTIONS_ID_MISSING_ERROR" } + // when the attestation is checked and is not valid or other cases in which the authenticator is not correct + | { status: "INVALID_AUTHENTICATOR_ERROR" } | { status: "LINKING_TO_SESSION_USER_FAILED"; reason: @@ -278,13 +277,10 @@ export type RecipeInterface = { userId: string; } | { status: "WRONG_CREDENTIALS_ERROR" } - | { status: "CREDENTIAL_MISSING_ERROR" } - | { status: "GENERATED_OPTIONS_ID_MISSING_ERROR" } - | { status: "RECOVER_ACCOUNT_TOKEN_MISSING_ERROR" } | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } >; - // used internally for creating a passkey during password reset flow or when adding a credential to an existing user + // used internally for creating a credential during account recovery flow or when adding a credential to an existing user // email will be taken from the options // no need for recoverAccountToken, as that will be used upstream // (in consumeRecoverAccountToken invalidating the token and in registerOptions for storing the email in the generated options) @@ -311,9 +307,8 @@ export type RecipeInterface = { user: User; recipeUserId: RecipeUserId; } - | { status: "CREDENTIAL_MISSING_ERROR" } | { status: "WRONG_CREDENTIALS_ERROR" } - | { status: "GENERATED_OPTIONS_ID_MISSING_ERROR" } + | { status: "INVALID_AUTHENTICATOR_ERROR" } >; // this function is meant only for creating the recipe in the core and nothing else. @@ -345,8 +340,6 @@ export type RecipeInterface = { } | { status: "EMAIL_ALREADY_EXISTS_ERROR" } | { status: "WRONG_CREDENTIALS_ERROR" } - | { status: "CREDENTIAL_MISSING_ERROR" } - | { status: "GENERATED_OPTIONS_ID_MISSING_ERROR" } >; verifyCredentials(input: { @@ -369,8 +362,7 @@ export type RecipeInterface = { }): Promise< | { status: "OK"; user: User; recipeUserId: RecipeUserId } | { status: "WRONG_CREDENTIALS_ERROR" } - | { status: "CREDENTIAL_MISSING_ERROR" } - | { status: "GENERATED_OPTIONS_ID_MISSING_ERROR" } + | { status: "INVALID_AUTHENTICATOR_ERROR" } >; // used for retrieving the user details (email) from the recover account token @@ -380,9 +372,7 @@ export type RecipeInterface = { tenantId: string; userContext: UserContext; }): Promise< - | { status: "OK"; user: User; recipeUserId: RecipeUserId } - | { status: "RECOVER_ACCOUNT_TOKEN_MISSING_ERROR" } - | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } + { status: "OK"; user: User; recipeUserId: RecipeUserId } | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } >; }; @@ -439,14 +429,7 @@ export type APIInterface = { } | GeneralErrorResponse | { status: "EMAIL_MISSING_ERROR" } // email is required if not using session or recoverAccountToken - | { status: "SESSION_MISSING_ERROR" } // session is required if not using email or recoverAccountToken - | { status: "RECOVER_ACCOUNT_TOKEN_MISSING_ERROR" } // recoverAccountToken is required if not using email or session | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } - | { status: "RELYING_PARTY_ID_MISSING_ERROR" } - | { status: "RELYING_PARTY_ID_INVALID_ERROR" } // wrong format; must be domain - | { status: "RELYING_PARTY_NAME_MISSING_ERROR" } // relyingPartyName is required - | { status: "ORIGIN_MISSING_ERROR" } - | { status: "ORIGIN_INVALID_ERROR" } // wrong format; must be domain url >); signInOptionsPOST: @@ -589,7 +572,7 @@ export type APIInterface = { user: User; } | { - status: "RECOVER_ACCOUNT_TOKEN_INVALID_TOKEN_ERROR"; + status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR"; } | GeneralErrorResponse >); From 8c8d71121037a2625419711e6718443a9105bacc Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Fri, 18 Oct 2024 16:21:45 +0300 Subject: [PATCH 06/25] added missing user type --- lib/ts/user.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ts/user.ts b/lib/ts/user.ts index 365e0f846..f7daf67aa 100644 --- a/lib/ts/user.ts +++ b/lib/ts/user.ts @@ -136,7 +136,7 @@ export type UserWithoutHelperFunctions = { userId: string; }[]; loginMethods: { - recipeId: "emailpassword" | "thirdparty" | "passwordless"; + recipeId: "emailpassword" | "thirdparty" | "passwordless" | "webauthn"; recipeUserId: string; tenantIds: string[]; From ced57b11260144d1e1b89039db3c1bdbbc258318 Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Fri, 18 Oct 2024 20:38:36 +0300 Subject: [PATCH 07/25] added webauthn details to user object --- lib/ts/recipe/accountlinking/types.ts | 3 +++ lib/ts/types.ts | 3 +++ lib/ts/user.ts | 16 ++++++++++++++-- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/lib/ts/recipe/accountlinking/types.ts b/lib/ts/recipe/accountlinking/types.ts index 9426edbc9..5559796c9 100644 --- a/lib/ts/recipe/accountlinking/types.ts +++ b/lib/ts/recipe/accountlinking/types.ts @@ -192,6 +192,9 @@ export type AccountInfo = { id: string; userId: string; }; + webauthn?: { + credentialIds: string[]; + }; }; export type AccountInfoWithRecipeId = { diff --git a/lib/ts/types.ts b/lib/ts/types.ts index e87e6b2a7..482d41b9d 100644 --- a/lib/ts/types.ts +++ b/lib/ts/types.ts @@ -115,6 +115,9 @@ export type User = { id: string; userId: string; }[]; + webauthn: { + credentialIds: string[]; + }[]; loginMethods: (RecipeLevelUser & { verified: boolean; hasSameEmailAs: (email: string | undefined) => boolean; diff --git a/lib/ts/user.ts b/lib/ts/user.ts index f7daf67aa..87213468e 100644 --- a/lib/ts/user.ts +++ b/lib/ts/user.ts @@ -11,6 +11,7 @@ export class LoginMethod implements RecipeLevelUser { public readonly email?: string; public readonly phoneNumber?: string; public readonly thirdParty?: RecipeLevelUser["thirdParty"]; + public readonly webauthn?: RecipeLevelUser["webauthn"]; public readonly verified: boolean; public readonly timeJoined: number; @@ -23,7 +24,7 @@ export class LoginMethod implements RecipeLevelUser { this.email = loginMethod.email; this.phoneNumber = loginMethod.phoneNumber; this.thirdParty = loginMethod.thirdParty; - + this.webauthn = loginMethod.webauthn; this.timeJoined = loginMethod.timeJoined; this.verified = loginMethod.verified; } @@ -73,6 +74,7 @@ export class LoginMethod implements RecipeLevelUser { email: this.email, phoneNumber: this.phoneNumber, thirdParty: this.thirdParty, + webauthn: this.webauthn, timeJoined: this.timeJoined, verified: this.verified, }; @@ -90,6 +92,9 @@ export class User implements UserType { id: string; userId: string; }[]; + public readonly webauthn: { + credentialIds: string[]; + }[]; public readonly loginMethods: LoginMethod[]; public readonly timeJoined: number; // minimum timeJoined value from linkedRecipes @@ -102,7 +107,7 @@ export class User implements UserType { this.emails = user.emails; this.phoneNumbers = user.phoneNumbers; this.thirdParty = user.thirdParty; - + this.webauthn = user.webauthn; this.timeJoined = user.timeJoined; this.loginMethods = user.loginMethods.map((m) => new LoginMethod(m)); @@ -117,6 +122,7 @@ export class User implements UserType { emails: this.emails, phoneNumbers: this.phoneNumbers, thirdParty: this.thirdParty, + webauthn: this.webauthn, loginMethods: this.loginMethods.map((m) => m.toJson()), timeJoined: this.timeJoined, @@ -135,6 +141,9 @@ export type UserWithoutHelperFunctions = { id: string; userId: string; }[]; + webauthn: { + credentialIds: string[]; + }[]; loginMethods: { recipeId: "emailpassword" | "thirdparty" | "passwordless" | "webauthn"; recipeUserId: string; @@ -146,6 +155,9 @@ export type UserWithoutHelperFunctions = { id: string; userId: string; }; + webauthn?: { + credentialIds: string[]; + }; verified: boolean; timeJoined: number; From fbbed56109bcccbd51d19fc0aa0c307a8b7bab08 Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Mon, 21 Oct 2024 12:00:10 +0300 Subject: [PATCH 08/25] pr fixes. centralized error types and added crud for credentials --- lib/ts/recipe/multitenancy/types.ts | 3 + lib/ts/recipe/webauthn/types.ts | 223 ++++++++++++++++++++++------ 2 files changed, 178 insertions(+), 48 deletions(-) diff --git a/lib/ts/recipe/multitenancy/types.ts b/lib/ts/recipe/multitenancy/types.ts index 3dfb26a7a..e0d56dff3 100644 --- a/lib/ts/recipe/multitenancy/types.ts +++ b/lib/ts/recipe/multitenancy/types.ts @@ -173,6 +173,9 @@ export type APIInterface = { passwordless: { enabled: boolean; }; + webauthn: { + credentialIds: string[]; + }; firstFactors: string[]; } | GeneralErrorResponse diff --git a/lib/ts/recipe/webauthn/types.ts b/lib/ts/recipe/webauthn/types.ts index 65ad9e66c..c3f93f7bc 100644 --- a/lib/ts/recipe/webauthn/types.ts +++ b/lib/ts/recipe/webauthn/types.ts @@ -84,6 +84,37 @@ export type TypeInputGetOrigin = (input: { userContext: UserContext; }) => Promise; +// centralize error types in order to prevent missing cascading errors +type RegisterCredentialErrorResponse = + | { 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" }; + +type VerifyCredentialsErrorResponse = + | { status: "WRONG_CREDENTIALS_ERROR" } + // when the attestation is checked and is not valid or other cases in which the authenticator is not correct + | { status: "INVALID_AUTHENTICATOR_ERROR" }; + +type CreateNewRecipeUserErrorResponse = RegisterCredentialErrorResponse | { status: "EMAIL_ALREADY_EXISTS_ERROR" }; + +type GetUserFromRecoverAccountTokenErrorResponse = { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" }; + +type RegisterOptionsErrorResponse = GetUserFromRecoverAccountTokenErrorResponse | { status: "EMAIL_MISSING_ERROR" }; + +type SignUpErrorResponse = CreateNewRecipeUserErrorResponse; + +type SignInErrorResponse = VerifyCredentialsErrorResponse; + +type GenerateRecoverAccountTokenErrorResponse = { status: "UNKNOWN_USER_ID_ERROR" } | { status: "UNKNOWN_EMAIL_ERROR" }; + +type ConsumeRecoverAccountTokenErrorResponse = + | RegisterCredentialErrorResponse + | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" }; + +type AddCredentialErrorResponse = RegisterCredentialErrorResponse; + +type RemoveCredentialErrorResponse = { status: "CREDENTIAL_NOT_FOUND_ERROR" }; + export type RecipeInterface = { // should have a way to access the user email: passed as a param, through session, or using recoverAccountToken // it should have at least one of those 3 options @@ -146,7 +177,7 @@ export type RecipeInterface = { userVerification: "required" | "preferred" | "discouraged"; }; } - | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } + | RegisterOptionsErrorResponse >; signInOptions(input: { @@ -189,10 +220,7 @@ export type RecipeInterface = { user: User; recipeUserId: RecipeUserId; } - | { status: "EMAIL_ALREADY_EXISTS_ERROR" } - // when the attestation is checked and is not valid or other cases in whcih the authenticator is not correct - | { status: "INVALID_AUTHENTICATOR_ERROR" } - | { status: "WRONG_CREDENTIALS_ERROR" } + | SignUpErrorResponse | { status: "LINKING_TO_SESSION_USER_FAILED"; reason: @@ -224,9 +252,7 @@ export type RecipeInterface = { userContext: UserContext; }): Promise< | { status: "OK"; user: User; recipeUserId: RecipeUserId } - | { status: "WRONG_CREDENTIALS_ERROR" } - // when the attestation is checked and is not valid or other cases in which the authenticator is not correct - | { status: "INVALID_AUTHENTICATOR_ERROR" } + | SignInErrorResponse | { status: "LINKING_TO_SESSION_USER_FAILED"; reason: @@ -247,9 +273,7 @@ export type RecipeInterface = { email: string; tenantId: string; userContext: UserContext; - }): Promise< - { status: "OK"; token: string } | { status: "UNKNOWN_USER_ID_ERROR" } | { status: "UNKNOWN_EMAIL_ERROR" } - >; + }): Promise<{ status: "OK"; token: string } | GenerateRecoverAccountTokenErrorResponse>; // make sure the email maps to options email consumeRecoverAccountToken(input: { @@ -276,15 +300,14 @@ export type RecipeInterface = { email: string; userId: string; } - | { status: "WRONG_CREDENTIALS_ERROR" } - | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } + | ConsumeRecoverAccountTokenErrorResponse >; // used internally for creating a credential during account recovery flow or when adding a credential to an existing user // email will be taken from the options // no need for recoverAccountToken, as that will be used upstream // (in consumeRecoverAccountToken invalidating the token and in registerOptions for storing the email in the generated options) - registerPasskey(input: { + registerCredential(input: { webauthnGeneratedOptionsId: string; credential: { id: string; @@ -307,8 +330,7 @@ export type RecipeInterface = { user: User; recipeUserId: RecipeUserId; } - | { status: "WRONG_CREDENTIALS_ERROR" } - | { status: "INVALID_AUTHENTICATOR_ERROR" } + | RegisterCredentialErrorResponse >; // this function is meant only for creating the recipe in the core and nothing else. @@ -338,8 +360,7 @@ export type RecipeInterface = { user: User; recipeUserId: RecipeUserId; } - | { status: "EMAIL_ALREADY_EXISTS_ERROR" } - | { status: "WRONG_CREDENTIALS_ERROR" } + | CreateNewRecipeUserErrorResponse >; verifyCredentials(input: { @@ -359,11 +380,7 @@ export type RecipeInterface = { }; tenantId: string; userContext: UserContext; - }): Promise< - | { status: "OK"; user: User; recipeUserId: RecipeUserId } - | { status: "WRONG_CREDENTIALS_ERROR" } - | { status: "INVALID_AUTHENTICATOR_ERROR" } - >; + }): Promise<{ status: "OK"; user: User; recipeUserId: RecipeUserId } | VerifyCredentialsErrorResponse>; // used for retrieving the user details (email) from the recover account token // should be used in the registerOptions function when the user recovers the account and generates the credentials @@ -371,8 +388,45 @@ export type RecipeInterface = { token: string; tenantId: string; userContext: UserContext; + }): Promise<{ status: "OK"; user: User; recipeUserId: RecipeUserId } | GetUserFromRecoverAccountTokenErrorResponse>; + + // credentials CRUD + + // this will call registerCredential internally + addCredential(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"; + }; + tenantId: string; + userContext: UserContext; + }): Promise< + | { + status: "OK"; + } + | AddCredentialErrorResponse + >; + + // credentials CRUD + removeCredential(input: { + webauthnCredentialId: string; + tenantId: string; + userContext: UserContext; }): Promise< - { status: "OK"; user: User; recipeUserId: RecipeUserId } | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } + | { + status: "OK"; + } + | RemoveCredentialErrorResponse >; }; @@ -387,6 +441,51 @@ export type APIOptions = { emailDelivery: EmailDeliveryIngredient; }; +type RegisterOptionsPOSTErrorResponse = + | RegisterOptionsErrorResponse + | { status: "REGISTER_OPTIONS_NOT_ALLOWED"; reason: string }; + +type SignInOptionsPOSTErrorResponse = { status: "SIGN_IN_OPTIONS_NOT_ALLOWED"; reason: string }; + +type SignUpPOSTErrorResponse = + | { + status: "SIGN_UP_NOT_ALLOWED"; + reason: string; + } + | SignUpErrorResponse; + +type SignInPOSTErrorResponse = + | { + status: "SIGN_IN_NOT_ALLOWED"; + reason: string; + } + | SignInErrorResponse; + +type GenerateRecoverAccountTokenPOSTErrorResponse = { + status: "ACCOUNT_RECOVERY_NOT_ALLOWED"; + reason: string; +}; + +type RecoverAccountPOSTErrorResponse = + | { + status: "ACCOUNT_RECOVERY_NOT_ALLOWED"; + reason: string; + } + | ConsumeRecoverAccountTokenErrorResponse; + +type AddCredentialPOSTErrorResponse = + | { + status: "ADD_CREDENTIAL_NOT_ALLOWED"; + reason: string; + } + | AddCredentialErrorResponse; + +type RemoveCredentialPOSTErrorResponse = + | { + status: "REMOVE_CREDENTIAL_NOT_ALLOWED"; + reason: string; + } + | RemoveCredentialErrorResponse; export type APIInterface = { registerOptionsPOST: | undefined @@ -428,8 +527,7 @@ export type APIInterface = { }; } | GeneralErrorResponse - | { status: "EMAIL_MISSING_ERROR" } // email is required if not using session or recoverAccountToken - | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } + | RegisterOptionsPOSTErrorResponse >); signInOptionsPOST: @@ -447,7 +545,7 @@ export type APIInterface = { userVerification: "required" | "preferred" | "discouraged"; } | GeneralErrorResponse - | { status: "SIGN_IN_OPTIONS_NOT_ALLOWED"; reason: string } + | SignInOptionsPOSTErrorResponse >); signUpPOST: @@ -478,14 +576,8 @@ export type APIInterface = { user: User; session: SessionContainerInterface; } - | { - status: "SIGN_UP_NOT_ALLOWED"; - reason: string; - } - | { - status: "EMAIL_ALREADY_EXISTS_ERROR"; - } | GeneralErrorResponse + | SignUpPOSTErrorResponse >); signInPOST: @@ -516,14 +608,8 @@ export type APIInterface = { user: User; session: SessionContainerInterface; } - | { - status: "SIGN_IN_NOT_ALLOWED"; - reason: string; - } - | { - status: "WRONG_CREDENTIALS_ERROR"; - } | GeneralErrorResponse + | SignInPOSTErrorResponse >); generateRecoverAccountTokenPOST: @@ -537,10 +623,7 @@ export type APIInterface = { | { status: "OK"; } - | { - status: "ACCOUNT_RECOVERY_NOT_ALLOWED"; - reason: string; - } + | GenerateRecoverAccountTokenPOSTErrorResponse | GeneralErrorResponse >); @@ -571,9 +654,7 @@ export type APIInterface = { email: string; user: User; } - | { - status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR"; - } + | RecoverAccountPOSTErrorResponse | GeneralErrorResponse >); @@ -592,6 +673,52 @@ export type APIInterface = { } | GeneralErrorResponse >); + + //credentials CRUD + addCredentialPOST: + | 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"; + }; + tenantId: string; + session: SessionContainerInterface; + options: APIOptions; + userContext: UserContext; + }) => Promise< + | { + status: "OK"; + } + | AddCredentialPOSTErrorResponse + | GeneralErrorResponse + >); + + removeCredentialPOST: + | undefined + | ((input: { + webauthnCredentialId: string; + tenantId: string; + session: SessionContainerInterface; + options: APIOptions; + userContext: UserContext; + }) => Promise< + | { + status: "OK"; + } + | RemoveCredentialPOSTErrorResponse + | GeneralErrorResponse + >); }; export type TypeWebauthnRecoverAccountEmailDeliveryInput = { From 64a4a6bb90af4dc78d2c66218aa071a2f8eb867b Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Mon, 21 Oct 2024 16:26:04 +0300 Subject: [PATCH 09/25] pr fixes --- lib/ts/recipe/multitenancy/types.ts | 3 -- lib/ts/recipe/webauthn/types.ts | 69 ++++++++++++++++------------- 2 files changed, 38 insertions(+), 34 deletions(-) diff --git a/lib/ts/recipe/multitenancy/types.ts b/lib/ts/recipe/multitenancy/types.ts index e0d56dff3..3dfb26a7a 100644 --- a/lib/ts/recipe/multitenancy/types.ts +++ b/lib/ts/recipe/multitenancy/types.ts @@ -173,9 +173,6 @@ export type APIInterface = { passwordless: { enabled: boolean; }; - webauthn: { - credentialIds: string[]; - }; firstFactors: string[]; } | GeneralErrorResponse diff --git a/lib/ts/recipe/webauthn/types.ts b/lib/ts/recipe/webauthn/types.ts index c3f93f7bc..205fa6067 100644 --- a/lib/ts/recipe/webauthn/types.ts +++ b/lib/ts/recipe/webauthn/types.ts @@ -111,8 +111,6 @@ type ConsumeRecoverAccountTokenErrorResponse = | RegisterCredentialErrorResponse | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" }; -type AddCredentialErrorResponse = RegisterCredentialErrorResponse; - type RemoveCredentialErrorResponse = { status: "CREDENTIAL_NOT_FOUND_ERROR" }; export type RecipeInterface = { @@ -390,37 +388,9 @@ export type RecipeInterface = { userContext: UserContext; }): Promise<{ status: "OK"; user: User; recipeUserId: RecipeUserId } | GetUserFromRecoverAccountTokenErrorResponse>; - // credentials CRUD - - // this will call registerCredential internally - addCredential(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"; - }; - tenantId: string; - userContext: UserContext; - }): Promise< - | { - status: "OK"; - } - | AddCredentialErrorResponse - >; - // credentials CRUD removeCredential(input: { webauthnCredentialId: string; - tenantId: string; userContext: UserContext; }): Promise< | { @@ -428,6 +398,17 @@ export type RecipeInterface = { } | RemoveCredentialErrorResponse >; + + listCredentials(input: { + userContext: UserContext; + }): Promise<{ + status: "OK"; + credentials: { + id: string; + rp_id: string; + created_at: number; + }[]; + }>; }; export type APIOptions = { @@ -478,7 +459,7 @@ type AddCredentialPOSTErrorResponse = status: "ADD_CREDENTIAL_NOT_ALLOWED"; reason: string; } - | AddCredentialErrorResponse; + | RegisterCredentialErrorResponse; type RemoveCredentialPOSTErrorResponse = | { @@ -486,6 +467,12 @@ type RemoveCredentialPOSTErrorResponse = reason: string; } | RemoveCredentialErrorResponse; + +type ListCredentialsPOSTErrorResponse = { + status: "LIST_CREDENTIALS_NOT_ALLOWED"; + reason: string; +}; + export type APIInterface = { registerOptionsPOST: | undefined @@ -719,6 +706,26 @@ export type APIInterface = { | RemoveCredentialPOSTErrorResponse | GeneralErrorResponse >); + + listCredentialsPOST: + | undefined + | ((input: { + tenantId: string; + session: SessionContainerInterface; + options: APIOptions; + userContext: UserContext; + }) => Promise< + | { + status: "OK"; + credentials: { + id: string; + rp_id: string; + created_at: number; + }[]; + } + | ListCredentialsPOSTErrorResponse + | GeneralErrorResponse + >); }; export type TypeWebauthnRecoverAccountEmailDeliveryInput = { From 3186aa5aa282fa79a3ae2ea2dee76d3db3c1138e Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Mon, 21 Oct 2024 19:27:50 +0300 Subject: [PATCH 10/25] pr fixes and added decode method --- lib/ts/recipe/webauthn/types.ts | 130 ++++++++++++++++++++++++++++++-- 1 file changed, 123 insertions(+), 7 deletions(-) diff --git a/lib/ts/recipe/webauthn/types.ts b/lib/ts/recipe/webauthn/types.ts index 205fa6067..3503aea4c 100644 --- a/lib/ts/recipe/webauthn/types.ts +++ b/lib/ts/recipe/webauthn/types.ts @@ -88,7 +88,7 @@ export type TypeInputGetOrigin = (input: { type RegisterCredentialErrorResponse = | { 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" }; + | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string }; type VerifyCredentialsErrorResponse = | { status: "WRONG_CREDENTIALS_ERROR" } @@ -113,6 +113,10 @@ type ConsumeRecoverAccountTokenErrorResponse = type RemoveCredentialErrorResponse = { status: "CREDENTIAL_NOT_FOUND_ERROR" }; +type DecodeCredentialErrorResponse = { status: "WRONG_CREDENTIALS_ERROR" }; + +type Base64URLString = string; + export type RecipeInterface = { // should have a way to access the user email: passed as a param, through session, or using recoverAccountToken // it should have at least one of those 3 options @@ -139,9 +143,6 @@ export type RecipeInterface = { | { email: string; } - | { - session: SessionContainerInterface; - } ) ): Promise< | { @@ -301,6 +302,78 @@ export type RecipeInterface = { | ConsumeRecoverAccountTokenErrorResponse >; + 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"; + }; + }): Promise< + | { + status: "OK"; + credential: { + id: string; + rawId: string; + response: { + clientDataJSON: { + type: string; + challenge: string; + origin: string; + crossOrigin?: boolean; + tokenBinding?: { + id?: string; + status: "present" | "supported" | "not-supported"; + }; + }; + attestationObject: { + fmt: "packed" | "tpm" | "android-key" | "android-safetynet" | "fido-u2f" | "none"; + authData: { + rpIdHash: string; + flags: { + up: boolean; + uv: boolean; + be: boolean; + bs: boolean; + at: boolean; + ed: boolean; + flagsInt: number; + }; + counter: number; + aaguid?: string; + credentialID?: string; + credentialPublicKey?: string; + extensionsData?: unknown; + }; + attStmt: { + sig?: Base64URLString; + x5c?: Base64URLString[]; + response?: Base64URLString; + alg?: number; + ver?: string; + certInfo?: Base64URLString; + pubArea?: Base64URLString; + size: number; + }; + }; + transports?: ("ble" | "cable" | "hybrid" | "internal" | "nfc" | "smart-card" | "usb")[]; + userHandle: string; + }; + authenticatorAttachment: "platform" | "cross-platform"; + clientExtensionResults: Record; + type: string; + }; + } + | DecodeCredentialErrorResponse + >; + // used internally for creating a credential during account recovery flow or when adding a credential to an existing user // email will be taken from the options // no need for recoverAccountToken, as that will be used upstream @@ -322,11 +395,10 @@ export type RecipeInterface = { }; tenantId: string; userContext: UserContext; + recipeUserId: RecipeUserId; }): Promise< | { status: "OK"; - user: User; - recipeUserId: RecipeUserId; } | RegisterCredentialErrorResponse >; @@ -391,6 +463,7 @@ export type RecipeInterface = { // credentials CRUD removeCredential(input: { webauthnCredentialId: string; + recipeUserId: RecipeUserId; userContext: UserContext; }): Promise< | { @@ -399,7 +472,24 @@ export type RecipeInterface = { | RemoveCredentialErrorResponse >; + getCredential(input: { + webauthnCredentialId: string; + recipeUserId: RecipeUserId; + userContext: UserContext; + }): Promise< + | { + status: "OK"; + credential: { + id: string; + rp_id: string; + created_at: number; + }; + } + | RemoveCredentialErrorResponse + >; + listCredentials(input: { + recipeUserId: RecipeUserId; userContext: UserContext; }): Promise<{ status: "OK"; @@ -473,6 +563,11 @@ type ListCredentialsPOSTErrorResponse = { reason: string; }; +type GetCredentialGETErrorResponse = { + status: "GET_CREDENTIAL_NOT_ALLOWED"; + reason: string; +}; + export type APIInterface = { registerOptionsPOST: | undefined @@ -707,7 +802,7 @@ export type APIInterface = { | GeneralErrorResponse >); - listCredentialsPOST: + listCredentialsGET: | undefined | ((input: { tenantId: string; @@ -726,6 +821,27 @@ export type APIInterface = { | ListCredentialsPOSTErrorResponse | GeneralErrorResponse >); + + getCredentialGET: + | undefined + | ((input: { + webauthnCredentialId: string; + tenantId: string; + session: SessionContainerInterface; + options: APIOptions; + userContext: UserContext; + }) => Promise< + | { + status: "OK"; + credential: { + id: string; + rp_id: string; + created_at: number; + }; + } + | GetCredentialGETErrorResponse + | GeneralErrorResponse + >); }; export type TypeWebauthnRecoverAccountEmailDeliveryInput = { From 3fa31cee027ee06dc052bdcfbdacb6f4cf517343 Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Mon, 28 Oct 2024 08:26:21 +0200 Subject: [PATCH 11/25] added types implementation and minor fixes --- lib/ts/recipe/webauthn/api/emailExists.ts | 51 + .../api/generateRecoverAccountToken.ts | 50 + lib/ts/recipe/webauthn/api/implementation.ts | 1399 +++++++++-------- lib/ts/recipe/webauthn/api/recoverAccount.ts | 72 + lib/ts/recipe/webauthn/api/registerOptions.ts | 10 +- lib/ts/recipe/webauthn/api/signInOptions.ts | 1 + lib/ts/recipe/webauthn/api/signin.ts | 1 - lib/ts/recipe/webauthn/constants.ts | 18 +- lib/ts/recipe/webauthn/index.ts | 600 +++---- lib/ts/recipe/webauthn/recipe.ts | 70 +- .../recipe/webauthn/recipeImplementation.ts | 260 +-- lib/ts/recipe/webauthn/types.ts | 209 +-- lib/ts/recipe/webauthn/utils.ts | 18 +- 13 files changed, 1464 insertions(+), 1295 deletions(-) create mode 100644 lib/ts/recipe/webauthn/api/emailExists.ts create mode 100644 lib/ts/recipe/webauthn/api/generateRecoverAccountToken.ts create mode 100644 lib/ts/recipe/webauthn/api/recoverAccount.ts 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 From 177b58083eee4c182fe892626760f1cef54865b5 Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Mon, 28 Oct 2024 08:34:59 +0200 Subject: [PATCH 12/25] pr fixes --- lib/ts/recipe/webauthn/types.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/lib/ts/recipe/webauthn/types.ts b/lib/ts/recipe/webauthn/types.ts index 5ea126719..b4ec1651e 100644 --- a/lib/ts/recipe/webauthn/types.ts +++ b/lib/ts/recipe/webauthn/types.ts @@ -112,6 +112,8 @@ type ConsumeRecoverAccountTokenErrorResponse = { status: "RECOVER_ACCOUNT_TOKEN_ type RemoveCredentialErrorResponse = { status: "CREDENTIAL_NOT_FOUND_ERROR" }; +type GetCredentialErrorResponse = { status: "CREDENTIAL_NOT_FOUND_ERROR" }; + type DecodeCredentialErrorResponse = { status: "WRONG_CREDENTIALS_ERROR" }; type Base64URLString = string; @@ -394,11 +396,12 @@ export type RecipeInterface = { status: "OK"; credential: { id: string; - rp_id: string; - created_at: number; + rpId: string; + recipeUserId: RecipeUserId; + createdAt: number; }; } - | RemoveCredentialErrorResponse + | GetCredentialErrorResponse >; listCredentials(input: { @@ -408,8 +411,8 @@ export type RecipeInterface = { status: "OK"; credentials: { id: string; - rp_id: string; - created_at: number; + rpId: string; + createdAt: number; }[]; }>; }; From 5d1363ef6c4788ce06532ab767ad60b2b4468559 Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Mon, 28 Oct 2024 14:08:51 +0200 Subject: [PATCH 13/25] pr fixes and cleanup --- lib/ts/recipe/webauthn/api/implementation.ts | 47 +- lib/ts/recipe/webauthn/api/recoverAccount.ts | 8 +- lib/ts/recipe/webauthn/core-mock.ts | 2 +- lib/ts/recipe/webauthn/index.ts | 2 +- lib/ts/recipe/webauthn/types.ts | 426 +++++++++---------- lib/ts/recipe/webauthn/utils.ts | 22 + 6 files changed, 254 insertions(+), 253 deletions(-) diff --git a/lib/ts/recipe/webauthn/api/implementation.ts b/lib/ts/recipe/webauthn/api/implementation.ts index dc87e0e0b..844d9385f 100644 --- a/lib/ts/recipe/webauthn/api/implementation.ts +++ b/lib/ts/recipe/webauthn/api/implementation.ts @@ -65,6 +65,7 @@ export default function getAPIImplementation(): APIInterface { } | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } | { status: "EMAIL_MISSING_ERROR" } + | { status: "REGISTER_OPTIONS_NOT_ALLOWED"; reason: string } > { const relyingPartyId = await options.config.relyingPartyId({ tenantId, @@ -543,7 +544,7 @@ export default function getAPIImplementation(): APIInterface { | { status: "OK"; } - | { status: "ACCOUNT_RECOVERY_NOT_ALLOWED"; reason: string } + | { status: "RECOVER_ACCOUNT_NOT_ALLOWED"; reason: string } | GeneralErrorResponse > { // NOTE: Check for email being a non-string value. This check will likely @@ -562,7 +563,7 @@ export default function getAPIImplementation(): APIInterface { | { status: "OK"; } - | { status: "ACCOUNT_RECOVERY_NOT_ALLOWED"; reason: string } + | { status: "RECOVER_ACCOUNT_NOT_ALLOWED"; reason: string } | GeneralErrorResponse > { // the user ID here can be primary or recipe level. @@ -575,7 +576,7 @@ export default function getAPIImplementation(): APIInterface { if (response.status === "UNKNOWN_USER_ID_ERROR") { logDebugMessage( - `Account recovery email not sent, unknown user id: ${ + `Recover account email not sent, unknown user id: ${ recipeUserId === undefined ? primaryUserId : recipeUserId.getAsString() }` ); @@ -592,7 +593,7 @@ export default function getAPIImplementation(): APIInterface { userContext, }); - logDebugMessage(`Sending account recovery email to ${email}`); + logDebugMessage(`Sending recover account email to ${email}`); await options.emailDelivery.ingredientInterfaceImpl.sendEmail({ tenantId, type: "RECOVER_ACCOUNT", @@ -639,10 +640,10 @@ export default function getAPIImplementation(): APIInterface { 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 not, then we do the regular flow for recover account if (primaryUserAssociatedWithEmail === undefined) { if (webauthnAccount === undefined) { - logDebugMessage(`Account recovery email not sent, unknown user email: ${email}`); + logDebugMessage(`Recover account email not sent, unknown user email: ${email}`); return { status: "OK", }; @@ -675,9 +676,9 @@ export default function getAPIImplementation(): APIInterface { if (!emailVerified && hasOtherEmailOrPhone) { return { - status: "ACCOUNT_RECOVERY_NOT_ALLOWED", + status: "RECOVER_ACCOUNT_NOT_ALLOWED", reason: - "Account recovery link was not created because of account take over risk. Please contact support. (ERR_CODE_001)", + "Recover account link was not created because of account take over risk. Please contact support. (ERR_CODE_001)", }; } @@ -704,12 +705,12 @@ export default function getAPIImplementation(): APIInterface { // 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 + // not, then the new webauthn user that will be created in recover account // code consume cannot be linked to the primary user - therefore, we should - // not generate a account recovery reset token + // not generate a recover account reset token if (!shouldDoAccountLinkingResponse.shouldAutomaticallyLink) { logDebugMessage( - `Account recovery email not sent, since webauthn user didn't exist, and account linking not enabled` + `Recover account email not sent, since webauthn user didn't exist, and account linking not enabled` ); return { status: "OK", @@ -733,7 +734,7 @@ export default function getAPIImplementation(): APIInterface { return await generateAndSendRecoverAccountToken(primaryUserAssociatedWithEmail.id, undefined); } else { logDebugMessage( - `Account recovery email not sent, isSignUpAllowed returned false for email: ${email}` + `Recover account email not sent, isSignUpAllowed returned false for email: ${email}` ); return { status: "OK", @@ -772,7 +773,7 @@ export default function getAPIImplementation(): APIInterface { 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 + 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 recover account 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.. @@ -801,7 +802,7 @@ export default function getAPIImplementation(): APIInterface { webauthnAccount.recipeUserId ); }, - recoverAccountTokenPOST: async function ({ + recoverAccountPOST: async function ({ webauthnGeneratedOptionsId, credential, token, @@ -891,10 +892,10 @@ export default function getAPIImplementation(): APIInterface { // 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) + // We do this because we assume that the recover account 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: + // We only do this if the recover account 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 @@ -903,7 +904,7 @@ export default function getAPIImplementation(): APIInterface { // 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"); + throw new Error("Should never happen - user deleted after during recover account"); } if (updatedUserAfterEmailVerification.isPrimaryUser) { @@ -948,10 +949,6 @@ export default function getAPIImplementation(): APIInterface { // 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; @@ -984,7 +981,7 @@ export default function getAPIImplementation(): APIInterface { // 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 + // after the recover account 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. @@ -1009,7 +1006,7 @@ export default function getAPIImplementation(): APIInterface { // 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. + // cause we already have asked them this when generating an recover account 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 @@ -1034,7 +1031,7 @@ export default function getAPIImplementation(): APIInterface { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR", }; } else { - // we mark the email as verified because account recovery also requires + // we mark the email as verified because recover account 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. @@ -1044,7 +1041,7 @@ export default function getAPIImplementation(): APIInterface { ); const updatedUser = await getUser(createUserResponse.user.id, userContext); if (updatedUser === undefined) { - throw new Error("Should never happen - user deleted after during account recovery"); + throw new Error("Should never happen - user deleted after during recover account"); } createUserResponse.user = updatedUser; // Now we try and link the accounts. The function below will try and also diff --git a/lib/ts/recipe/webauthn/api/recoverAccount.ts b/lib/ts/recipe/webauthn/api/recoverAccount.ts index 9475beb36..dfbc2a476 100644 --- a/lib/ts/recipe/webauthn/api/recoverAccount.ts +++ b/lib/ts/recipe/webauthn/api/recoverAccount.ts @@ -27,7 +27,7 @@ export default async function recoverAccount( ): Promise { // Logic as per https://github.com/supertokens/supertokens-node/issues/22#issuecomment-710512442 - if (apiImplementation.recoverAccountTokenPOST === undefined) { + if (apiImplementation.recoverAccountPOST === undefined) { return false; } @@ -41,17 +41,17 @@ export default async function recoverAccount( if (token === undefined) { throw new STError({ type: STError.BAD_INPUT_ERROR, - message: "Please provide the account recovery token", + message: "Please provide the recover account token", }); } if (typeof token !== "string") { throw new STError({ type: STError.BAD_INPUT_ERROR, - message: "The account recovery token must be a string", + message: "The recover account token must be a string", }); } - let result = await apiImplementation.recoverAccountTokenPOST({ + let result = await apiImplementation.recoverAccountPOST({ webauthnGeneratedOptionsId, credential, token, diff --git a/lib/ts/recipe/webauthn/core-mock.ts b/lib/ts/recipe/webauthn/core-mock.ts index f6e725433..7ee65cb53 100644 --- a/lib/ts/recipe/webauthn/core-mock.ts +++ b/lib/ts/recipe/webauthn/core-mock.ts @@ -41,7 +41,7 @@ export const getMockQuerier = (recipeId: string) => { // // @ts-ignore // return { // status: "OK", - // token: "dummy-recovery-token", + // token: "dummy-recover-token", // }; // } else if (path.getAsStringDangerous().includes("/recipe/webauthn/user/recover/token/consume")) { // // @ts-ignore diff --git a/lib/ts/recipe/webauthn/index.ts b/lib/ts/recipe/webauthn/index.ts index 932ef4997..9e53ff0d9 100644 --- a/lib/ts/recipe/webauthn/index.ts +++ b/lib/ts/recipe/webauthn/index.ts @@ -29,7 +29,6 @@ import { 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; @@ -78,6 +77,7 @@ export default class Wrapper { } | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } | { status: "EMAIL_MISSING_ERROR" } + | { status: "INVALID_EMAIL_ERROR" } > { return Recipe.getInstanceOrThrowError().recipeInterfaceImpl.registerOptions({ requireResidentKey: DEFAULT_REGISTER_OPTIONS_REQUIRE_RESIDENT_KEY, diff --git a/lib/ts/recipe/webauthn/types.ts b/lib/ts/recipe/webauthn/types.ts index b4ec1651e..d30fbbb33 100644 --- a/lib/ts/recipe/webauthn/types.ts +++ b/lib/ts/recipe/webauthn/types.ts @@ -31,6 +31,7 @@ export type TypeNormalisedInput = { getEmailDeliveryConfig: ( isInServerlessEnv: boolean ) => EmailDeliveryTypeInputWithService; + validateEmailAddress: TypeNormalisedInputValidateEmailAddress; override: { functions: ( originalImplementation: RecipeInterface, @@ -57,10 +58,16 @@ export type TypeNormalisedInputGetOrigin = (input: { userContext: UserContext; }) => Promise; // should return the app name +export type TypeNormalisedInputValidateEmailAddress = ( + email: string, + tenantId: string +) => Promise | string | undefined; + export type TypeInput = { emailDelivery?: EmailDeliveryTypeInput; relyingPartyId?: TypeInputRelyingPartyId; relyingPartyName?: TypeInputRelyingPartyName; + validateEmailAddress?: TypeInputValidateEmailAddress; getOrigin?: TypeInputGetOrigin; override?: { functions?: ( @@ -85,36 +92,52 @@ export type TypeInputGetOrigin = (input: { userContext: UserContext; }) => Promise; +export type TypeInputValidateEmailAddress = ( + email: string, + tenantId: string +) => Promise | string | undefined; + // centralize error types in order to prevent missing cascading errors -type RegisterCredentialErrorResponse = +type RegisterOptionsErrorResponse = + | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } + | { status: "INVALID_EMAIL_ERROR" }; + +type SignInOptionsErrorResponse = { status: "WRONG_CREDENTIALS_ERROR" } | { status: "INVALID_EMAIL_ERROR" }; + +type SignUpErrorResponse = + | { status: "EMAIL_ALREADY_EXISTS_ERROR" } | { 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 }; -type VerifyCredentialsErrorResponse = - | { status: "WRONG_CREDENTIALS_ERROR" } - // when the attestation is checked and is not valid or other cases in which the authenticator is not correct - | { status: "INVALID_AUTHENTICATOR_ERROR" }; +type SignInErrorResponse = { status: "WRONG_CREDENTIALS_ERROR" }; -type CreateNewRecipeUserErrorResponse = RegisterCredentialErrorResponse | { status: "EMAIL_ALREADY_EXISTS_ERROR" }; +type VerifyCredentialsErrorResponse = { status: "WRONG_CREDENTIALS_ERROR" }; -type GetUserFromRecoverAccountTokenErrorResponse = { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" }; +type GenerateRecoverAccountTokenErrorResponse = { status: "UNKNOWN_USER_ID_ERROR" } | { status: "INVALID_EMAIL_ERROR" }; -type RegisterOptionsErrorResponse = GetUserFromRecoverAccountTokenErrorResponse | { status: "EMAIL_MISSING_ERROR" }; +type ConsumeRecoverAccountTokenErrorResponse = { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" }; -type SignUpErrorResponse = CreateNewRecipeUserErrorResponse; +type RegisterCredentialErrorResponse = + | { 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 }; -type SignInErrorResponse = VerifyCredentialsErrorResponse; +type CreateNewRecipeUserErrorResponse = + | { status: "WRONG_CREDENTIALS_ERROR" } + | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string } + | { status: "EMAIL_ALREADY_EXISTS_ERROR" }; -type GenerateRecoverAccountTokenErrorResponse = { status: "UNKNOWN_USER_ID_ERROR" }; +type DecodeCredentialErrorResponse = { status: "WRONG_CREDENTIALS_ERROR" }; -type ConsumeRecoverAccountTokenErrorResponse = { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" }; +type GetUserFromRecoverAccountTokenErrorResponse = { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" }; type RemoveCredentialErrorResponse = { status: "CREDENTIAL_NOT_FOUND_ERROR" }; type GetCredentialErrorResponse = { status: "CREDENTIAL_NOT_FOUND_ERROR" }; -type DecodeCredentialErrorResponse = { status: "WRONG_CREDENTIALS_ERROR" }; +type RemoveGeneratedOptionsErrorResponse = { status: "GENERATED_OPTIONS_NOT_FOUND_ERROR" }; + +type GetGeneratedOptionsErrorResponse = { status: "GENERATED_OPTIONS_NOT_FOUND_ERROR" }; type Base64URLString = string; @@ -177,23 +200,30 @@ export type RecipeInterface = { userVerification: "required" | "preferred" | "discouraged"; }; } - | RegisterOptionsErrorResponse + // | RegisterOptionsErrorResponse + | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } + | { status: "INVALID_EMAIL_ERROR" } >; signInOptions(input: { + email?: string; relyingPartyId: string; origin: string; userVerification: "required" | "preferred" | "discouraged" | undefined; // see register options timeout: number | undefined; tenantId: string; userContext: UserContext; - }): Promise<{ - status: "OK"; - webauthnGeneratedOptionsId: string; - challenge: string; - timeout: number; - userVerification: "required" | "preferred" | "discouraged"; - }>; + }): Promise< + | { + status: "OK"; + webauthnGeneratedOptionsId: string; + challenge: string; + timeout: number; + userVerification: "required" | "preferred" | "discouraged"; + } + // | SignInOptionsErrorResponse + | { status: "WRONG_CREDENTIALS_ERROR" } + >; signUp(input: { webauthnGeneratedOptionsId: string; @@ -208,7 +238,10 @@ export type RecipeInterface = { user: User; recipeUserId: RecipeUserId; } - | SignUpErrorResponse + // | SignUpErrorResponse + | { status: "EMAIL_ALREADY_EXISTS_ERROR" } + | { status: "WRONG_CREDENTIALS_ERROR" } + | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string } | { status: "LINKING_TO_SESSION_USER_FAILED"; reason: @@ -228,7 +261,8 @@ export type RecipeInterface = { userContext: UserContext; }): Promise< | { status: "OK"; user: User; recipeUserId: RecipeUserId } - | SignInErrorResponse + // | SignInErrorResponse + | { status: "WRONG_CREDENTIALS_ERROR" } | { status: "LINKING_TO_SESSION_USER_FAILED"; reason: @@ -239,6 +273,17 @@ export type RecipeInterface = { } >; + verifyCredentials(input: { + webauthnGeneratedOptionsId: string; + credential: CredentialPayload; + tenantId: string; + userContext: UserContext; + }): Promise< + | { status: "OK"; user: User; recipeUserId: RecipeUserId } + // | VerifyCredentialsErrorResponse + | { status: "WRONG_CREDENTIALS_ERROR" } + >; + /** * We pass in the email as well to this function cause the input userId * may not be associated with an webauthn account. In this case, we @@ -249,7 +294,12 @@ export type RecipeInterface = { email: string; tenantId: string; userContext: UserContext; - }): Promise<{ status: "OK"; token: string } | GenerateRecoverAccountTokenErrorResponse>; + }): Promise< + | { status: "OK"; token: string } + // | GenerateRecoverAccountTokenErrorResponse + | { status: "UNKNOWN_USER_ID_ERROR" } + | { status: "INVALID_EMAIL_ERROR" } + >; // make sure the email maps to options email consumeRecoverAccountToken(input: { @@ -262,7 +312,48 @@ export type RecipeInterface = { email: string; userId: string; } - | ConsumeRecoverAccountTokenErrorResponse + // | ConsumeRecoverAccountTokenErrorResponse + | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } + >; + + // used internally for creating a credential during recover account flow or when adding a credential to an existing user + // email will be taken from the options + // no need for recoverAccountToken, as that will be used upstream + // (in consumeRecoverAccountToken invalidating the token and in registerOptions for storing the email in the generated options) + registerCredential(input: { + webauthnGeneratedOptionsId: string; + credential: CredentialPayload; + tenantId: string; + userContext: UserContext; + recipeUserId: RecipeUserId; + }): Promise< + | { + status: "OK"; + } + // | RegisterCredentialErrorResponse + | { status: "WRONG_CREDENTIALS_ERROR" } + | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string } + >; + + // this function is meant only for creating the recipe in the core and nothing else. + // we added this even though signUp exists cause devs may override signup expecting it + // to be called just during sign up. But we also need a version of signing up which can be + // called during operations like creating a user during password reset flow. + createNewRecipeUser(input: { + webauthnGeneratedOptionsId: string; + credential: CredentialPayload; + tenantId: string; + userContext: UserContext; + }): Promise< + | { + status: "OK"; + user: User; + recipeUserId: RecipeUserId; + } + // | CreateNewRecipeUserErrorResponse + | { status: "WRONG_CREDENTIALS_ERROR" } + | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string } + | { status: "EMAIL_ALREADY_EXISTS_ERROR" } >; decodeCredential(input: { @@ -322,58 +413,21 @@ export type RecipeInterface = { type: string; }; } - | DecodeCredentialErrorResponse - >; - - // used internally for creating a credential during account recovery flow or when adding a credential to an existing user - // email will be taken from the options - // no need for recoverAccountToken, as that will be used upstream - // (in consumeRecoverAccountToken invalidating the token and in registerOptions for storing the email in the generated options) - registerCredential(input: { - webauthnGeneratedOptionsId: string; - credential: CredentialPayload; - tenantId: string; - userContext: UserContext; - recipeUserId: RecipeUserId; - }): Promise< - | { - status: "OK"; - } - | RegisterCredentialErrorResponse + // | DecodeCredentialErrorResponse + | { status: "WRONG_CREDENTIALS_ERROR" } >; - // this function is meant only for creating the recipe in the core and nothing else. - // we added this even though signUp exists cause devs may override signup expecting it - // to be called just during sign up. But we also need a version of signing up which can be - // called during operations like creating a user during password reset flow. - createNewRecipeUser(input: { - webauthnGeneratedOptionsId: string; - credential: CredentialPayload; - tenantId: string; - userContext: UserContext; - }): Promise< - | { - status: "OK"; - user: User; - recipeUserId: RecipeUserId; - } - | CreateNewRecipeUserErrorResponse - >; - - verifyCredentials(input: { - webauthnGeneratedOptionsId: string; - credential: CredentialPayload; - tenantId: string; - userContext: UserContext; - }): Promise<{ status: "OK"; user: User; recipeUserId: RecipeUserId } | VerifyCredentialsErrorResponse>; - // used for retrieving the user details (email) from the recover account token // should be used in the registerOptions function when the user recovers the account and generates the credentials getUserFromRecoverAccountToken(input: { token: string; tenantId: string; userContext: UserContext; - }): Promise<{ status: "OK"; user: User; recipeUserId: RecipeUserId } | GetUserFromRecoverAccountTokenErrorResponse>; + }): Promise< + | { status: "OK"; user: User; recipeUserId: RecipeUserId } + // | GetUserFromRecoverAccountTokenErrorResponse + | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } + >; // credentials CRUD removeCredential(input: { @@ -384,7 +438,8 @@ export type RecipeInterface = { | { status: "OK"; } - | RemoveCredentialErrorResponse + // | RemoveCredentialErrorResponse + | { status: "CREDENTIAL_NOT_FOUND_ERROR" } >; getCredential(input: { @@ -396,12 +451,13 @@ export type RecipeInterface = { status: "OK"; credential: { id: string; - rpId: string; + relyingPartyId: string; recipeUserId: RecipeUserId; createdAt: number; }; } - | GetCredentialErrorResponse + // | GetCredentialErrorResponse + | { status: "CREDENTIAL_NOT_FOUND_ERROR" } >; listCredentials(input: { @@ -411,10 +467,42 @@ export type RecipeInterface = { status: "OK"; credentials: { id: string; - rpId: string; + relyingPartyId: string; createdAt: number; }[]; }>; + + // Generated options CRUD + removeGeneratedOptions(input: { + webauthnGeneratedOptionsId: string; + tenantId: string; + userContext: UserContext; + }): Promise< + | { status: "OK" } + // | RemoveGeneratedOptionsErrorResponse + | { status: "GENERATED_OPTIONS_NOT_FOUND_ERROR" } + >; + + getGeneratedOptions(input: { + webauthnGeneratedOptionsId: string; + tenantId: string; + userContext: UserContext; + }): Promise< + | { + status: "OK"; + id: string; + relyingPartyId: string; + relyingPartyName: string; + origin: string; + userName: string; + userDisplayName: string; + timeout: string; + attestation: string; + challenge: string; + } + // | GetGeneratedOptionsErrorResponse + | { status: "GENERATED_OPTIONS_NOT_FOUND_ERROR" } + >; }; export type APIOptions = { @@ -429,67 +517,34 @@ export type APIOptions = { }; type RegisterOptionsPOSTErrorResponse = - | RegisterOptionsErrorResponse - | { status: "REGISTER_OPTIONS_NOT_ALLOWED"; reason: string }; + | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } + | { status: "INVALID_EMAIL_ERROR" }; -type SignInOptionsPOSTErrorResponse = { status: "SIGN_IN_OPTIONS_NOT_ALLOWED"; reason: string }; +type SignInOptionsPOSTErrorResponse = { status: "WRONG_CREDENTIALS_ERROR" } | { status: "INVALID_EMAIL_ERROR" }; type SignUpPOSTErrorResponse = | { status: "SIGN_UP_NOT_ALLOWED"; reason: string; } - | SignUpErrorResponse; - -type SignInPOSTErrorResponse = - | { - status: "SIGN_IN_NOT_ALLOWED"; - reason: string; - } - | SignInErrorResponse; - -type GenerateRecoverAccountTokenPOSTErrorResponse = { - status: "ACCOUNT_RECOVERY_NOT_ALLOWED"; - reason: string; -}; - -type RecoverAccountPOSTErrorResponse = - | { - status: "ACCOUNT_RECOVERY_NOT_ALLOWED"; - reason: string; - } - | ConsumeRecoverAccountTokenErrorResponse; + | { status: "EMAIL_ALREADY_EXISTS_ERROR" } + | { status: "WRONG_CREDENTIALS_ERROR" } + | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string }; -type AddCredentialPOSTErrorResponse = - | { - status: "ADD_CREDENTIAL_NOT_ALLOWED"; - reason: string; - } - | RegisterCredentialErrorResponse; +type SignInPOSTErrorResponse = { status: "WRONG_CREDENTIALS_ERROR" }; -type RemoveCredentialPOSTErrorResponse = +type GenerateRecoverAccountTokenPOSTErrorResponse = | { - status: "REMOVE_CREDENTIAL_NOT_ALLOWED"; + status: "RECOVER_ACCOUNT_NOT_ALLOWED"; reason: string; } - | RemoveCredentialErrorResponse; - -type ListCredentialsPOSTErrorResponse = { - status: "LIST_CREDENTIALS_NOT_ALLOWED"; - reason: string; -}; - -type GetCredentialGETErrorResponse = { - status: "GET_CREDENTIAL_NOT_ALLOWED"; - reason: string; -}; + | { status: "UNKNOWN_USER_ID_ERROR" } + | { status: "INVALID_EMAIL_ERROR" }; -type RecoverAccountTokenPOSTErrorResponse = - | { - status: "CONSUME_RECOVER_ACCOUNT_TOKEN_NOT_ALLOWED"; - reason: string; - } - | ConsumeRecoverAccountTokenErrorResponse; +type RecoverAccountPOSTErrorResponse = + | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } + | { status: "WRONG_CREDENTIALS_ERROR" } + | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string }; export type APIInterface = { registerOptionsPOST: @@ -532,12 +587,15 @@ export type APIInterface = { }; } | GeneralErrorResponse - | RegisterOptionsPOSTErrorResponse + // | RegisterOptionsPOSTErrorResponse + | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } + | { status: "INVALID_EMAIL_ERROR" } >); signInOptionsPOST: | undefined | ((input: { + email?: string; tenantId: string; options: APIOptions; userContext: UserContext; @@ -550,13 +608,14 @@ export type APIInterface = { userVerification: "required" | "preferred" | "discouraged"; } | GeneralErrorResponse - | SignInOptionsPOSTErrorResponse + // | SignInOptionsPOSTErrorResponse + | { status: "WRONG_CREDENTIALS_ERROR" } + | { status: "INVALID_EMAIL_ERROR" } >); signUpPOST: | undefined | ((input: { - email: string; webauthnGeneratedOptionsId: string; credential: CredentialPayload; tenantId: string; @@ -572,7 +631,14 @@ export type APIInterface = { session: SessionContainerInterface; } | GeneralErrorResponse - | SignUpPOSTErrorResponse + // | SignUpPOSTErrorResponse + | { + status: "SIGN_UP_NOT_ALLOWED"; + reason: string; + } + | { status: "EMAIL_ALREADY_EXISTS_ERROR" } + | { status: "WRONG_CREDENTIALS_ERROR" } + | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string } >); signInPOST: @@ -592,7 +658,8 @@ export type APIInterface = { session: SessionContainerInterface; } | GeneralErrorResponse - | SignInPOSTErrorResponse + // | SignInPOSTErrorResponse + | { status: "WRONG_CREDENTIALS_ERROR" } >); generateRecoverAccountTokenPOST: @@ -606,30 +673,17 @@ export type APIInterface = { | { status: "OK"; } - | GenerateRecoverAccountTokenPOSTErrorResponse | GeneralErrorResponse - >); - - recoverAccountPOST: - | undefined - | ((input: { - webauthnGeneratedOptionsId: string; - credential: CredentialPayload; - token: string; - tenantId: string; - options: APIOptions; - userContext: UserContext; - }) => Promise< + // | GenerateRecoverAccountTokenPOSTErrorResponse | { - status: "OK"; - email: string; - user: User; + status: "RECOVER_ACCOUNT_NOT_ALLOWED"; + reason: string; } - | RecoverAccountPOSTErrorResponse - | GeneralErrorResponse + | { status: "UNKNOWN_USER_ID_ERROR" } + | { status: "INVALID_EMAIL_ERROR" } >); - recoverAccountTokenPOST: + recoverAccountPOST: | undefined | ((input: { token: string; @@ -645,7 +699,10 @@ export type APIInterface = { email: string; } | GeneralErrorResponse - | RecoverAccountTokenPOSTErrorResponse + // | RecoverAccountPOSTErrorResponse + | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } + | { status: "WRONG_CREDENTIALS_ERROR" } + | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string } >); // used for checking if the email already exists before generating the credential @@ -663,81 +720,6 @@ export type APIInterface = { } | GeneralErrorResponse >); - - //credentials CRUD - addCredentialPOST: - | undefined - | ((input: { - webauthnGeneratedOptionsId: string; - credential: CredentialPayload; - tenantId: string; - session: SessionContainerInterface; - options: APIOptions; - userContext: UserContext; - }) => Promise< - | { - status: "OK"; - } - | AddCredentialPOSTErrorResponse - | GeneralErrorResponse - >); - - removeCredentialPOST: - | undefined - | ((input: { - webauthnCredentialId: string; - tenantId: string; - session: SessionContainerInterface; - options: APIOptions; - userContext: UserContext; - }) => Promise< - | { - status: "OK"; - } - | RemoveCredentialPOSTErrorResponse - | GeneralErrorResponse - >); - - listCredentialsGET: - | undefined - | ((input: { - tenantId: string; - session: SessionContainerInterface; - options: APIOptions; - userContext: UserContext; - }) => Promise< - | { - status: "OK"; - credentials: { - id: string; - rp_id: string; - created_at: number; - }[]; - } - | ListCredentialsPOSTErrorResponse - | GeneralErrorResponse - >); - - getCredentialGET: - | undefined - | ((input: { - webauthnCredentialId: string; - tenantId: string; - session: SessionContainerInterface; - options: APIOptions; - userContext: UserContext; - }) => Promise< - | { - status: "OK"; - credential: { - id: string; - rp_id: string; - created_at: number; - }; - } - | GetCredentialGETErrorResponse - | GeneralErrorResponse - >); }; export type TypeWebauthnRecoverAccountEmailDeliveryInput = { diff --git a/lib/ts/recipe/webauthn/utils.ts b/lib/ts/recipe/webauthn/utils.ts index f58fc2e4d..9f94448c0 100644 --- a/lib/ts/recipe/webauthn/utils.ts +++ b/lib/ts/recipe/webauthn/utils.ts @@ -19,10 +19,12 @@ import { TypeInputGetOrigin, TypeInputRelyingPartyId, TypeInputRelyingPartyName, + TypeInputValidateEmailAddress, TypeNormalisedInput, TypeNormalisedInputGetOrigin, TypeNormalisedInputRelyingPartyId, TypeNormalisedInputRelyingPartyName, + TypeNormalisedInputValidateEmailAddress, } from "./types"; import { NormalisedAppinfo, UserContext } from "../../types"; import { RecipeInterface, APIInterface } from "./types"; @@ -40,6 +42,11 @@ export function validateAndNormaliseUserInput( config?.relyingPartyName ); let getOrigin = validateAndNormaliseGetOriginConfig(recipeInstance, appInfo, config?.getOrigin); + let validateEmailAddress = validateAndNormaliseValidateEmailAddressConfig( + recipeInstance, + appInfo, + config?.validateEmailAddress + ); let override = { functions: (originalImplementation: RecipeInterface) => originalImplementation, @@ -78,6 +85,7 @@ export function validateAndNormaliseUserInput( getOrigin, relyingPartyId, relyingPartyName, + validateEmailAddress, getEmailDeliveryConfig, }; } @@ -132,6 +140,20 @@ function validateAndNormaliseGetOriginConfig( }; } +function validateAndNormaliseValidateEmailAddressConfig( + _: Recipe, + __: NormalisedAppinfo, + validateEmailAddressConfig: TypeInputValidateEmailAddress | undefined +): TypeNormalisedInputValidateEmailAddress { + return (email, tenantId) => { + if (typeof validateEmailAddressConfig === "function") { + return validateEmailAddressConfig(email, tenantId); + } else { + return defaultEmailValidator(email); + } + }; +} + export async function defaultEmailValidator(value: any) { // We check if the email syntax is correct // As per https://github.com/supertokens/supertokens-auth-react/issues/5#issuecomment-709512438 From 57e210d71b8455180e4def45f2ebc4577d686127 Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Mon, 28 Oct 2024 16:56:06 +0200 Subject: [PATCH 14/25] pr fixes --- lib/ts/recipe/webauthn/api/registerOptions.ts | 14 +++++++- lib/ts/recipe/webauthn/types.ts | 35 ++++++------------- 2 files changed, 24 insertions(+), 25 deletions(-) diff --git a/lib/ts/recipe/webauthn/api/registerOptions.ts b/lib/ts/recipe/webauthn/api/registerOptions.ts index 1eb7e4da1..d2f1b1e58 100644 --- a/lib/ts/recipe/webauthn/api/registerOptions.ts +++ b/lib/ts/recipe/webauthn/api/registerOptions.ts @@ -30,7 +30,7 @@ export default async function registerOptions( const requestBody = await options.req.getJSONBody(); - let email = requestBody.email; + let email = requestBody.email?.trim(); let recoverAccountToken = requestBody.recoverAccountToken; if ( @@ -43,6 +43,18 @@ export default async function registerOptions( }); } + // same as for passwordless lib/ts/recipe/passwordless/api/createCode.ts + if (email !== undefined) { + const validateError = await options.config.validateEmailAddress(email, tenantId); + if (validateError !== undefined) { + send200Response(options.res, { + status: "INVALID_EMAIL_ERROR", + err: validateError, + }); + return true; + } + } + let result = await apiImplementation.registerOptionsPOST({ email, recoverAccountToken, diff --git a/lib/ts/recipe/webauthn/types.ts b/lib/ts/recipe/webauthn/types.ts index d30fbbb33..f2b24efbd 100644 --- a/lib/ts/recipe/webauthn/types.ts +++ b/lib/ts/recipe/webauthn/types.ts @@ -98,11 +98,9 @@ export type TypeInputValidateEmailAddress = ( ) => Promise | string | undefined; // centralize error types in order to prevent missing cascading errors -type RegisterOptionsErrorResponse = - | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } - | { status: "INVALID_EMAIL_ERROR" }; +type RegisterOptionsErrorResponse = { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" }; -type SignInOptionsErrorResponse = { status: "WRONG_CREDENTIALS_ERROR" } | { status: "INVALID_EMAIL_ERROR" }; +type SignInOptionsErrorResponse = { status: "WRONG_CREDENTIALS_ERROR" }; type SignUpErrorResponse = | { status: "EMAIL_ALREADY_EXISTS_ERROR" } @@ -113,7 +111,7 @@ type SignInErrorResponse = { status: "WRONG_CREDENTIALS_ERROR" }; type VerifyCredentialsErrorResponse = { status: "WRONG_CREDENTIALS_ERROR" }; -type GenerateRecoverAccountTokenErrorResponse = { status: "UNKNOWN_USER_ID_ERROR" } | { status: "INVALID_EMAIL_ERROR" }; +type GenerateRecoverAccountTokenErrorResponse = { status: "UNKNOWN_USER_ID_ERROR" }; type ConsumeRecoverAccountTokenErrorResponse = { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" }; @@ -202,7 +200,6 @@ export type RecipeInterface = { } // | RegisterOptionsErrorResponse | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } - | { status: "INVALID_EMAIL_ERROR" } >; signInOptions(input: { @@ -298,7 +295,6 @@ export type RecipeInterface = { | { status: "OK"; token: string } // | GenerateRecoverAccountTokenErrorResponse | { status: "UNKNOWN_USER_ID_ERROR" } - | { status: "INVALID_EMAIL_ERROR" } >; // make sure the email maps to options email @@ -492,12 +488,9 @@ export type RecipeInterface = { status: "OK"; id: string; relyingPartyId: string; - relyingPartyName: string; origin: string; - userName: string; - userDisplayName: string; + email: string; timeout: string; - attestation: string; challenge: string; } // | GetGeneratedOptionsErrorResponse @@ -518,9 +511,9 @@ export type APIOptions = { type RegisterOptionsPOSTErrorResponse = | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } - | { status: "INVALID_EMAIL_ERROR" }; + | { status: "INVALID_EMAIL_ERROR"; err: string }; -type SignInOptionsPOSTErrorResponse = { status: "WRONG_CREDENTIALS_ERROR" } | { status: "INVALID_EMAIL_ERROR" }; +type SignInOptionsPOSTErrorResponse = { status: "WRONG_CREDENTIALS_ERROR" }; type SignUpPOSTErrorResponse = | { @@ -533,13 +526,10 @@ type SignUpPOSTErrorResponse = type SignInPOSTErrorResponse = { status: "WRONG_CREDENTIALS_ERROR" }; -type GenerateRecoverAccountTokenPOSTErrorResponse = - | { - status: "RECOVER_ACCOUNT_NOT_ALLOWED"; - reason: string; - } - | { status: "UNKNOWN_USER_ID_ERROR" } - | { status: "INVALID_EMAIL_ERROR" }; +type GenerateRecoverAccountTokenPOSTErrorResponse = { + status: "RECOVER_ACCOUNT_NOT_ALLOWED"; + reason: string; +}; type RecoverAccountPOSTErrorResponse = | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } @@ -589,7 +579,7 @@ export type APIInterface = { | GeneralErrorResponse // | RegisterOptionsPOSTErrorResponse | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } - | { status: "INVALID_EMAIL_ERROR" } + | { status: "INVALID_EMAIL_ERROR"; err: string } >); signInOptionsPOST: @@ -610,7 +600,6 @@ export type APIInterface = { | GeneralErrorResponse // | SignInOptionsPOSTErrorResponse | { status: "WRONG_CREDENTIALS_ERROR" } - | { status: "INVALID_EMAIL_ERROR" } >); signUpPOST: @@ -679,8 +668,6 @@ export type APIInterface = { status: "RECOVER_ACCOUNT_NOT_ALLOWED"; reason: string; } - | { status: "UNKNOWN_USER_ID_ERROR" } - | { status: "INVALID_EMAIL_ERROR" } >); recoverAccountPOST: From ea42bd102d353c33031a535a175483e095bc1926 Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Tue, 29 Oct 2024 15:56:08 +0200 Subject: [PATCH 15/25] updated initial recipe implementation --- lib/ts/recipe/webauthn/api/implementation.ts | 3 + lib/ts/recipe/webauthn/constants.ts | 1 + .../recipe/webauthn/recipeImplementation.ts | 424 ++++++++---------- lib/ts/recipe/webauthn/types.ts | 57 +-- 4 files changed, 224 insertions(+), 261 deletions(-) diff --git a/lib/ts/recipe/webauthn/api/implementation.ts b/lib/ts/recipe/webauthn/api/implementation.ts index 844d9385f..cdf15a290 100644 --- a/lib/ts/recipe/webauthn/api/implementation.ts +++ b/lib/ts/recipe/webauthn/api/implementation.ts @@ -13,6 +13,7 @@ import { DEFAULT_REGISTER_OPTIONS_USER_VERIFICATION, DEFAULT_SIGNIN_OPTIONS_TIMEOUT, DEFAULT_SIGNIN_OPTIONS_USER_VERIFICATION, + DEFAULT_REGISTER_OPTIONS_SUPPORTED_ALGORITHM_IDS, } from "../constants"; import RecipeUserId from "../../../recipeUserId"; import { getRecoverAccountLink } from "../utils"; @@ -88,6 +89,7 @@ export default function getAPIImplementation(): APIInterface { const requireResidentKey = DEFAULT_REGISTER_OPTIONS_REQUIRE_RESIDENT_KEY; const residentKey = DEFAULT_REGISTER_OPTIONS_RESIDENT_KEY; const userVerification = DEFAULT_REGISTER_OPTIONS_USER_VERIFICATION; + const supportedAlgorithmIds = DEFAULT_REGISTER_OPTIONS_SUPPORTED_ALGORITHM_IDS; let response = await options.recipeImplementation.registerOptions({ ...props, @@ -101,6 +103,7 @@ export default function getAPIImplementation(): APIInterface { timeout, tenantId, userContext, + supportedAlgorithmIds, }); if (response.status !== "OK") { diff --git a/lib/ts/recipe/webauthn/constants.ts b/lib/ts/recipe/webauthn/constants.ts index a94993d0f..652ee6e46 100644 --- a/lib/ts/recipe/webauthn/constants.ts +++ b/lib/ts/recipe/webauthn/constants.ts @@ -32,6 +32,7 @@ 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_REGISTER_OPTIONS_SUPPORTED_ALGORITHM_IDS = [-8, -7, -257]; export const DEFAULT_SIGNIN_OPTIONS_USER_VERIFICATION = "preferred"; diff --git a/lib/ts/recipe/webauthn/recipeImplementation.ts b/lib/ts/recipe/webauthn/recipeImplementation.ts index f5e23fb40..062cef00d 100644 --- a/lib/ts/recipe/webauthn/recipeImplementation.ts +++ b/lib/ts/recipe/webauthn/recipeImplementation.ts @@ -1,11 +1,10 @@ -import { CredentialPayload, RecipeInterface, TypeNormalisedInput } from "./types"; +import { RecipeInterface, TypeNormalisedInput } from "./types"; import AccountLinking from "../accountlinking/recipe"; import { Querier } from "../../querier"; import NormalisedURLPath from "../../normalisedURLPath"; import { getUser } from "../.."; import RecipeUserId from "../../recipeUserId"; 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"; @@ -23,77 +22,21 @@ export default function getRecipeInterface( attestation = "none", tenantId, userContext, + supportedAlgorithmIds, ...rest - }: { - 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; - } & ( - | { - 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", - }; - } + }) { + const emailInput = "email" in rest ? rest.email : undefined; + const recoverAccountTokenInput = "recoverAccountToken" in rest ? rest.recoverAccountToken : undefined; - // 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 email: string | undefined; + if (emailInput !== undefined) { + email = emailInput; + } else if (recoverAccountTokenInput !== undefined) { + // 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 of the token will be done during consumeRecoverAccountToken let decoded: jose.JWTPayload | undefined; try { - decoded = await jose.decodeJwt(recoverAccountToken); + decoded = await jose.decodeJwt(recoverAccountTokenInput); } catch (e) { console.error(e); @@ -107,7 +50,16 @@ export default function getRecipeInterface( if (!email) { return { - status: "EMAIL_MISSING_ERROR", + status: "INVALID_EMAIL_ERROR", + err: "The email is missing", + }; + } + + const err = await getWebauthnConfig().validateEmailAddress(email, tenantId); + if (err) { + return { + status: "INVALID_EMAIL_ERROR", + err, }; } @@ -122,31 +74,13 @@ export default function getRecipeInterface( origin, timeout, attestation, + supportedAlgorithmIds, }, userContext ); }, - signInOptions: async function ({ - relyingPartyId, - origin, - timeout, - tenantId, - userContext, - }: { - relyingPartyId: string; - origin: string; - userVerification: "required" | "preferred" | "discouraged" | undefined; // see register options - timeout: number | undefined; - tenantId: string; - userContext: UserContext; - }): Promise<{ - status: "OK"; - webauthnGeneratedOptionsId: string; - challenge: string; - timeout: number; - userVerification: "required" | "preferred" | "discouraged"; - }> { + signInOptions: async function ({ relyingPartyId, origin, timeout, tenantId, userContext }) { // the input user ID can be a recipe or a primary user ID. return await querier.sendPostRequest( new NormalisedURLPath( @@ -164,25 +98,7 @@ export default function getRecipeInterface( signUp: async function ( this: RecipeInterface, { webauthnGeneratedOptionsId, credential, tenantId, session, shouldTryLinkingWithSessionUser, userContext } - ): Promise< - | { - status: "OK"; - user: UserType; - 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: - | "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"; - } - > { + ) { const response = await this.createNewRecipeUser({ credential, webauthnGeneratedOptionsId, @@ -216,57 +132,63 @@ export default function getRecipeInterface( }; }, - createNewRecipeUser: async function (input: { - tenantId: string; - credential: CredentialPayload; - webauthnGeneratedOptionsId: string; - userContext: UserContext; - }): Promise< - | { - status: "OK"; - 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( - new NormalisedURLPath( - `/${input.tenantId === undefined ? DEFAULT_TENANT_ID : input.tenantId}/recipe/webauthn/signup` - ), - { - webauthnGeneratedOptionsId: input.webauthnGeneratedOptionsId, - credential: input.credential, - }, - input.userContext - ); + signIn: async function ( + this: RecipeInterface, + { credential, webauthnGeneratedOptionsId, tenantId, session, shouldTryLinkingWithSessionUser, userContext } + ) { + const response = await this.verifyCredentials({ + credential, + webauthnGeneratedOptionsId, + tenantId, + userContext, + }); + if (response.status !== "OK") { + return response; + } - if (resp.status === "OK") { - return { - status: "OK", - user: new User(resp.user), - recipeUserId: new RecipeUserId(resp.recipeUserId), - }; + const loginMethod: LoginMethod = response.user.loginMethods.find( + (lm: LoginMethod) => lm.recipeUserId.getAsString() === response.recipeUserId.getAsString() + )!; + + if (!loginMethod.verified) { + await AccountLinking.getInstance().verifyEmailForRecipeUserIfLinkedAccountsAreVerified({ + user: response.user, + recipeUserId: response.recipeUserId, + userContext, + }); + + // Unlike in the sign up recipe function, we do not do account linking here + // cause we do not want sign in to change the potentially user ID of a user + // due to linking when this function is called by the dev in their API - + // for example in their update password API. If we did account linking + // then we would have to ask the dev to also change the session + // in such API calls. + // In the case of sign up, since we are creating a new user, it's fine + // to link there since there is no user id change really from the dev's + // point of view who is calling the sign up recipe function. + + // We do this so that we get the updated user (in case the above + // function updated the verification status) and can return that + response.user = (await getUser(response.recipeUserId!.getAsString(), userContext))!; } - return resp; + const linkResult = await AuthUtils.linkToSessionIfRequiredElseCreatePrimaryUserIdOrLinkByAccountInfo({ + tenantId, + inputUser: response.user, + recipeUserId: response.recipeUserId, + session, + shouldTryLinkingWithSessionUser, + userContext, + }); + if (linkResult.status === "LINKING_TO_SESSION_USER_FAILED") { + return linkResult; + } + response.user = linkResult.user; + + return response; }, - verifyCredentials: async function ({ - credential, - webauthnGeneratedOptionsId, - tenantId, - userContext, - }): Promise< - | { - status: "OK"; - user: User; - recipeUserId: RecipeUserId; - } - | { status: "WRONG_CREDENTIALS_ERROR" } - > { + verifyCredentials: async function ({ credential, webauthnGeneratedOptionsId, tenantId, userContext }) { const response = await querier.sendPostRequest( new NormalisedURLPath( `/${tenantId === undefined ? DEFAULT_TENANT_ID : tenantId}/recipe/webauthn/signin` @@ -291,72 +213,30 @@ export default function getRecipeInterface( }; }, - signIn: async function ( - this: RecipeInterface, - { credential, webauthnGeneratedOptionsId, tenantId, session, shouldTryLinkingWithSessionUser, userContext } - ) { - const response = await this.verifyCredentials({ - credential, - webauthnGeneratedOptionsId, - tenantId, - userContext, - }); - - if (response.status === "OK") { - const loginMethod: LoginMethod = response.user.loginMethods.find( - (lm: LoginMethod) => lm.recipeUserId.getAsString() === response.recipeUserId.getAsString() - )!; - - if (!loginMethod.verified) { - await AccountLinking.getInstance().verifyEmailForRecipeUserIfLinkedAccountsAreVerified({ - user: response.user, - recipeUserId: response.recipeUserId, - userContext, - }); - - // Unlike in the sign up recipe function, we do not do account linking here - // cause we do not want sign in to change the potentially user ID of a user - // due to linking when this function is called by the dev in their API - - // for example in their update password API. If we did account linking - // then we would have to ask the dev to also change the session - // in such API calls. - // In the case of sign up, since we are creating a new user, it's fine - // to link there since there is no user id change really from the dev's - // point of view who is calling the sign up recipe function. - - // We do this so that we get the updated user (in case the above - // function updated the verification status) and can return that - response.user = (await getUser(response.recipeUserId!.getAsString(), userContext))!; - } + createNewRecipeUser: async function (input) { + const resp = await querier.sendPostRequest( + new NormalisedURLPath( + `/${input.tenantId === undefined ? DEFAULT_TENANT_ID : input.tenantId}/recipe/webauthn/signup` + ), + { + webauthnGeneratedOptionsId: input.webauthnGeneratedOptionsId, + credential: input.credential, + }, + input.userContext + ); - const linkResult = await AuthUtils.linkToSessionIfRequiredElseCreatePrimaryUserIdOrLinkByAccountInfo({ - tenantId, - inputUser: response.user, - recipeUserId: response.recipeUserId, - session, - shouldTryLinkingWithSessionUser, - userContext, - }); - if (linkResult.status === "LINKING_TO_SESSION_USER_FAILED") { - return linkResult; - } - response.user = linkResult.user; + if (resp.status === "OK") { + return { + status: "OK", + user: new User(resp.user), + recipeUserId: new RecipeUserId(resp.recipeUserId), + }; } - return response; + return resp; }, - 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" }> { + generateRecoverAccountToken: async function ({ userId, email, tenantId, userContext }) { // the input user ID can be a recipe or a primary user ID. return await querier.sendPostRequest( new NormalisedURLPath( @@ -370,25 +250,12 @@ export default function getRecipeInterface( ); }, - 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" } - > { + consumeRecoverAccountToken: async function ({ token, tenantId, userContext }) { return await querier.sendPostRequest( new NormalisedURLPath( - `/${tenantId === undefined ? DEFAULT_TENANT_ID : tenantId}/recipe/paskey/user/recover/token/consume` + `/${ + tenantId === undefined ? DEFAULT_TENANT_ID : tenantId + }/recipe/webauthn/user/recover/token/consume` ), { token, @@ -396,5 +263,96 @@ export default function getRecipeInterface( userContext ); }, + + registerCredential: async function ({ webauthnGeneratedOptionsId, credential, userContext, recipeUserId }) { + return await querier.sendPostRequest( + new NormalisedURLPath(`/recipe/webauthn/user/${recipeUserId}/credential/register`), + { + webauthnGeneratedOptionsId, + credential, + }, + userContext + ); + }, + + decodeCredential: async function ({ credential, userContext }) { + const response = await querier.sendPostRequest( + new NormalisedURLPath(`/recipe/webauthn/credential/decode`), + { + credential, + }, + userContext + ); + + if (response.status === "OK") { + return response; + } + + return { + status: "WRONG_CREDENTIALS_ERROR", + }; + }, + + getUserFromRecoverAccountToken: async function ({ token, tenantId, userContext }) { + return await querier.sendGetRequest( + new NormalisedURLPath( + `/${ + tenantId === undefined ? DEFAULT_TENANT_ID : tenantId + }/recipe/webauthn/user/recover/token/${token}` + ), + {}, + userContext + ); + }, + + removeCredential: async function ({ webauthnCredentialId, recipeUserId, userContext }) { + return await querier.sendDeleteRequest( + new NormalisedURLPath(`/recipe/webauthn/user/${recipeUserId}/credential/${webauthnCredentialId}`), + {}, + {}, + userContext + ); + }, + + getCredential: async function ({ webauthnCredentialId, recipeUserId, userContext }) { + return await querier.sendGetRequest( + new NormalisedURLPath(`/recipe/webauthn/user/${recipeUserId}/credential/${webauthnCredentialId}`), + {}, + userContext + ); + }, + + listCredentials: async function ({ recipeUserId, userContext }) { + return await querier.sendGetRequest( + new NormalisedURLPath(`/recipe/webauthn/user/${recipeUserId}/credential/list`), + {}, + userContext + ); + }, + + removeGeneratedOptions: async function ({ webauthnGeneratedOptionsId, tenantId, userContext }) { + return await querier.sendDeleteRequest( + new NormalisedURLPath( + `/${ + tenantId === undefined ? DEFAULT_TENANT_ID : tenantId + }/recipe/webauthn/options/${webauthnGeneratedOptionsId}` + ), + {}, + {}, + userContext + ); + }, + + getGeneratedOptions: async function ({ webauthnGeneratedOptionsId, tenantId, userContext }) { + return await querier.sendGetRequest( + new NormalisedURLPath( + `/${ + tenantId === undefined ? DEFAULT_TENANT_ID : tenantId + }/recipe/webauthn/options/${webauthnGeneratedOptionsId}` + ), + {}, + userContext + ); + }, }; } diff --git a/lib/ts/recipe/webauthn/types.ts b/lib/ts/recipe/webauthn/types.ts index f2b24efbd..593998a6d 100644 --- a/lib/ts/recipe/webauthn/types.ts +++ b/lib/ts/recipe/webauthn/types.ts @@ -154,6 +154,8 @@ export type RecipeInterface = { 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 [-8, -7, -257] as supported algorithms. See https://www.iana.org/assignments/cose/cose.xhtml#algorithms. + supportedAlgorithmIds: number[] | undefined; // default to 5 seconds timeout: number | undefined; tenantId: string; @@ -200,6 +202,7 @@ export type RecipeInterface = { } // | RegisterOptionsErrorResponse | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } + | { status: "INVALID_EMAIL_ERROR"; err: string } >; signInOptions(input: { @@ -281,6 +284,27 @@ export type RecipeInterface = { | { status: "WRONG_CREDENTIALS_ERROR" } >; + // this function is meant only for creating the recipe in the core and nothing else. + // we added this even though signUp exists cause devs may override signup expecting it + // to be called just during sign up. But we also need a version of signing up which can be + // called during operations like creating a user during password reset flow. + createNewRecipeUser(input: { + webauthnGeneratedOptionsId: string; + credential: CredentialPayload; + tenantId: string; + userContext: UserContext; + }): Promise< + | { + status: "OK"; + user: User; + recipeUserId: RecipeUserId; + } + // | CreateNewRecipeUserErrorResponse + | { status: "WRONG_CREDENTIALS_ERROR" } + | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string } + | { status: "EMAIL_ALREADY_EXISTS_ERROR" } + >; + /** * We pass in the email as well to this function cause the input userId * may not be associated with an webauthn account. In this case, we @@ -319,7 +343,6 @@ export type RecipeInterface = { registerCredential(input: { webauthnGeneratedOptionsId: string; credential: CredentialPayload; - tenantId: string; userContext: UserContext; recipeUserId: RecipeUserId; }): Promise< @@ -331,29 +354,9 @@ export type RecipeInterface = { | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string } >; - // this function is meant only for creating the recipe in the core and nothing else. - // we added this even though signUp exists cause devs may override signup expecting it - // to be called just during sign up. But we also need a version of signing up which can be - // called during operations like creating a user during password reset flow. - createNewRecipeUser(input: { - webauthnGeneratedOptionsId: string; - credential: CredentialPayload; - tenantId: string; - userContext: UserContext; - }): Promise< - | { - status: "OK"; - user: User; - recipeUserId: RecipeUserId; - } - // | CreateNewRecipeUserErrorResponse - | { status: "WRONG_CREDENTIALS_ERROR" } - | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string } - | { status: "EMAIL_ALREADY_EXISTS_ERROR" } - >; - decodeCredential(input: { credential: CredentialPayload; + userContext: UserContext; }): Promise< | { status: "OK"; @@ -445,12 +448,10 @@ export type RecipeInterface = { }): Promise< | { status: "OK"; - credential: { - id: string; - relyingPartyId: string; - recipeUserId: RecipeUserId; - createdAt: number; - }; + id: string; + relyingPartyId: string; + recipeUserId: RecipeUserId; + createdAt: number; } // | GetCredentialErrorResponse | { status: "CREDENTIAL_NOT_FOUND_ERROR" } From b8cffc134545dff749836f72413fd21448e6aff3 Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Wed, 6 Nov 2024 15:53:56 +0200 Subject: [PATCH 16/25] fixed implementation --- lib/ts/recipe/webauthn/api/implementation.ts | 41 ++++++++------ lib/ts/recipe/webauthn/api/signup.ts | 8 +-- lib/ts/recipe/webauthn/api/utils.ts | 10 ---- lib/ts/recipe/webauthn/index.ts | 56 ++++++++++++-------- lib/ts/recipe/webauthn/types.ts | 11 +++- lib/ts/types.ts | 2 +- lib/ts/user.ts | 4 +- 7 files changed, 71 insertions(+), 61 deletions(-) diff --git a/lib/ts/recipe/webauthn/api/implementation.ts b/lib/ts/recipe/webauthn/api/implementation.ts index cdf15a290..61f905625 100644 --- a/lib/ts/recipe/webauthn/api/implementation.ts +++ b/lib/ts/recipe/webauthn/api/implementation.ts @@ -65,8 +65,7 @@ export default function getAPIImplementation(): APIInterface { }; } | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } - | { status: "EMAIL_MISSING_ERROR" } - | { status: "REGISTER_OPTIONS_NOT_ALLOWED"; reason: string } + | { status: "INVALID_EMAIL_ERROR"; err: string } > { const relyingPartyId = await options.config.relyingPartyId({ tenantId, @@ -125,10 +124,12 @@ export default function getAPIImplementation(): APIInterface { }, signInOptionsPOST: async function ({ + email, tenantId, options, userContext, }: { + email?: string; tenantId: string; options: APIOptions; userContext: UserContext; @@ -141,6 +142,7 @@ export default function getAPIImplementation(): APIInterface { userVerification: "required" | "preferred" | "discouraged"; } | GeneralErrorResponse + | { status: "WRONG_CREDENTIALS_ERROR" } > { const relyingPartyId = await options.config.relyingPartyId({ tenantId, @@ -159,6 +161,7 @@ export default function getAPIImplementation(): APIInterface { const userVerification = DEFAULT_SIGNIN_OPTIONS_USER_VERIFICATION; let response = await options.recipeImplementation.signInOptions({ + email, userVerification, origin, relyingPartyId, @@ -181,7 +184,6 @@ export default function getAPIImplementation(): APIInterface { }, signUpPOST: async function ({ - email, webauthnGeneratedOptionsId, credential, tenantId, @@ -190,28 +192,28 @@ export default function getAPIImplementation(): APIInterface { options, userContext, }: { - email: string; webauthnGeneratedOptionsId: string; credential: CredentialPayload; tenantId: string; - session?: SessionContainerInterface; + 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"; session: SessionContainerInterface; user: User; } + | GeneralErrorResponse | { 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: @@ -232,13 +234,25 @@ export default function getAPIImplementation(): APIInterface { }, }; + const generatedOptions = await options.recipeImplementation.getGeneratedOptions({ + webauthnGeneratedOptionsId, + tenantId, + userContext, + }); + if (generatedOptions.status !== "OK") { + return { status: "WRONG_CREDENTIALS_ERROR" }; + } + + const email = generatedOptions.email; + // NOTE: Following checks will likely never throw an error as the // check for type is done in a parent function but they are kept // here to be on the safe side. - if (typeof email !== "string") + if (!email) { throw new Error( "Should never come here since we already check that the email value is a string in validateFormFieldsOrThrowError" ); + } const preAuthCheckRes = await AuthUtils.preAuthChecks({ authenticatingAccountInfo: { @@ -353,9 +367,7 @@ export default function getAPIImplementation(): APIInterface { session: SessionContainerInterface; user: User; } - | { - status: "WRONG_CREDENTIALS_ERROR"; - } + | { status: "WRONG_CREDENTIALS_ERROR" } | { status: "SIGN_IN_NOT_ALLOWED"; reason: string; @@ -826,13 +838,9 @@ export default function getAPIImplementation(): APIInterface { email: string; } | GeneralErrorResponse - | { - status: "CONSUME_RECOVER_ACCOUNT_TOKEN_NOT_ALLOWED"; - reason: string; - } + | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } | { 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(); @@ -874,7 +882,6 @@ export default function getAPIImplementation(): APIInterface { let updateResponse = await options.recipeImplementation.registerCredential({ recipeUserId, webauthnGeneratedOptionsId, - tenantId, credential, userContext, }); diff --git a/lib/ts/recipe/webauthn/api/signup.ts b/lib/ts/recipe/webauthn/api/signup.ts index 3695cbe3e..2be7713f8 100644 --- a/lib/ts/recipe/webauthn/api/signup.ts +++ b/lib/ts/recipe/webauthn/api/signup.ts @@ -18,11 +18,7 @@ import { getNormalisedShouldTryLinkingWithSessionUserFlag, send200Response, } from "../../../utils"; -import { - validateEmailOrThrowError, - validatewebauthnGeneratedOptionsIdOrThrowError, - validateCredentialOrThrowError, -} from "./utils"; +import { validatewebauthnGeneratedOptionsIdOrThrowError, validateCredentialOrThrowError } from "./utils"; import { APIInterface, APIOptions } from ".."; import STError from "../error"; import { UserContext } from "../../../types"; @@ -39,7 +35,6 @@ export default async function signUpAPI( } const requestBody = await options.req.getJSONBody(); - const email = await validateEmailOrThrowError(requestBody.email); const webauthnGeneratedOptionsId = await validatewebauthnGeneratedOptionsIdOrThrowError( requestBody.webauthnGeneratedOptionsId ); @@ -58,7 +53,6 @@ export default async function signUpAPI( } let result = await apiImplementation.signUpPOST({ - email, credential, webauthnGeneratedOptionsId, tenantId, diff --git a/lib/ts/recipe/webauthn/api/utils.ts b/lib/ts/recipe/webauthn/api/utils.ts index 4b1ecaaf8..5e1fa497c 100644 --- a/lib/ts/recipe/webauthn/api/utils.ts +++ b/lib/ts/recipe/webauthn/api/utils.ts @@ -13,16 +13,6 @@ * under the License. */ import STError from "../error"; -import { defaultEmailValidator } from "../utils"; - -export async function validateEmailOrThrowError(email: string): Promise { - const error = await defaultEmailValidator(email); - if (error) { - throw newBadRequestError(error); - } - - return email; -} export async function validatewebauthnGeneratedOptionsIdOrThrowError( webauthnGeneratedOptionsId: string diff --git a/lib/ts/recipe/webauthn/index.ts b/lib/ts/recipe/webauthn/index.ts index 9e53ff0d9..a1da56d89 100644 --- a/lib/ts/recipe/webauthn/index.ts +++ b/lib/ts/recipe/webauthn/index.ts @@ -22,10 +22,11 @@ import { getRecoverAccountLink } from "./utils"; import { getRequestFromUserContext, getUser } from "../.."; import { getUserContext } from "../../utils"; import { SessionContainerInterface } from "../session/types"; -import { User, UserContext } from "../../types"; +import { User } from "../../types"; import { DEFAULT_REGISTER_OPTIONS_REQUIRE_RESIDENT_KEY, DEFAULT_REGISTER_OPTIONS_RESIDENT_KEY, + DEFAULT_REGISTER_OPTIONS_SUPPORTED_ALGORITHM_IDS, DEFAULT_REGISTER_OPTIONS_USER_VERIFICATION, DEFAULT_SIGNIN_OPTIONS_USER_VERIFICATION, } from "./constants"; @@ -35,8 +36,9 @@ export default class Wrapper { static Error = SuperTokensError; - static registerOptions( - email: string, + static async registerOptions( + email: string | undefined, + recoverAccountToken: string | undefined, relyingPartyId: string, relyingPartyName: string, origin: string, @@ -76,14 +78,24 @@ export default class Wrapper { }; } | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } - | { status: "EMAIL_MISSING_ERROR" } - | { status: "INVALID_EMAIL_ERROR" } + | { status: "INVALID_EMAIL_ERROR"; err: string } > { + let payload: { email: string } | { recoverAccountToken: string } | null = email + ? { email } + : recoverAccountToken + ? { recoverAccountToken } + : null; + + if (!payload) { + return { status: "INVALID_EMAIL_ERROR", err: "Email is missing" }; + } + 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, + supportedAlgorithmIds: DEFAULT_REGISTER_OPTIONS_SUPPORTED_ALGORITHM_IDS, + ...payload, relyingPartyId, relyingPartyName, origin, @@ -100,13 +112,16 @@ export default class Wrapper { timeout: number, tenantId: string, userContext: Record - ): Promise<{ - status: "OK"; - webauthnGeneratedOptionsId: string; - challenge: string; - timeout: number; - userVerification: "required" | "preferred" | "discouraged"; - }> { + ): Promise< + | { + status: "OK"; + webauthnGeneratedOptionsId: string; + challenge: string; + timeout: number; + userVerification: "required" | "preferred" | "discouraged"; + } + | { status: "WRONG_CREDENTIALS_ERROR" } + > { return Recipe.getInstanceOrThrowError().recipeInterfaceImpl.signInOptions({ userVerification: DEFAULT_SIGNIN_OPTIONS_USER_VERIFICATION, relyingPartyId, @@ -123,11 +138,7 @@ export default class Wrapper { credential: CredentialPayload, session?: undefined, userContext?: Record - ): Promise< - | { status: "OK"; user: User; recipeUserId: RecipeUserId } - | { status: "WRONG_CREDENTIALS_ERROR" } - | { status: "INVALID_AUTHENTICATOR_ERROR" } - >; + ): Promise<{ status: "OK"; user: User; recipeUserId: RecipeUserId } | { status: "WRONG_CREDENTIALS_ERROR" }>; static signIn( tenantId: string, webauthnGeneratedOptionsId: string, @@ -137,7 +148,6 @@ export default class Wrapper { ): Promise< | { status: "OK"; user: User; recipeUserId: RecipeUserId } | { status: "WRONG_CREDENTIALS_ERROR" } - | { status: "INVALID_AUTHENTICATOR_ERROR" } | { status: "LINKING_TO_SESSION_USER_FAILED"; reason: @@ -156,7 +166,6 @@ export default class Wrapper { ): Promise< | { status: "OK"; user: User; recipeUserId: RecipeUserId } | { status: "WRONG_CREDENTIALS_ERROR" } - | { status: "INVALID_AUTHENTICATOR_ERROR" } | { status: "LINKING_TO_SESSION_USER_FAILED"; reason: @@ -181,7 +190,7 @@ export default class Wrapper { webauthnGeneratedOptionsId: string, credential: CredentialPayload, userContext?: Record - ): Promise<{ status: "OK" } | { status: "WRONG_CREDENTIALS_ERROR" } | { status: "INVALID_AUTHENTICATOR_ERROR" }> { + ): Promise<{ status: "OK" } | { status: "WRONG_CREDENTIALS_ERROR" }> { const resp = await Recipe.getInstanceOrThrowError().recipeInterfaceImpl.verifyCredentials({ webauthnGeneratedOptionsId, credential, @@ -220,7 +229,7 @@ export default class Wrapper { }); } - static async recoverAccountUsingToken( + static async recoverAccount( tenantId: string, webauthnGeneratedOptionsId: string, token: string, @@ -286,6 +295,7 @@ export default class Wrapper { | { status: "OK" | "WRONG_CREDENTIALS_ERROR"; } + | { status: "WRONG_CREDENTIALS_ERROR" } | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string } > { return Recipe.getInstanceOrThrowError().recipeInterfaceImpl.registerCredential({ @@ -383,7 +393,7 @@ export let verifyCredentials = Wrapper.verifyCredentials; export let generateRecoverAccountToken = Wrapper.generateRecoverAccountToken; -export let recoverAccountUsingToken = Wrapper.recoverAccountUsingToken; +export let recoverAccount = Wrapper.recoverAccount; export let consumeRecoverAccountToken = Wrapper.consumeRecoverAccountToken; diff --git a/lib/ts/recipe/webauthn/types.ts b/lib/ts/recipe/webauthn/types.ts index 593998a6d..6275e7464 100644 --- a/lib/ts/recipe/webauthn/types.ts +++ b/lib/ts/recipe/webauthn/types.ts @@ -525,7 +525,12 @@ type SignUpPOSTErrorResponse = | { status: "WRONG_CREDENTIALS_ERROR" } | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string }; -type SignInPOSTErrorResponse = { status: "WRONG_CREDENTIALS_ERROR" }; +type SignInPOSTErrorResponse = + | { status: "WRONG_CREDENTIALS_ERROR" } + | { + status: "SIGN_IN_NOT_ALLOWED"; + reason: string; + }; type GenerateRecoverAccountTokenPOSTErrorResponse = { status: "RECOVER_ACCOUNT_NOT_ALLOWED"; @@ -649,6 +654,10 @@ export type APIInterface = { } | GeneralErrorResponse // | SignInPOSTErrorResponse + | { + status: "SIGN_IN_NOT_ALLOWED"; + reason: string; + } | { status: "WRONG_CREDENTIALS_ERROR" } >); diff --git a/lib/ts/types.ts b/lib/ts/types.ts index 482d41b9d..308fd0436 100644 --- a/lib/ts/types.ts +++ b/lib/ts/types.ts @@ -117,7 +117,7 @@ export type User = { }[]; webauthn: { credentialIds: string[]; - }[]; + }; loginMethods: (RecipeLevelUser & { verified: boolean; hasSameEmailAs: (email: string | undefined) => boolean; diff --git a/lib/ts/user.ts b/lib/ts/user.ts index 87213468e..5a611e340 100644 --- a/lib/ts/user.ts +++ b/lib/ts/user.ts @@ -94,7 +94,7 @@ export class User implements UserType { }[]; public readonly webauthn: { credentialIds: string[]; - }[]; + }; public readonly loginMethods: LoginMethod[]; public readonly timeJoined: number; // minimum timeJoined value from linkedRecipes @@ -143,7 +143,7 @@ export type UserWithoutHelperFunctions = { }[]; webauthn: { credentialIds: string[]; - }[]; + }; loginMethods: { recipeId: "emailpassword" | "thirdparty" | "passwordless" | "webauthn"; recipeUserId: string; From bf9e00fe8a53713fc6a60cacb781b2184a0c8a2b Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Fri, 8 Nov 2024 09:57:03 +0200 Subject: [PATCH 17/25] added basic build --- lib/build/recipe/accountlinking/types.d.ts | 5 +- lib/build/recipe/multifactorauth/index.d.ts | 1 + lib/build/recipe/multifactorauth/types.d.ts | 1 + lib/build/recipe/multifactorauth/types.js | 1 + .../recipe/webauthn/api/emailExists.d.ts | 9 + lib/build/recipe/webauthn/api/emailExists.js | 45 + .../api/generateRecoverAccountToken.d.ts | 9 + .../api/generateRecoverAccountToken.js | 45 + .../recipe/webauthn/api/implementation.d.ts | 3 + .../recipe/webauthn/api/implementation.js | 886 ++++++++++++++++++ .../recipe/webauthn/api/recoverAccount.d.ts | 9 + .../recipe/webauthn/api/recoverAccount.js | 66 ++ .../recipe/webauthn/api/registerOptions.d.ts | 9 + .../recipe/webauthn/api/registerOptions.js | 62 ++ .../recipe/webauthn/api/signInOptions.d.ts | 9 + .../recipe/webauthn/api/signInOptions.js | 30 + lib/build/recipe/webauthn/api/signin.d.ts | 9 + lib/build/recipe/webauthn/api/signin.js | 61 ++ lib/build/recipe/webauthn/api/signup.d.ts | 9 + lib/build/recipe/webauthn/api/signup.js | 80 ++ lib/build/recipe/webauthn/api/utils.d.ts | 5 + lib/build/recipe/webauthn/api/utils.js | 43 + lib/build/recipe/webauthn/constants.d.ts | 16 + lib/build/recipe/webauthn/constants.js | 33 + lib/build/recipe/webauthn/core-mock.d.ts | 3 + lib/build/recipe/webauthn/core-mock.js | 79 ++ lib/build/recipe/webauthn/error.d.ts | 20 + lib/build/recipe/webauthn/error.js | 30 + lib/build/recipe/webauthn/index.d.ts | 244 +++++ lib/build/recipe/webauthn/index.js | 228 +++++ lib/build/recipe/webauthn/recipe.d.ts | 43 + lib/build/recipe/webauthn/recipe.js | 313 +++++++ .../recipe/webauthn/recipeImplementation.d.ts | 7 + .../recipe/webauthn/recipeImplementation.js | 404 ++++++++ lib/build/recipe/webauthn/types.d.ts | 673 +++++++++++++ lib/build/recipe/webauthn/types.js | 16 + lib/build/recipe/webauthn/utils.d.ts | 20 + lib/build/recipe/webauthn/utils.js | 163 ++++ lib/build/types.d.ts | 3 + lib/build/user.d.ts | 12 +- lib/build/user.js | 4 + lib/ts/recipe/webauthn/core-mock.ts | 3 + lib/ts/recipe/webauthn/types.ts | 110 +-- lib/ts/recipe/webauthn/utils.ts | 2 + 44 files changed, 3766 insertions(+), 57 deletions(-) create mode 100644 lib/build/recipe/webauthn/api/emailExists.d.ts create mode 100644 lib/build/recipe/webauthn/api/emailExists.js create mode 100644 lib/build/recipe/webauthn/api/generateRecoverAccountToken.d.ts create mode 100644 lib/build/recipe/webauthn/api/generateRecoverAccountToken.js create mode 100644 lib/build/recipe/webauthn/api/implementation.d.ts create mode 100644 lib/build/recipe/webauthn/api/implementation.js create mode 100644 lib/build/recipe/webauthn/api/recoverAccount.d.ts create mode 100644 lib/build/recipe/webauthn/api/recoverAccount.js create mode 100644 lib/build/recipe/webauthn/api/registerOptions.d.ts create mode 100644 lib/build/recipe/webauthn/api/registerOptions.js create mode 100644 lib/build/recipe/webauthn/api/signInOptions.d.ts create mode 100644 lib/build/recipe/webauthn/api/signInOptions.js create mode 100644 lib/build/recipe/webauthn/api/signin.d.ts create mode 100644 lib/build/recipe/webauthn/api/signin.js create mode 100644 lib/build/recipe/webauthn/api/signup.d.ts create mode 100644 lib/build/recipe/webauthn/api/signup.js create mode 100644 lib/build/recipe/webauthn/api/utils.d.ts create mode 100644 lib/build/recipe/webauthn/api/utils.js create mode 100644 lib/build/recipe/webauthn/constants.d.ts create mode 100644 lib/build/recipe/webauthn/constants.js create mode 100644 lib/build/recipe/webauthn/core-mock.d.ts create mode 100644 lib/build/recipe/webauthn/core-mock.js create mode 100644 lib/build/recipe/webauthn/error.d.ts create mode 100644 lib/build/recipe/webauthn/error.js create mode 100644 lib/build/recipe/webauthn/index.d.ts create mode 100644 lib/build/recipe/webauthn/index.js create mode 100644 lib/build/recipe/webauthn/recipe.d.ts create mode 100644 lib/build/recipe/webauthn/recipe.js create mode 100644 lib/build/recipe/webauthn/recipeImplementation.d.ts create mode 100644 lib/build/recipe/webauthn/recipeImplementation.js create mode 100644 lib/build/recipe/webauthn/types.d.ts create mode 100644 lib/build/recipe/webauthn/types.js create mode 100644 lib/build/recipe/webauthn/utils.d.ts create mode 100644 lib/build/recipe/webauthn/utils.js diff --git a/lib/build/recipe/accountlinking/types.d.ts b/lib/build/recipe/accountlinking/types.d.ts index 3530940ee..845780080 100644 --- a/lib/build/recipe/accountlinking/types.d.ts +++ b/lib/build/recipe/accountlinking/types.d.ts @@ -182,9 +182,12 @@ export declare type AccountInfo = { id: string; userId: string; }; + webauthn?: { + credentialIds: string[]; + }; }; export declare type AccountInfoWithRecipeId = { - recipeId: "emailpassword" | "thirdparty" | "passwordless"; + recipeId: "emailpassword" | "thirdparty" | "passwordless" | "webauthn"; } & AccountInfo; export declare type RecipeLevelUser = { tenantIds: string[]; diff --git a/lib/build/recipe/multifactorauth/index.d.ts b/lib/build/recipe/multifactorauth/index.d.ts index b62940921..b2d06cd51 100644 --- a/lib/build/recipe/multifactorauth/index.d.ts +++ b/lib/build/recipe/multifactorauth/index.d.ts @@ -9,6 +9,7 @@ export default class Wrapper { static MultiFactorAuthClaim: import("./multiFactorAuthClaim").MultiFactorAuthClaimClass; static FactorIds: { EMAILPASSWORD: string; + WEBAUTHN: string; OTP_EMAIL: string; OTP_PHONE: string; LINK_EMAIL: string; diff --git a/lib/build/recipe/multifactorauth/types.d.ts b/lib/build/recipe/multifactorauth/types.d.ts index 53f7ac093..e02cbadb4 100644 --- a/lib/build/recipe/multifactorauth/types.d.ts +++ b/lib/build/recipe/multifactorauth/types.d.ts @@ -136,6 +136,7 @@ export declare type GetPhoneNumbersForFactorsFromOtherRecipesFunc = ( }; export declare const FactorIds: { EMAILPASSWORD: string; + WEBAUTHN: string; OTP_EMAIL: string; OTP_PHONE: string; LINK_EMAIL: string; diff --git a/lib/build/recipe/multifactorauth/types.js b/lib/build/recipe/multifactorauth/types.js index ef0ec1829..e4e0f5219 100644 --- a/lib/build/recipe/multifactorauth/types.js +++ b/lib/build/recipe/multifactorauth/types.js @@ -17,6 +17,7 @@ Object.defineProperty(exports, "__esModule", { value: true }); exports.FactorIds = void 0; exports.FactorIds = { EMAILPASSWORD: "emailpassword", + WEBAUTHN: "webauthn", OTP_EMAIL: "otp-email", OTP_PHONE: "otp-phone", LINK_EMAIL: "link-email", diff --git a/lib/build/recipe/webauthn/api/emailExists.d.ts b/lib/build/recipe/webauthn/api/emailExists.d.ts new file mode 100644 index 000000000..2f55b6d3b --- /dev/null +++ b/lib/build/recipe/webauthn/api/emailExists.d.ts @@ -0,0 +1,9 @@ +// @ts-nocheck +import { APIInterface, APIOptions } from "../"; +import { UserContext } from "../../../types"; +export default function emailExists( + apiImplementation: APIInterface, + tenantId: string, + options: APIOptions, + userContext: UserContext +): Promise; diff --git a/lib/build/recipe/webauthn/api/emailExists.js b/lib/build/recipe/webauthn/api/emailExists.js new file mode 100644 index 000000000..b76ac08c7 --- /dev/null +++ b/lib/build/recipe/webauthn/api/emailExists.js @@ -0,0 +1,45 @@ +"use strict"; +/* 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. + */ +var __importDefault = + (this && this.__importDefault) || + function (mod) { + return mod && mod.__esModule ? mod : { default: mod }; + }; +Object.defineProperty(exports, "__esModule", { value: true }); +const utils_1 = require("../../../utils"); +const error_1 = __importDefault(require("../error")); +async function emailExists(apiImplementation, tenantId, options, userContext) { + // 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 error_1.default({ + type: error_1.default.BAD_INPUT_ERROR, + message: "Please provide the email as a GET param", + }); + } + let result = await apiImplementation.emailExistsGET({ + email, + tenantId, + options, + userContext, + }); + utils_1.send200Response(options.res, result); + return true; +} +exports.default = emailExists; diff --git a/lib/build/recipe/webauthn/api/generateRecoverAccountToken.d.ts b/lib/build/recipe/webauthn/api/generateRecoverAccountToken.d.ts new file mode 100644 index 000000000..ca836c5b4 --- /dev/null +++ b/lib/build/recipe/webauthn/api/generateRecoverAccountToken.d.ts @@ -0,0 +1,9 @@ +// @ts-nocheck +import { APIInterface, APIOptions } from "../"; +import { UserContext } from "../../../types"; +export default function generateRecoverAccountToken( + apiImplementation: APIInterface, + tenantId: string, + options: APIOptions, + userContext: UserContext +): Promise; diff --git a/lib/build/recipe/webauthn/api/generateRecoverAccountToken.js b/lib/build/recipe/webauthn/api/generateRecoverAccountToken.js new file mode 100644 index 000000000..651c5a398 --- /dev/null +++ b/lib/build/recipe/webauthn/api/generateRecoverAccountToken.js @@ -0,0 +1,45 @@ +"use strict"; +/* 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. + */ +var __importDefault = + (this && this.__importDefault) || + function (mod) { + return mod && mod.__esModule ? mod : { default: mod }; + }; +Object.defineProperty(exports, "__esModule", { value: true }); +const utils_1 = require("../../../utils"); +const error_1 = __importDefault(require("../error")); +async function generateRecoverAccountToken(apiImplementation, tenantId, options, userContext) { + if (apiImplementation.generateRecoverAccountTokenPOST === undefined) { + return false; + } + const requestBody = await options.req.getJSONBody(); + const email = requestBody.email; + if (email === undefined || typeof email !== "string") { + throw new error_1.default({ + type: error_1.default.BAD_INPUT_ERROR, + message: "Please provide the email", + }); + } + let result = await apiImplementation.generateRecoverAccountTokenPOST({ + email, + tenantId, + options, + userContext, + }); + utils_1.send200Response(options.res, result); + return true; +} +exports.default = generateRecoverAccountToken; diff --git a/lib/build/recipe/webauthn/api/implementation.d.ts b/lib/build/recipe/webauthn/api/implementation.d.ts new file mode 100644 index 000000000..2f382b449 --- /dev/null +++ b/lib/build/recipe/webauthn/api/implementation.d.ts @@ -0,0 +1,3 @@ +// @ts-nocheck +import { APIInterface } from ".."; +export default function getAPIImplementation(): APIInterface; diff --git a/lib/build/recipe/webauthn/api/implementation.js b/lib/build/recipe/webauthn/api/implementation.js new file mode 100644 index 000000000..6eef006ec --- /dev/null +++ b/lib/build/recipe/webauthn/api/implementation.js @@ -0,0 +1,886 @@ +"use strict"; +var __rest = + (this && this.__rest) || + function (s, e) { + var t = {}; + for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) t[p] = s[p]; + if (s != null && typeof Object.getOwnPropertySymbols === "function") + for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) { + if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) t[p[i]] = s[p[i]]; + } + return t; + }; +var __importDefault = + (this && this.__importDefault) || + function (mod) { + return mod && mod.__esModule ? mod : { default: mod }; + }; +Object.defineProperty(exports, "__esModule", { value: true }); +const recipe_1 = __importDefault(require("../../accountlinking/recipe")); +const recipe_2 = __importDefault(require("../../emailverification/recipe")); +const authUtils_1 = require("../../../authUtils"); +const utils_1 = require("../../thirdparty/utils"); +const constants_1 = require("../constants"); +const recipeUserId_1 = __importDefault(require("../../../recipeUserId")); +const utils_2 = require("../utils"); +const logger_1 = require("../../../logger"); +const __1 = require("../../.."); +function getAPIImplementation() { + return { + registerOptionsPOST: async function (_a) { + var { tenantId, options, userContext } = _a, + props = __rest(_a, ["tenantId", "options", "userContext"]); + const relyingPartyId = await options.config.relyingPartyId({ + tenantId, + request: options.req, + userContext, + }); + const relyingPartyName = await options.config.relyingPartyName({ + tenantId, + userContext, + }); + const origin = await options.config.getOrigin({ + tenantId, + request: options.req, + userContext, + }); + const timeout = constants_1.DEFAULT_REGISTER_OPTIONS_TIMEOUT; + const attestation = constants_1.DEFAULT_REGISTER_OPTIONS_ATTESTATION; + const requireResidentKey = constants_1.DEFAULT_REGISTER_OPTIONS_REQUIRE_RESIDENT_KEY; + const residentKey = constants_1.DEFAULT_REGISTER_OPTIONS_RESIDENT_KEY; + const userVerification = constants_1.DEFAULT_REGISTER_OPTIONS_USER_VERIFICATION; + const supportedAlgorithmIds = constants_1.DEFAULT_REGISTER_OPTIONS_SUPPORTED_ALGORITHM_IDS; + let response = await options.recipeImplementation.registerOptions( + Object.assign(Object.assign({}, props), { + attestation, + requireResidentKey, + residentKey, + userVerification, + origin, + relyingPartyId, + relyingPartyName, + timeout, + tenantId, + userContext, + supportedAlgorithmIds, + }) + ); + 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, + }; + }, + signInOptionsPOST: async function ({ email, tenantId, options, userContext }) { + const relyingPartyId = await options.config.relyingPartyId({ + tenantId, + request: options.req, + userContext, + }); + // use this to get the full url instead of only the domain url + const origin = await options.config.getOrigin({ + tenantId, + request: options.req, + userContext, + }); + const timeout = constants_1.DEFAULT_SIGNIN_OPTIONS_TIMEOUT; + const userVerification = constants_1.DEFAULT_SIGNIN_OPTIONS_USER_VERIFICATION; + let response = await options.recipeImplementation.signInOptions({ + email, + userVerification, + origin, + relyingPartyId, + timeout, + tenantId, + userContext, + }); + if (response.status !== "OK") { + return response; + } + return { + status: "OK", + webauthnGeneratedOptionsId: response.webauthnGeneratedOptionsId, + challenge: response.challenge, + timeout: response.timeout, + userVerification: response.userVerification, + }; + }, + signUpPOST: async function ({ + webauthnGeneratedOptionsId, + credential, + tenantId, + session, + shouldTryLinkingWithSessionUser, + options, + userContext, + }) { + 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)", + RECIPE_USER_ID_ALREADY_LINKED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR: + "Cannot sign in / up due to security reasons. Please contact support. (ERR_CODE_014)", + ACCOUNT_INFO_ALREADY_ASSOCIATED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR: + "Cannot sign in / up due to security reasons. Please contact support. (ERR_CODE_015)", + SESSION_USER_ACCOUNT_INFO_ALREADY_ASSOCIATED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR: + "Cannot sign in / up due to security reasons. Please contact support. (ERR_CODE_016)", + }, + }; + const generatedOptions = await options.recipeImplementation.getGeneratedOptions({ + webauthnGeneratedOptionsId, + tenantId, + userContext, + }); + if (generatedOptions.status !== "OK") { + return { status: "WRONG_CREDENTIALS_ERROR" }; + } + const email = generatedOptions.email; + // NOTE: Following checks will likely never throw an error as the + // check for type is done in a parent function but they are kept + // here to be on the safe side. + if (!email) { + throw new Error( + "Should never come here since we already check that the email value is a string in validateFormFieldsOrThrowError" + ); + } + const preAuthCheckRes = await authUtils_1.AuthUtils.preAuthChecks({ + authenticatingAccountInfo: { + recipeId: "webauthn", + email, + }, + factorIds: ["webauthn"], + isSignUp: true, + isVerified: utils_1.isFakeEmail(email), + signInVerifiesLoginMethod: false, + skipSessionUserUpdateInCore: false, + authenticatingUser: undefined, + tenantId, + userContext, + session, + shouldTryLinkingWithSessionUser, + }); + if (preAuthCheckRes.status === "SIGN_UP_NOT_ALLOWED") { + const conflictingUsers = await recipe_1.default + .getInstance() + .recipeInterfaceImpl.listUsersByAccountInfo({ + tenantId, + accountInfo: { + email, + }, + doUnionOfAccountInfo: false, + userContext, + }); + if ( + conflictingUsers.some((u) => + u.loginMethods.some((lm) => lm.recipeId === "webauthn" && lm.hasSameEmailAs(email)) + ) + ) { + return { + status: "EMAIL_ALREADY_EXISTS_ERROR", + }; + } + } + if (preAuthCheckRes.status !== "OK") { + return authUtils_1.AuthUtils.getErrorStatusResponseWithReason( + preAuthCheckRes, + errorCodeMap, + "SIGN_UP_NOT_ALLOWED" + ); + } + if (utils_1.isFakeEmail(email) && preAuthCheckRes.isFirstFactor) { + // Fake emails cannot be used as a first factor + return { + status: "EMAIL_ALREADY_EXISTS_ERROR", + }; + } + // we are using the email from the register options + const signUpResponse = await options.recipeImplementation.signUp({ + webauthnGeneratedOptionsId, + credential, + tenantId, + session, + shouldTryLinkingWithSessionUser, + userContext, + }); + if (signUpResponse.status === "EMAIL_ALREADY_EXISTS_ERROR") { + return signUpResponse; + } + if (signUpResponse.status !== "OK") { + return authUtils_1.AuthUtils.getErrorStatusResponseWithReason( + signUpResponse, + errorCodeMap, + "SIGN_UP_NOT_ALLOWED" + ); + } + const postAuthChecks = await authUtils_1.AuthUtils.postAuthChecks({ + authenticatedUser: signUpResponse.user, + recipeUserId: signUpResponse.recipeUserId, + isSignUp: true, + factorId: "webauthn", + session, + req: options.req, + res: options.res, + tenantId, + userContext, + }); + if (postAuthChecks.status !== "OK") { + // It should never actually come here, but we do it cause of consistency. + // If it does come here (in case there is a bug), it would make this func throw + // anyway, cause there is no SIGN_IN_NOT_ALLOWED in the errorCodeMap. + authUtils_1.AuthUtils.getErrorStatusResponseWithReason( + postAuthChecks, + errorCodeMap, + "SIGN_UP_NOT_ALLOWED" + ); + throw new Error("This should never happen"); + } + return { + status: "OK", + session: postAuthChecks.session, + user: postAuthChecks.user, + }; + }, + signInPOST: async function ({ + webauthnGeneratedOptionsId, + credential, + tenantId, + session, + shouldTryLinkingWithSessionUser, + options, + userContext, + }) { + const errorCodeMap = { + SIGN_IN_NOT_ALLOWED: + "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)", + RECIPE_USER_ID_ALREADY_LINKED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR: + "Cannot sign in / up due to security reasons. Please contact support. (ERR_CODE_010)", + ACCOUNT_INFO_ALREADY_ASSOCIATED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR: + "Cannot sign in / up due to security reasons. Please contact support. (ERR_CODE_011)", + SESSION_USER_ACCOUNT_INFO_ALREADY_ASSOCIATED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR: + "Cannot sign in / up due to security reasons. Please contact support. (ERR_CODE_012)", + }, + }; + const recipeId = "webauthn"; + // do the verification before in order to retrieve the user email + const verifyCredentialsResponse = await options.recipeImplementation.verifyCredentials({ + credential, + webauthnGeneratedOptionsId, + tenantId, + userContext, + }); + const checkCredentialsOnTenant = async () => { + return verifyCredentialsResponse.status === "OK"; + }; + // doing it like this because the email is only available after verifyCredentials is called + let email; + if (verifyCredentialsResponse.status == "OK") { + 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_1.AuthUtils.getErrorStatusResponseWithReason( + verifyCredentialsResponse, + errorCodeMap, + "SIGN_IN_NOT_ALLOWED" + ); + } + email = loginMethod === null || loginMethod === void 0 ? void 0 : loginMethod.email; + } else { + return { + status: "WRONG_CREDENTIALS_ERROR", + }; + } + const authenticatingUser = await authUtils_1.AuthUtils.getAuthenticatingUserAndAddToCurrentTenantIfRequired( + { + accountInfo: { email }, + userContext, + recipeId, + session, + tenantId, + checkCredentialsOnTenant, + } + ); + const isVerified = authenticatingUser !== undefined && authenticatingUser.loginMethod.verified; + // We check this before preAuthChecks, because that function assumes that if isSignUp is false, + // then authenticatingUser is defined. While it wouldn't technically cause any problems with + // the implementation of that function, this way we can guarantee that either isSignInAllowed or + // isSignUpAllowed will be called as expected. + if (authenticatingUser === undefined) { + return { + status: "WRONG_CREDENTIALS_ERROR", + }; + } + const preAuthChecks = await authUtils_1.AuthUtils.preAuthChecks({ + authenticatingAccountInfo: { + recipeId, + email, + }, + factorIds: [recipeId], + isSignUp: false, + authenticatingUser: + authenticatingUser === null || authenticatingUser === void 0 ? void 0 : authenticatingUser.user, + isVerified, + signInVerifiesLoginMethod: false, + skipSessionUserUpdateInCore: false, + tenantId, + userContext, + session, + shouldTryLinkingWithSessionUser, + }); + if (preAuthChecks.status === "SIGN_UP_NOT_ALLOWED") { + throw new Error("This should never happen: pre-auth checks should not fail for sign in"); + } + if (preAuthChecks.status !== "OK") { + return authUtils_1.AuthUtils.getErrorStatusResponseWithReason( + preAuthChecks, + errorCodeMap, + "SIGN_IN_NOT_ALLOWED" + ); + } + if (utils_1.isFakeEmail(email) && preAuthChecks.isFirstFactor) { + // Fake emails cannot be used as a first factor + return { + status: "WRONG_CREDENTIALS_ERROR", + }; + } + const signInResponse = await options.recipeImplementation.signIn({ + webauthnGeneratedOptionsId, + credential, + session, + shouldTryLinkingWithSessionUser, + tenantId, + userContext, + }); + if (signInResponse.status === "WRONG_CREDENTIALS_ERROR") { + return signInResponse; + } + if (signInResponse.status !== "OK") { + return authUtils_1.AuthUtils.getErrorStatusResponseWithReason( + signInResponse, + errorCodeMap, + "SIGN_IN_NOT_ALLOWED" + ); + } + const postAuthChecks = await authUtils_1.AuthUtils.postAuthChecks({ + authenticatedUser: signInResponse.user, + recipeUserId: signInResponse.recipeUserId, + isSignUp: false, + factorId: recipeId, + session, + req: options.req, + res: options.res, + tenantId, + userContext, + }); + if (postAuthChecks.status !== "OK") { + return authUtils_1.AuthUtils.getErrorStatusResponseWithReason( + postAuthChecks, + errorCodeMap, + "SIGN_IN_NOT_ALLOWED" + ); + } + return { + status: "OK", + session: postAuthChecks.session, + user: postAuthChecks.user, + }; + }, + emailExistsGET: async function ({ email, tenantId, userContext }) { + // 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 recipe_1.default.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 }) { + // 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, recipeUserId) { + // 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") { + logger_1.logDebugMessage( + `Recover account email not sent, unknown user id: ${ + recipeUserId === undefined ? primaryUserId : recipeUserId.getAsString() + }` + ); + return { + status: "OK", + }; + } + let recoverAccountLink = utils_2.getRecoverAccountLink({ + appInfo: options.appInfo, + token: response.token, + tenantId, + request: options.req, + userContext, + }); + logger_1.logDebugMessage(`Sending recover account 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 recipe_1.default.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 = 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 recover account + if (primaryUserAssociatedWithEmail === undefined) { + if (webauthnAccount === undefined) { + logger_1.logDebugMessage(`Recover account 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: "RECOVER_ACCOUNT_NOT_ALLOWED", + reason: + "Recover account link was not created because of account take over risk. Please contact support. (ERR_CODE_001)", + }; + } + let shouldDoAccountLinkingResponse = await recipe_1.default + .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 recover account + // code consume cannot be linked to the primary user - therefore, we should + // not generate a recover account reset token + if (!shouldDoAccountLinkingResponse.shouldAutomaticallyLink) { + logger_1.logDebugMessage( + `Recover account email not sent, since webauthn user didn't exist, and account linking not enabled` + ); + return { + status: "OK", + }; + } + let isSignUpAllowed = await recipe_1.default.getInstance().isSignUpAllowed({ + newUser: { + recipeId: "webauthn", + email, + }, + isVerified: true, + 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 { + logger_1.logDebugMessage( + `Recover account 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 recover account 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 + ); + }, + recoverAccountPOST: async function ({ + webauthnGeneratedOptionsId, + credential, + token, + tenantId, + options, + userContext, + }) { + async function markEmailAsVerified(recipeUserId, email) { + const emailVerificationInstance = recipe_2.default.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 anyway do account linking in this API after this function is + // called. + userContext, + }); + } + } + } + async function doRegisterCredentialAndVerifyEmailAndTryLinkIfNotPrimary(recipeUserId) { + let updateResponse = await options.recipeImplementation.registerCredential({ + recipeUserId, + webauthnGeneratedOptionsId, + 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 recover account 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 recover account 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 __1.getUser( + recipeUserId.getAsString(), + userContext + ); + if (updatedUserAfterEmailVerification === undefined) { + throw new Error("Should never happen - user deleted after during recover account"); + } + 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 recipe_1.default.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; + } + let userIdForWhomTokenWasGenerated = tokenConsumptionResponse.userId; + let emailForWhomTokenWasGenerated = tokenConsumptionResponse.email; + let existingUser = await __1.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 recover account 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_1.default(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 recover account 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 recover account 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 __1.getUser(createUserResponse.user.id, userContext); + if (updatedUser === undefined) { + throw new Error("Should never happen - user deleted after during recover account"); + } + 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 recipe_1.default + .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_1.default(userIdForWhomTokenWasGenerated) + ); + } + }, + }; +} +exports.default = getAPIImplementation; diff --git a/lib/build/recipe/webauthn/api/recoverAccount.d.ts b/lib/build/recipe/webauthn/api/recoverAccount.d.ts new file mode 100644 index 000000000..a5038fad1 --- /dev/null +++ b/lib/build/recipe/webauthn/api/recoverAccount.d.ts @@ -0,0 +1,9 @@ +// @ts-nocheck +import { APIInterface, APIOptions } from "../"; +import { UserContext } from "../../../types"; +export default function recoverAccount( + apiImplementation: APIInterface, + tenantId: string, + options: APIOptions, + userContext: UserContext +): Promise; diff --git a/lib/build/recipe/webauthn/api/recoverAccount.js b/lib/build/recipe/webauthn/api/recoverAccount.js new file mode 100644 index 000000000..9e63a577b --- /dev/null +++ b/lib/build/recipe/webauthn/api/recoverAccount.js @@ -0,0 +1,66 @@ +"use strict"; +/* 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. + */ +var __importDefault = + (this && this.__importDefault) || + function (mod) { + return mod && mod.__esModule ? mod : { default: mod }; + }; +Object.defineProperty(exports, "__esModule", { value: true }); +const utils_1 = require("../../../utils"); +const utils_2 = require("./utils"); +const error_1 = __importDefault(require("../error")); +async function recoverAccount(apiImplementation, tenantId, options, userContext) { + // Logic as per https://github.com/supertokens/supertokens-node/issues/22#issuecomment-710512442 + if (apiImplementation.recoverAccountPOST === undefined) { + return false; + } + const requestBody = await options.req.getJSONBody(); + let webauthnGeneratedOptionsId = await utils_2.validatewebauthnGeneratedOptionsIdOrThrowError( + requestBody.webauthnGeneratedOptionsId + ); + let credential = await utils_2.validateCredentialOrThrowError(requestBody.credential); + let token = requestBody.token; + if (token === undefined) { + throw new error_1.default({ + type: error_1.default.BAD_INPUT_ERROR, + message: "Please provide the recover account token", + }); + } + if (typeof token !== "string") { + throw new error_1.default({ + type: error_1.default.BAD_INPUT_ERROR, + message: "The recover account token must be a string", + }); + } + let result = await apiImplementation.recoverAccountPOST({ + webauthnGeneratedOptionsId, + credential, + token, + tenantId, + options, + userContext, + }); + utils_1.send200Response( + options.res, + result.status === "OK" + ? { + status: "OK", + } + : result + ); + return true; +} +exports.default = recoverAccount; diff --git a/lib/build/recipe/webauthn/api/registerOptions.d.ts b/lib/build/recipe/webauthn/api/registerOptions.d.ts new file mode 100644 index 000000000..6f9f603b6 --- /dev/null +++ b/lib/build/recipe/webauthn/api/registerOptions.d.ts @@ -0,0 +1,9 @@ +// @ts-nocheck +import { APIInterface, APIOptions } from ".."; +import { UserContext } from "../../../types"; +export default function registerOptions( + apiImplementation: APIInterface, + tenantId: string, + options: APIOptions, + userContext: UserContext +): Promise; diff --git a/lib/build/recipe/webauthn/api/registerOptions.js b/lib/build/recipe/webauthn/api/registerOptions.js new file mode 100644 index 000000000..6ea9bf0ec --- /dev/null +++ b/lib/build/recipe/webauthn/api/registerOptions.js @@ -0,0 +1,62 @@ +"use strict"; +/* 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. + */ +var __importDefault = + (this && this.__importDefault) || + function (mod) { + return mod && mod.__esModule ? mod : { default: mod }; + }; +Object.defineProperty(exports, "__esModule", { value: true }); +const utils_1 = require("../../../utils"); +const error_1 = __importDefault(require("../error")); +async function registerOptions(apiImplementation, tenantId, options, userContext) { + var _a; + if (apiImplementation.registerOptionsPOST === undefined) { + return false; + } + const requestBody = await options.req.getJSONBody(); + let email = (_a = requestBody.email) === null || _a === void 0 ? void 0 : _a.trim(); + let recoverAccountToken = requestBody.recoverAccountToken; + if ( + (email === undefined || typeof email !== "string") && + (recoverAccountToken === undefined || typeof recoverAccountToken !== "string") + ) { + throw new error_1.default({ + type: error_1.default.BAD_INPUT_ERROR, + message: "Please provide the email or the recover account token", + }); + } + // same as for passwordless lib/ts/recipe/passwordless/api/createCode.ts + if (email !== undefined) { + const validateError = await options.config.validateEmailAddress(email, tenantId); + if (validateError !== undefined) { + utils_1.send200Response(options.res, { + status: "INVALID_EMAIL_ERROR", + err: validateError, + }); + return true; + } + } + let result = await apiImplementation.registerOptionsPOST({ + email, + recoverAccountToken, + tenantId, + options, + userContext, + }); + utils_1.send200Response(options.res, result); + return true; +} +exports.default = registerOptions; diff --git a/lib/build/recipe/webauthn/api/signInOptions.d.ts b/lib/build/recipe/webauthn/api/signInOptions.d.ts new file mode 100644 index 000000000..1e3bc7b5f --- /dev/null +++ b/lib/build/recipe/webauthn/api/signInOptions.d.ts @@ -0,0 +1,9 @@ +// @ts-nocheck +import { APIInterface, APIOptions } from ".."; +import { UserContext } from "../../../types"; +export default function signInOptions( + apiImplementation: APIInterface, + tenantId: string, + options: APIOptions, + userContext: UserContext +): Promise; diff --git a/lib/build/recipe/webauthn/api/signInOptions.js b/lib/build/recipe/webauthn/api/signInOptions.js new file mode 100644 index 000000000..3c04d2808 --- /dev/null +++ b/lib/build/recipe/webauthn/api/signInOptions.js @@ -0,0 +1,30 @@ +"use strict"; +/* 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. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +const utils_1 = require("../../../utils"); +async function signInOptions(apiImplementation, tenantId, options, userContext) { + if (apiImplementation.signInOptionsPOST === undefined) { + return false; + } + let result = await apiImplementation.signInOptionsPOST({ + tenantId, + options, + userContext, + }); + utils_1.send200Response(options.res, result); + return true; +} +exports.default = signInOptions; diff --git a/lib/build/recipe/webauthn/api/signin.d.ts b/lib/build/recipe/webauthn/api/signin.d.ts new file mode 100644 index 000000000..72cd6e46b --- /dev/null +++ b/lib/build/recipe/webauthn/api/signin.d.ts @@ -0,0 +1,9 @@ +// @ts-nocheck +import { APIInterface, APIOptions } from ".."; +import { UserContext } from "../../../types"; +export default function signInAPI( + apiImplementation: APIInterface, + tenantId: string, + options: APIOptions, + userContext: UserContext +): Promise; diff --git a/lib/build/recipe/webauthn/api/signin.js b/lib/build/recipe/webauthn/api/signin.js new file mode 100644 index 000000000..44cc8bb6f --- /dev/null +++ b/lib/build/recipe/webauthn/api/signin.js @@ -0,0 +1,61 @@ +"use strict"; +/* 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. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +const utils_1 = require("../../../utils"); +const utils_2 = require("./utils"); +const authUtils_1 = require("../../../authUtils"); +async function signInAPI(apiImplementation, tenantId, options, userContext) { + if (apiImplementation.signInPOST === undefined) { + return false; + } + const requestBody = await options.req.getJSONBody(); + const webauthnGeneratedOptionsId = await utils_2.validatewebauthnGeneratedOptionsIdOrThrowError( + requestBody.webauthnGeneratedOptionsId + ); + const credential = await utils_2.validateCredentialOrThrowError(requestBody.credential); + const shouldTryLinkingWithSessionUser = utils_1.getNormalisedShouldTryLinkingWithSessionUserFlag( + options.req, + requestBody + ); + const session = await authUtils_1.AuthUtils.loadSessionInAuthAPIIfNeeded( + options.req, + options.res, + shouldTryLinkingWithSessionUser, + userContext + ); + if (session !== undefined) { + tenantId = session.getTenantId(); + } + let result = await apiImplementation.signInPOST({ + webauthnGeneratedOptionsId, + credential, + tenantId, + session, + shouldTryLinkingWithSessionUser, + options, + userContext, + }); + if (result.status === "OK") { + utils_1.send200Response( + options.res, + Object.assign({ status: "OK" }, utils_1.getBackwardsCompatibleUserInfo(options.req, result, userContext)) + ); + } else { + utils_1.send200Response(options.res, result); + } + return true; +} +exports.default = signInAPI; diff --git a/lib/build/recipe/webauthn/api/signup.d.ts b/lib/build/recipe/webauthn/api/signup.d.ts new file mode 100644 index 000000000..afc748051 --- /dev/null +++ b/lib/build/recipe/webauthn/api/signup.d.ts @@ -0,0 +1,9 @@ +// @ts-nocheck +import { APIInterface, APIOptions } from ".."; +import { UserContext } from "../../../types"; +export default function signUpAPI( + apiImplementation: APIInterface, + tenantId: string, + options: APIOptions, + userContext: UserContext +): Promise; diff --git a/lib/build/recipe/webauthn/api/signup.js b/lib/build/recipe/webauthn/api/signup.js new file mode 100644 index 000000000..8eb70b124 --- /dev/null +++ b/lib/build/recipe/webauthn/api/signup.js @@ -0,0 +1,80 @@ +"use strict"; +/* 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. + */ +var __importDefault = + (this && this.__importDefault) || + function (mod) { + return mod && mod.__esModule ? mod : { default: mod }; + }; +Object.defineProperty(exports, "__esModule", { value: true }); +const utils_1 = require("../../../utils"); +const utils_2 = require("./utils"); +const error_1 = __importDefault(require("../error")); +const authUtils_1 = require("../../../authUtils"); +async function signUpAPI(apiImplementation, tenantId, options, userContext) { + if (apiImplementation.signUpPOST === undefined) { + return false; + } + const requestBody = await options.req.getJSONBody(); + const webauthnGeneratedOptionsId = await utils_2.validatewebauthnGeneratedOptionsIdOrThrowError( + requestBody.webauthnGeneratedOptionsId + ); + const credential = await utils_2.validateCredentialOrThrowError(requestBody.credential); + const shouldTryLinkingWithSessionUser = utils_1.getNormalisedShouldTryLinkingWithSessionUserFlag( + options.req, + requestBody + ); + const session = await authUtils_1.AuthUtils.loadSessionInAuthAPIIfNeeded( + options.req, + options.res, + shouldTryLinkingWithSessionUser, + userContext + ); + if (session !== undefined) { + tenantId = session.getTenantId(); + } + let result = await apiImplementation.signUpPOST({ + credential, + webauthnGeneratedOptionsId, + tenantId, + session, + shouldTryLinkingWithSessionUser, + options, + userContext: userContext, + }); + if (result.status === "OK") { + utils_1.send200Response( + options.res, + Object.assign({ status: "OK" }, utils_1.getBackwardsCompatibleUserInfo(options.req, result, userContext)) + ); + } else if (result.status === "GENERAL_ERROR") { + utils_1.send200Response(options.res, result); + } else if (result.status === "EMAIL_ALREADY_EXISTS_ERROR") { + throw new error_1.default({ + type: error_1.default.FIELD_ERROR, + payload: [ + { + id: "email", + error: "This email already exists. Please sign in instead.", + }, + ], + message: "Error in input formFields", + }); + } else { + utils_1.send200Response(options.res, result); + } + return true; +} +exports.default = signUpAPI; diff --git a/lib/build/recipe/webauthn/api/utils.d.ts b/lib/build/recipe/webauthn/api/utils.d.ts new file mode 100644 index 000000000..8bd411782 --- /dev/null +++ b/lib/build/recipe/webauthn/api/utils.d.ts @@ -0,0 +1,5 @@ +// @ts-nocheck +export declare function validatewebauthnGeneratedOptionsIdOrThrowError( + webauthnGeneratedOptionsId: string +): Promise; +export declare function validateCredentialOrThrowError(credential: T): Promise; diff --git a/lib/build/recipe/webauthn/api/utils.js b/lib/build/recipe/webauthn/api/utils.js new file mode 100644 index 000000000..bfc2cd2e9 --- /dev/null +++ b/lib/build/recipe/webauthn/api/utils.js @@ -0,0 +1,43 @@ +"use strict"; +var __importDefault = + (this && this.__importDefault) || + function (mod) { + return mod && mod.__esModule ? mod : { default: mod }; + }; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.validateCredentialOrThrowError = exports.validatewebauthnGeneratedOptionsIdOrThrowError = void 0; +/* 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. + */ +const error_1 = __importDefault(require("../error")); +async function validatewebauthnGeneratedOptionsIdOrThrowError(webauthnGeneratedOptionsId) { + if (webauthnGeneratedOptionsId === undefined) { + throw newBadRequestError("webauthnGeneratedOptionsId is required"); + } + return webauthnGeneratedOptionsId; +} +exports.validatewebauthnGeneratedOptionsIdOrThrowError = validatewebauthnGeneratedOptionsIdOrThrowError; +async function validateCredentialOrThrowError(credential) { + if (credential === undefined) { + throw newBadRequestError("credential is required"); + } + return credential; +} +exports.validateCredentialOrThrowError = validateCredentialOrThrowError; +function newBadRequestError(message) { + return new error_1.default({ + type: error_1.default.BAD_INPUT_ERROR, + message, + }); +} diff --git a/lib/build/recipe/webauthn/constants.d.ts b/lib/build/recipe/webauthn/constants.d.ts new file mode 100644 index 000000000..d58df9d60 --- /dev/null +++ b/lib/build/recipe/webauthn/constants.d.ts @@ -0,0 +1,16 @@ +// @ts-nocheck +export declare const REGISTER_OPTIONS_API = "/webauthn/options/register"; +export declare const SIGNIN_OPTIONS_API = "/webauthn/options/signin"; +export declare const SIGN_UP_API = "/webauthn/signup"; +export declare const SIGN_IN_API = "/webauthn/signin"; +export declare const GENERATE_RECOVER_ACCOUNT_TOKEN_API = "/user/webauthn/reset/token"; +export declare const RECOVER_ACCOUNT_API = "/user/webauthn/reset"; +export declare const SIGNUP_EMAIL_EXISTS_API = "/webauthn/email/exists"; +export declare const DEFAULT_REGISTER_OPTIONS_ATTESTATION = "none"; +export declare const DEFAULT_REGISTER_OPTIONS_REQUIRE_RESIDENT_KEY = false; +export declare const DEFAULT_REGISTER_OPTIONS_RESIDENT_KEY = "required"; +export declare const DEFAULT_REGISTER_OPTIONS_USER_VERIFICATION = "preferred"; +export declare const DEFAULT_REGISTER_OPTIONS_SUPPORTED_ALGORITHM_IDS: number[]; +export declare const DEFAULT_SIGNIN_OPTIONS_USER_VERIFICATION = "preferred"; +export declare const DEFAULT_REGISTER_OPTIONS_TIMEOUT = 5000; +export declare const DEFAULT_SIGNIN_OPTIONS_TIMEOUT = 5000; diff --git a/lib/build/recipe/webauthn/constants.js b/lib/build/recipe/webauthn/constants.js new file mode 100644 index 000000000..2e86a4747 --- /dev/null +++ b/lib/build/recipe/webauthn/constants.js @@ -0,0 +1,33 @@ +"use strict"; +/* 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. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.DEFAULT_SIGNIN_OPTIONS_TIMEOUT = exports.DEFAULT_REGISTER_OPTIONS_TIMEOUT = exports.DEFAULT_SIGNIN_OPTIONS_USER_VERIFICATION = exports.DEFAULT_REGISTER_OPTIONS_SUPPORTED_ALGORITHM_IDS = exports.DEFAULT_REGISTER_OPTIONS_USER_VERIFICATION = exports.DEFAULT_REGISTER_OPTIONS_RESIDENT_KEY = exports.DEFAULT_REGISTER_OPTIONS_REQUIRE_RESIDENT_KEY = exports.DEFAULT_REGISTER_OPTIONS_ATTESTATION = exports.SIGNUP_EMAIL_EXISTS_API = exports.RECOVER_ACCOUNT_API = exports.GENERATE_RECOVER_ACCOUNT_TOKEN_API = exports.SIGN_IN_API = exports.SIGN_UP_API = exports.SIGNIN_OPTIONS_API = exports.REGISTER_OPTIONS_API = void 0; +exports.REGISTER_OPTIONS_API = "/webauthn/options/register"; +exports.SIGNIN_OPTIONS_API = "/webauthn/options/signin"; +exports.SIGN_UP_API = "/webauthn/signup"; +exports.SIGN_IN_API = "/webauthn/signin"; +exports.GENERATE_RECOVER_ACCOUNT_TOKEN_API = "/user/webauthn/reset/token"; +exports.RECOVER_ACCOUNT_API = "/user/webauthn/reset"; +exports.SIGNUP_EMAIL_EXISTS_API = "/webauthn/email/exists"; +// defaults that can be overridden by the developer +exports.DEFAULT_REGISTER_OPTIONS_ATTESTATION = "none"; +exports.DEFAULT_REGISTER_OPTIONS_REQUIRE_RESIDENT_KEY = false; +exports.DEFAULT_REGISTER_OPTIONS_RESIDENT_KEY = "required"; +exports.DEFAULT_REGISTER_OPTIONS_USER_VERIFICATION = "preferred"; +exports.DEFAULT_REGISTER_OPTIONS_SUPPORTED_ALGORITHM_IDS = [-8, -7, -257]; +exports.DEFAULT_SIGNIN_OPTIONS_USER_VERIFICATION = "preferred"; +exports.DEFAULT_REGISTER_OPTIONS_TIMEOUT = 5000; +exports.DEFAULT_SIGNIN_OPTIONS_TIMEOUT = 5000; diff --git a/lib/build/recipe/webauthn/core-mock.d.ts b/lib/build/recipe/webauthn/core-mock.d.ts new file mode 100644 index 000000000..0bb5666c4 --- /dev/null +++ b/lib/build/recipe/webauthn/core-mock.d.ts @@ -0,0 +1,3 @@ +// @ts-nocheck +import { Querier } from "../../querier"; +export declare const getMockQuerier: (recipeId: string) => Querier; diff --git a/lib/build/recipe/webauthn/core-mock.js b/lib/build/recipe/webauthn/core-mock.js new file mode 100644 index 000000000..723cce9f8 --- /dev/null +++ b/lib/build/recipe/webauthn/core-mock.js @@ -0,0 +1,79 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.getMockQuerier = void 0; +const querier_1 = require("../../querier"); +const getMockQuerier = (recipeId) => { + const querier = querier_1.Querier.getNewInstanceOrThrowError(recipeId); + const sendPostRequest = async (path, body, userContext) => { + console.log("body", body); + console.log("userContext", userContext); + if (path.getAsStringDangerous().includes("/recipe/webauthn/options/register")) { + // @ts-ignore + return { + status: "OK", + webauthnGeneratedOptionsId: "7ab03f6a-61b8-4f65-992f-b8b8469bc18f", + rp: { id: "example.com", name: "Example App" }, + user: { id: "dummy-user-id", name: "user@example.com", displayName: "User" }, + challenge: "dummy-challenge", + timeout: 60000, + excludeCredentials: [], + attestation: "none", + pubKeyCredParams: [{ alg: -7, type: "public-key" }], + authenticatorSelection: { + requireResidentKey: false, + residentKey: "preferred", + userVerification: "preferred", + }, + }; + } else if (path.getAsStringDangerous().includes("/recipe/webauthn/options/signin")) { + // @ts-ignore + return { + status: "OK", + webauthnGeneratedOptionsId: "18302759-87c6-4d88-990d-c7cab43653cc", + challenge: "dummy-signin-challenge", + timeout: 60000, + userVerification: "preferred", + }; + // } else if (path.getAsStringDangerous().includes("/recipe/webauthn/user/recover/token")) { + // // @ts-ignore + // return { + // status: "OK", + // token: "dummy-recover-token", + // }; + // } else if (path.getAsStringDangerous().includes("/recipe/webauthn/user/recover/token/consume")) { + // // @ts-ignore + // return { + // status: "OK", + // userId: "dummy-user-id", + // email: "user@example.com", + // }; + // } + } else if (path.getAsStringDangerous().includes("/recipe/webauthn/signup")) { + // @ts-ignore + return { + status: "OK", + user: { + id: "dummy-user-id", + email: "user@example.com", + timeJoined: Date.now(), + }, + recipeUserId: "dummy-recipe-user-id", + }; + } else if (path.getAsStringDangerous().includes("/recipe/webauthn/signin")) { + // @ts-ignore + return { + status: "OK", + user: { + id: "dummy-user-id", + email: "user@example.com", + timeJoined: Date.now(), + }, + recipeUserId: "dummy-recipe-user-id", + }; + } + throw new Error(`Unmocked endpoint: ${path}`); + }; + querier.sendPostRequest = sendPostRequest; + return querier; +}; +exports.getMockQuerier = getMockQuerier; diff --git a/lib/build/recipe/webauthn/error.d.ts b/lib/build/recipe/webauthn/error.d.ts new file mode 100644 index 000000000..d4dc2cf9b --- /dev/null +++ b/lib/build/recipe/webauthn/error.d.ts @@ -0,0 +1,20 @@ +// @ts-nocheck +import STError from "../../error"; +export default class SessionError extends STError { + static FIELD_ERROR: "FIELD_ERROR"; + constructor( + options: + | { + type: "FIELD_ERROR"; + payload: { + id: string; + error: string; + }[]; + message: string; + } + | { + type: "BAD_INPUT_ERROR"; + message: string; + } + ); +} diff --git a/lib/build/recipe/webauthn/error.js b/lib/build/recipe/webauthn/error.js new file mode 100644 index 000000000..9cce55615 --- /dev/null +++ b/lib/build/recipe/webauthn/error.js @@ -0,0 +1,30 @@ +"use strict"; +/* 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. + */ +var __importDefault = + (this && this.__importDefault) || + function (mod) { + return mod && mod.__esModule ? mod : { default: mod }; + }; +Object.defineProperty(exports, "__esModule", { value: true }); +const error_1 = __importDefault(require("../../error")); +class SessionError extends error_1.default { + constructor(options) { + super(Object.assign({}, options)); + this.fromRecipe = "webauthn"; + } +} +exports.default = SessionError; +SessionError.FIELD_ERROR = "FIELD_ERROR"; diff --git a/lib/build/recipe/webauthn/index.d.ts b/lib/build/recipe/webauthn/index.d.ts new file mode 100644 index 000000000..fc33da5b1 --- /dev/null +++ b/lib/build/recipe/webauthn/index.d.ts @@ -0,0 +1,244 @@ +// @ts-nocheck +import Recipe from "./recipe"; +import SuperTokensError from "./error"; +import { RecipeInterface, APIOptions, APIInterface, TypeWebauthnEmailDeliveryInput, CredentialPayload } from "./types"; +import RecipeUserId from "../../recipeUserId"; +import { SessionContainerInterface } from "../session/types"; +import { User } from "../../types"; +export default class Wrapper { + static init: typeof Recipe.init; + static Error: typeof SuperTokensError; + static registerOptions( + email: string | undefined, + recoverAccountToken: string | undefined, + relyingPartyId: string, + relyingPartyName: string, + origin: string, + timeout: number, + attestation: "none" | "indirect" | "direct" | "enterprise" | undefined, + 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: "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: "INVALID_EMAIL_ERROR"; + err: string; + } + >; + static signInOptions( + relyingPartyId: string, + origin: string, + timeout: number, + tenantId: string, + userContext: Record + ): Promise< + | { + status: "OK"; + webauthnGeneratedOptionsId: string; + challenge: string; + timeout: number; + userVerification: "required" | "preferred" | "discouraged"; + } + | { + status: "WRONG_CREDENTIALS_ERROR"; + } + >; + static signIn( + tenantId: string, + webauthnGeneratedOptionsId: string, + credential: CredentialPayload, + session?: undefined, + userContext?: Record + ): Promise< + | { + status: "OK"; + user: User; + recipeUserId: RecipeUserId; + } + | { + status: "WRONG_CREDENTIALS_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: "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 verifyCredentials( + tenantId: string, + webauthnGeneratedOptionsId: string, + credential: CredentialPayload, + userContext?: Record + ): Promise< + | { + status: "OK"; + } + | { + status: "WRONG_CREDENTIALS_ERROR"; + } + >; + /** + * 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"; + } + >; + static recoverAccount( + 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; + } + >; + static consumeRecoverAccountToken( + tenantId: string, + token: string, + userContext?: Record + ): Promise< + | { + status: "OK"; + email: string; + userId: string; + } + | { + status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR"; + } + >; + static registerCredential(input: { + recipeUserId: RecipeUserId; + tenantId: string; + webauthnGeneratedOptionsId: string; + credential: CredentialPayload; + userContext?: Record; + }): Promise< + | { + status: "OK" | "WRONG_CREDENTIALS_ERROR"; + } + | { + status: "WRONG_CREDENTIALS_ERROR"; + } + | { + status: "INVALID_AUTHENTICATOR_ERROR"; + reason: string; + } + >; + static createRecoverAccountLink( + tenantId: string, + userId: string, + email: string, + userContext?: Record + ): Promise< + | { + status: "OK"; + link: string; + } + | { + status: "UNKNOWN_USER_ID_ERROR"; + } + >; + static sendRecoverAccountEmail( + tenantId: string, + userId: string, + email: string, + userContext?: Record + ): Promise<{ + status: "OK" | "UNKNOWN_USER_ID_ERROR"; + }>; + static sendEmail( + input: TypeWebauthnEmailDeliveryInput & { + userContext?: Record; + } + ): Promise; +} +export declare let init: typeof Recipe.init; +export declare let Error: typeof SuperTokensError; +export declare let registerOptions: typeof Wrapper.registerOptions; +export declare let signInOptions: typeof Wrapper.signInOptions; +export declare let signIn: typeof Wrapper.signIn; +export declare let verifyCredentials: typeof Wrapper.verifyCredentials; +export declare let generateRecoverAccountToken: typeof Wrapper.generateRecoverAccountToken; +export declare let recoverAccount: typeof Wrapper.recoverAccount; +export declare let consumeRecoverAccountToken: typeof Wrapper.consumeRecoverAccountToken; +export declare let registerCredential: typeof Wrapper.registerCredential; +export type { RecipeInterface, APIOptions, APIInterface }; +export declare let createRecoverAccountLink: typeof Wrapper.createRecoverAccountLink; +export declare let sendRecoverAccountEmail: typeof Wrapper.sendRecoverAccountEmail; +export declare let sendEmail: typeof Wrapper.sendEmail; diff --git a/lib/build/recipe/webauthn/index.js b/lib/build/recipe/webauthn/index.js new file mode 100644 index 000000000..041a297b2 --- /dev/null +++ b/lib/build/recipe/webauthn/index.js @@ -0,0 +1,228 @@ +"use strict"; +/* 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. + */ +var __importDefault = + (this && this.__importDefault) || + function (mod) { + return mod && mod.__esModule ? mod : { default: mod }; + }; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.sendEmail = exports.sendRecoverAccountEmail = exports.createRecoverAccountLink = exports.registerCredential = exports.consumeRecoverAccountToken = exports.recoverAccount = exports.generateRecoverAccountToken = exports.verifyCredentials = exports.signIn = exports.signInOptions = exports.registerOptions = exports.Error = exports.init = void 0; +const recipe_1 = __importDefault(require("./recipe")); +const error_1 = __importDefault(require("./error")); +const recipeUserId_1 = __importDefault(require("../../recipeUserId")); +const constants_1 = require("../multitenancy/constants"); +const utils_1 = require("./utils"); +const __1 = require("../.."); +const utils_2 = require("../../utils"); +const constants_2 = require("./constants"); +class Wrapper { + static async registerOptions( + email, + recoverAccountToken, + relyingPartyId, + relyingPartyName, + origin, + timeout, + attestation = "none", + tenantId, + userContext + ) { + let payload = email ? { email } : recoverAccountToken ? { recoverAccountToken } : null; + if (!payload) { + return { status: "INVALID_EMAIL_ERROR", err: "Email is missing" }; + } + return recipe_1.default.getInstanceOrThrowError().recipeInterfaceImpl.registerOptions( + Object.assign( + Object.assign( + { + requireResidentKey: constants_2.DEFAULT_REGISTER_OPTIONS_REQUIRE_RESIDENT_KEY, + residentKey: constants_2.DEFAULT_REGISTER_OPTIONS_RESIDENT_KEY, + userVerification: constants_2.DEFAULT_REGISTER_OPTIONS_USER_VERIFICATION, + supportedAlgorithmIds: constants_2.DEFAULT_REGISTER_OPTIONS_SUPPORTED_ALGORITHM_IDS, + }, + payload + ), + { + relyingPartyId, + relyingPartyName, + origin, + timeout, + attestation, + tenantId: tenantId === undefined ? constants_1.DEFAULT_TENANT_ID : tenantId, + userContext: utils_2.getUserContext(userContext), + } + ) + ); + } + static signInOptions(relyingPartyId, origin, timeout, tenantId, userContext) { + return recipe_1.default.getInstanceOrThrowError().recipeInterfaceImpl.signInOptions({ + userVerification: constants_2.DEFAULT_SIGNIN_OPTIONS_USER_VERIFICATION, + relyingPartyId, + origin, + timeout, + tenantId: tenantId === undefined ? constants_1.DEFAULT_TENANT_ID : tenantId, + userContext: utils_2.getUserContext(userContext), + }); + } + static signIn(tenantId, webauthnGeneratedOptionsId, credential, session, userContext) { + return recipe_1.default.getInstanceOrThrowError().recipeInterfaceImpl.signIn({ + webauthnGeneratedOptionsId, + credential, + session, + shouldTryLinkingWithSessionUser: !!session, + tenantId: tenantId === undefined ? constants_1.DEFAULT_TENANT_ID : tenantId, + userContext: utils_2.getUserContext(userContext), + }); + } + static async verifyCredentials(tenantId, webauthnGeneratedOptionsId, credential, userContext) { + const resp = await recipe_1.default.getInstanceOrThrowError().recipeInterfaceImpl.verifyCredentials({ + webauthnGeneratedOptionsId, + credential, + tenantId: tenantId === undefined ? constants_1.DEFAULT_TENANT_ID : tenantId, + userContext: utils_2.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, userId, email, userContext) { + return recipe_1.default.getInstanceOrThrowError().recipeInterfaceImpl.generateRecoverAccountToken({ + userId, + email, + tenantId: tenantId === undefined ? constants_1.DEFAULT_TENANT_ID : tenantId, + userContext: utils_2.getUserContext(userContext), + }); + } + static async recoverAccount(tenantId, webauthnGeneratedOptionsId, token, credential, userContext) { + const consumeResp = await Wrapper.consumeRecoverAccountToken(tenantId, token, userContext); + if (consumeResp.status !== "OK") { + return consumeResp; + } + let result = await Wrapper.registerCredential({ + recipeUserId: new recipeUserId_1.default(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, token, userContext) { + return recipe_1.default.getInstanceOrThrowError().recipeInterfaceImpl.consumeRecoverAccountToken({ + token, + tenantId: tenantId === undefined ? constants_1.DEFAULT_TENANT_ID : tenantId, + userContext: utils_2.getUserContext(userContext), + }); + } + static registerCredential(input) { + return recipe_1.default + .getInstanceOrThrowError() + .recipeInterfaceImpl.registerCredential( + Object.assign(Object.assign({}, input), { userContext: utils_2.getUserContext(input.userContext) }) + ); + } + static async createRecoverAccountLink(tenantId, userId, email, userContext) { + const ctx = utils_2.getUserContext(userContext); + let token = await this.generateRecoverAccountToken(tenantId, userId, email, ctx); + if (token.status === "UNKNOWN_USER_ID_ERROR") { + return token; + } + const recipeInstance = recipe_1.default.getInstanceOrThrowError(); + return { + status: "OK", + link: utils_1.getRecoverAccountLink({ + appInfo: recipeInstance.getAppInfo(), + token: token.token, + tenantId: tenantId === undefined ? constants_1.DEFAULT_TENANT_ID : tenantId, + request: __1.getRequestFromUserContext(ctx), + userContext: ctx, + }), + }; + } + static async sendRecoverAccountEmail(tenantId, userId, email, userContext) { + const user = await __1.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 exports.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) { + let recipeInstance = recipe_1.default.getInstanceOrThrowError(); + return await recipeInstance.emailDelivery.ingredientInterfaceImpl.sendEmail( + Object.assign(Object.assign({}, input), { + tenantId: input.tenantId === undefined ? constants_1.DEFAULT_TENANT_ID : input.tenantId, + userContext: utils_2.getUserContext(input.userContext), + }) + ); + } +} +exports.default = Wrapper; +Wrapper.init = recipe_1.default.init; +Wrapper.Error = error_1.default; +exports.init = Wrapper.init; +exports.Error = Wrapper.Error; +exports.registerOptions = Wrapper.registerOptions; +exports.signInOptions = Wrapper.signInOptions; +exports.signIn = Wrapper.signIn; +exports.verifyCredentials = Wrapper.verifyCredentials; +exports.generateRecoverAccountToken = Wrapper.generateRecoverAccountToken; +exports.recoverAccount = Wrapper.recoverAccount; +exports.consumeRecoverAccountToken = Wrapper.consumeRecoverAccountToken; +exports.registerCredential = Wrapper.registerCredential; +exports.createRecoverAccountLink = Wrapper.createRecoverAccountLink; +exports.sendRecoverAccountEmail = Wrapper.sendRecoverAccountEmail; +exports.sendEmail = Wrapper.sendEmail; diff --git a/lib/build/recipe/webauthn/recipe.d.ts b/lib/build/recipe/webauthn/recipe.d.ts new file mode 100644 index 000000000..12c1bf86c --- /dev/null +++ b/lib/build/recipe/webauthn/recipe.d.ts @@ -0,0 +1,43 @@ +// @ts-nocheck +import RecipeModule from "../../recipeModule"; +import { TypeInput, TypeNormalisedInput, RecipeInterface, APIInterface } from "./types"; +import { NormalisedAppinfo, APIHandled, HTTPMethod, RecipeListFunction, UserContext } from "../../types"; +import STError from "./error"; +import NormalisedURLPath from "../../normalisedURLPath"; +import type { BaseRequest, BaseResponse } from "../../framework"; +import EmailDeliveryIngredient from "../../ingredients/emaildelivery"; +import { TypeWebauthnEmailDeliveryInput } from "./types"; +export default class Recipe extends RecipeModule { + private static instance; + static RECIPE_ID: string; + config: TypeNormalisedInput; + recipeInterfaceImpl: RecipeInterface; + apiImpl: APIInterface; + isInServerlessEnv: boolean; + emailDelivery: EmailDeliveryIngredient; + constructor( + recipeId: string, + appInfo: NormalisedAppinfo, + isInServerlessEnv: boolean, + config: TypeInput | undefined, + ingredients: { + emailDelivery: EmailDeliveryIngredient | undefined; + } + ); + static getInstanceOrThrowError(): Recipe; + static init(config?: TypeInput): RecipeListFunction; + static reset(): void; + getAPIsHandled: () => APIHandled[]; + handleAPIRequest: ( + id: string, + tenantId: string, + req: BaseRequest, + res: BaseResponse, + _path: NormalisedURLPath, + _method: HTTPMethod, + userContext: UserContext + ) => Promise; + handleError: (err: STError, _request: BaseRequest, response: BaseResponse) => Promise; + getAllCORSHeaders: () => string[]; + isErrorFromThisRecipe: (err: any) => err is STError; +} diff --git a/lib/build/recipe/webauthn/recipe.js b/lib/build/recipe/webauthn/recipe.js new file mode 100644 index 000000000..f3a82ef7c --- /dev/null +++ b/lib/build/recipe/webauthn/recipe.js @@ -0,0 +1,313 @@ +"use strict"; +/* 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. + */ +var __importDefault = + (this && this.__importDefault) || + function (mod) { + return mod && mod.__esModule ? mod : { default: mod }; + }; +Object.defineProperty(exports, "__esModule", { value: true }); +const recipeModule_1 = __importDefault(require("../../recipeModule")); +const error_1 = __importDefault(require("./error")); +const utils_1 = require("./utils"); +const normalisedURLPath_1 = __importDefault(require("../../normalisedURLPath")); +const constants_1 = require("./constants"); +const signup_1 = __importDefault(require("./api/signup")); +const signin_1 = __importDefault(require("./api/signin")); +const registerOptions_1 = __importDefault(require("./api/registerOptions")); +const signInOptions_1 = __importDefault(require("./api/signInOptions")); +const generateRecoverAccountToken_1 = __importDefault(require("./api/generateRecoverAccountToken")); +const recoverAccount_1 = __importDefault(require("./api/recoverAccount")); +const emailExists_1 = __importDefault(require("./api/emailExists")); +const utils_2 = require("../../utils"); +const recipeImplementation_1 = __importDefault(require("./recipeImplementation")); +const implementation_1 = __importDefault(require("./api/implementation")); +const supertokens_js_override_1 = __importDefault(require("supertokens-js-override")); +const emaildelivery_1 = __importDefault(require("../../ingredients/emaildelivery")); +const postSuperTokensInitCallbacks_1 = require("../../postSuperTokensInitCallbacks"); +const recipe_1 = __importDefault(require("../multifactorauth/recipe")); +const recipe_2 = __importDefault(require("../multitenancy/recipe")); +const utils_3 = require("../thirdparty/utils"); +const multifactorauth_1 = require("../multifactorauth"); +const core_mock_1 = require("./core-mock"); +class Recipe extends recipeModule_1.default { + constructor(recipeId, appInfo, isInServerlessEnv, config, ingredients) { + super(recipeId, appInfo); + // abstract instance functions below............... + this.getAPIsHandled = () => { + return [ + { + method: "post", + pathWithoutApiBasePath: new normalisedURLPath_1.default(constants_1.REGISTER_OPTIONS_API), + id: constants_1.REGISTER_OPTIONS_API, + disabled: this.apiImpl.registerOptionsPOST === undefined, + }, + { + method: "post", + pathWithoutApiBasePath: new normalisedURLPath_1.default(constants_1.SIGNIN_OPTIONS_API), + id: constants_1.SIGNIN_OPTIONS_API, + disabled: this.apiImpl.signInOptionsPOST === undefined, + }, + { + method: "post", + pathWithoutApiBasePath: new normalisedURLPath_1.default(constants_1.SIGN_UP_API), + id: constants_1.SIGN_UP_API, + disabled: this.apiImpl.signUpPOST === undefined, + }, + { + method: "post", + pathWithoutApiBasePath: new normalisedURLPath_1.default(constants_1.SIGN_IN_API), + id: constants_1.SIGN_IN_API, + disabled: this.apiImpl.signInPOST === undefined, + }, + { + method: "post", + pathWithoutApiBasePath: new normalisedURLPath_1.default( + constants_1.GENERATE_RECOVER_ACCOUNT_TOKEN_API + ), + id: constants_1.GENERATE_RECOVER_ACCOUNT_TOKEN_API, + disabled: this.apiImpl.generateRecoverAccountTokenPOST === undefined, + }, + { + method: "post", + pathWithoutApiBasePath: new normalisedURLPath_1.default(constants_1.RECOVER_ACCOUNT_API), + id: constants_1.RECOVER_ACCOUNT_API, + disabled: this.apiImpl.recoverAccountPOST === undefined, + }, + { + method: "get", + pathWithoutApiBasePath: new normalisedURLPath_1.default(constants_1.SIGNUP_EMAIL_EXISTS_API), + id: constants_1.SIGNUP_EMAIL_EXISTS_API, + disabled: this.apiImpl.emailExistsGET === undefined, + }, + ]; + }; + this.handleAPIRequest = async (id, tenantId, req, res, _path, _method, userContext) => { + let options = { + config: this.config, + recipeId: this.getRecipeId(), + isInServerlessEnv: this.isInServerlessEnv, + recipeImplementation: this.recipeInterfaceImpl, + req, + res, + emailDelivery: this.emailDelivery, + appInfo: this.getAppInfo(), + }; + if (id === constants_1.REGISTER_OPTIONS_API) { + return await registerOptions_1.default(this.apiImpl, tenantId, options, userContext); + } else if (id === constants_1.SIGNIN_OPTIONS_API) { + return await signInOptions_1.default(this.apiImpl, tenantId, options, userContext); + } else if (id === constants_1.SIGN_UP_API) { + return await signup_1.default(this.apiImpl, tenantId, options, userContext); + } else if (id === constants_1.SIGN_IN_API) { + return await signin_1.default(this.apiImpl, tenantId, options, userContext); + } else if (id === constants_1.GENERATE_RECOVER_ACCOUNT_TOKEN_API) { + return await generateRecoverAccountToken_1.default(this.apiImpl, tenantId, options, userContext); + } else if (id === constants_1.RECOVER_ACCOUNT_API) { + return await recoverAccount_1.default(this.apiImpl, tenantId, options, userContext); + } else if (id === constants_1.SIGNUP_EMAIL_EXISTS_API) { + return await emailExists_1.default(this.apiImpl, tenantId, options, userContext); + } else return false; + }; + this.handleError = async (err, _request, response) => { + if (err.fromRecipe === Recipe.RECIPE_ID) { + if (err.type === error_1.default.FIELD_ERROR) { + return utils_2.send200Response(response, { + status: "FIELD_ERROR", + formFields: err.payload, + }); + } else { + throw err; + } + } else { + throw err; + } + }; + this.getAllCORSHeaders = () => { + return []; + }; + this.isErrorFromThisRecipe = (err) => { + return error_1.default.isErrorFromSuperTokens(err) && err.fromRecipe === Recipe.RECIPE_ID; + }; + this.isInServerlessEnv = isInServerlessEnv; + this.config = utils_1.validateAndNormaliseUserInput(this, appInfo, config); + { + const getWebauthnConfig = () => this.config; + // const querier = Querier.getNewInstanceOrThrowError(recipeId); + const querier = core_mock_1.getMockQuerier(recipeId); + let builder = new supertokens_js_override_1.default( + recipeImplementation_1.default(querier, getWebauthnConfig) + ); + this.recipeInterfaceImpl = builder.override(this.config.override.functions).build(); + } + { + let builder = new supertokens_js_override_1.default(implementation_1.default()); + this.apiImpl = builder.override(this.config.override.apis).build(); + } + /** + * emailDelivery will always needs to be declared after isInServerlessEnv + * and recipeInterfaceImpl values are set + */ + this.emailDelivery = + ingredients.emailDelivery === undefined + ? new emaildelivery_1.default(this.config.getEmailDeliveryConfig(this.isInServerlessEnv)) + : ingredients.emailDelivery; + // todo check correctness + postSuperTokensInitCallbacks_1.PostSuperTokensInitCallbacks.addPostInitCallback(() => { + const mfaInstance = recipe_1.default.getInstance(); + if (mfaInstance !== undefined) { + mfaInstance.addFuncToGetAllAvailableSecondaryFactorIdsFromOtherRecipes(() => { + return ["webauthn"]; + }); + mfaInstance.addFuncToGetFactorsSetupForUserFromOtherRecipes(async (user) => { + for (const loginMethod of user.loginMethods) { + // We don't check for tenantId here because if we find the user + // with emailpassword loginMethod from different tenant, then + // we assume the factor is setup for this user. And as part of factor + // completion, we associate that loginMethod with the session's tenantId + if (loginMethod.recipeId === Recipe.RECIPE_ID) { + return ["webauthn"]; + } + } + return []; + }); + mfaInstance.addFuncToGetEmailsForFactorFromOtherRecipes((user, sessionRecipeUserId) => { + // This function is called in the MFA info endpoint API. + // Based on https://github.com/supertokens/supertokens-node/pull/741#discussion_r1432749346 + // preparing some reusable variables for the logic below... + let sessionLoginMethod = user.loginMethods.find((lM) => { + return lM.recipeUserId.getAsString() === sessionRecipeUserId.getAsString(); + }); + if (sessionLoginMethod === undefined) { + // this can happen maybe cause this login method + // was unlinked from the user or deleted entirely... + return { + status: "UNKNOWN_SESSION_RECIPE_USER_ID", + }; + } + // We order the login methods based on timeJoined (oldest first) + const orderedLoginMethodsByTimeJoinedOldestFirst = user.loginMethods.sort((a, b) => { + return a.timeJoined - b.timeJoined; + }); + // Then we take the ones that belong to this recipe + const recipeLoginMethodsOrderedByTimeJoinedOldestFirst = orderedLoginMethodsByTimeJoinedOldestFirst.filter( + (lm) => lm.recipeId === Recipe.RECIPE_ID + ); + let result; + if (recipeLoginMethodsOrderedByTimeJoinedOldestFirst.length !== 0) { + // If there are login methods belonging to this recipe, the factor is set up + // In this case we only list email addresses that have a password associated with them + result = [ + // First we take the verified real emails associated with emailpassword login methods ordered by timeJoined (oldest first) + ...recipeLoginMethodsOrderedByTimeJoinedOldestFirst + .filter((lm) => !utils_3.isFakeEmail(lm.email) && lm.verified === true) + .map((lm) => lm.email), + // Then we take the non-verified real emails associated with emailpassword login methods ordered by timeJoined (oldest first) + ...recipeLoginMethodsOrderedByTimeJoinedOldestFirst + .filter((lm) => !utils_3.isFakeEmail(lm.email) && lm.verified === false) + .map((lm) => lm.email), + // Lastly, fake emails associated with emailpassword login methods ordered by timeJoined (oldest first) + // We also add these into the list because they already have a password added to them so they can be a valid choice when signing in + // We do not want to remove the previously added "MFA password", because a new email password user was linked + // E.g.: + // 1. A discord user adds a password for MFA (which will use the fake email associated with the discord user) + // 2. Later they also sign up and (manually) link a full emailpassword user that they intend to use as a first factor + // 3. The next time they sign in using Discord, they could be asked for a secondary password. + // In this case, they'd be checked against the first user that they originally created for MFA, not the one later linked to the account + ...recipeLoginMethodsOrderedByTimeJoinedOldestFirst + .filter((lm) => utils_3.isFakeEmail(lm.email)) + .map((lm) => lm.email), + ]; + // We handle moving the session email to the top of the list later + } else { + // This factor hasn't been set up, we list all emails belonging to the user + if ( + orderedLoginMethodsByTimeJoinedOldestFirst.some( + (lm) => lm.email !== undefined && !utils_3.isFakeEmail(lm.email) + ) + ) { + // If there is at least one real email address linked to the user, we only suggest real addresses + result = orderedLoginMethodsByTimeJoinedOldestFirst + .filter((lm) => lm.email !== undefined && !utils_3.isFakeEmail(lm.email)) + .map((lm) => lm.email); + } else { + // Else we use the fake ones + result = orderedLoginMethodsByTimeJoinedOldestFirst + .filter((lm) => lm.email !== undefined && utils_3.isFakeEmail(lm.email)) + .map((lm) => lm.email); + } + // We handle moving the session email to the top of the list later + // Since in this case emails are not guaranteed to be unique, we de-duplicate the results, keeping the oldest one in the list. + // The Set constructor keeps the original insertion order (OrderedByTimeJoinedOldestFirst), but de-duplicates the items, + // keeping the first one added (so keeping the older one if there are two entries with the same email) + // e.g.: [4,2,3,2,1] -> [4,2,3,1] + result = Array.from(new Set(result)); + } + // If the loginmethod associated with the session has an email address, we move it to the top of the list (if it's already in the list) + if (sessionLoginMethod.email !== undefined && result.includes(sessionLoginMethod.email)) { + result = [ + sessionLoginMethod.email, + ...result.filter((email) => email !== sessionLoginMethod.email), + ]; + } + // 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. + // Custom implementations can choose to ignore this, and ask the user for the email anyway. + if (result.length === 0) { + result.push(`${sessionRecipeUserId.getAsString()}@stfakeemail.supertokens.com`); + } + return { + status: "OK", + factorIdToEmailsMap: { + webauthn: result, + }, + }; + }); + } + const mtRecipe = recipe_2.default.getInstance(); + if (mtRecipe !== undefined) { + mtRecipe.allAvailableFirstFactors.push(multifactorauth_1.FactorIds.WEBAUTHN); + } + }); + } + static getInstanceOrThrowError() { + if (Recipe.instance !== undefined) { + return Recipe.instance; + } + throw new Error("Initialisation not done. Did you forget to call the Webauthn.init function?"); + } + static init(config) { + return (appInfo, isInServerlessEnv) => { + if (Recipe.instance === undefined) { + Recipe.instance = new Recipe(Recipe.RECIPE_ID, appInfo, isInServerlessEnv, config, { + emailDelivery: undefined, + }); + return Recipe.instance; + } else { + throw new Error("Webauthn recipe has already been initialised. Please check your code for bugs."); + } + }; + } + static reset() { + if (!utils_2.isTestEnv()) { + throw new Error("calling testing function in non testing env"); + } + Recipe.instance = undefined; + } +} +exports.default = Recipe; +Recipe.instance = undefined; +Recipe.RECIPE_ID = "webauthn"; diff --git a/lib/build/recipe/webauthn/recipeImplementation.d.ts b/lib/build/recipe/webauthn/recipeImplementation.d.ts new file mode 100644 index 000000000..b6421a667 --- /dev/null +++ b/lib/build/recipe/webauthn/recipeImplementation.d.ts @@ -0,0 +1,7 @@ +// @ts-nocheck +import { RecipeInterface, TypeNormalisedInput } from "./types"; +import { Querier } from "../../querier"; +export default function getRecipeInterface( + querier: Querier, + getWebauthnConfig: () => TypeNormalisedInput +): RecipeInterface; diff --git a/lib/build/recipe/webauthn/recipeImplementation.js b/lib/build/recipe/webauthn/recipeImplementation.js new file mode 100644 index 000000000..f664098c1 --- /dev/null +++ b/lib/build/recipe/webauthn/recipeImplementation.js @@ -0,0 +1,404 @@ +"use strict"; +var __createBinding = + (this && this.__createBinding) || + (Object.create + ? function (o, m, k, k2) { + if (k2 === undefined) k2 = k; + Object.defineProperty(o, k2, { + enumerable: true, + get: function () { + return m[k]; + }, + }); + } + : function (o, m, k, k2) { + if (k2 === undefined) k2 = k; + o[k2] = m[k]; + }); +var __setModuleDefault = + (this && this.__setModuleDefault) || + (Object.create + ? function (o, v) { + Object.defineProperty(o, "default", { enumerable: true, value: v }); + } + : function (o, v) { + o["default"] = v; + }); +var __importStar = + (this && this.__importStar) || + function (mod) { + if (mod && mod.__esModule) return mod; + var result = {}; + if (mod != null) + for (var k in mod) + if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); + __setModuleDefault(result, mod); + return result; + }; +var __rest = + (this && this.__rest) || + function (s, e) { + var t = {}; + for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) t[p] = s[p]; + if (s != null && typeof Object.getOwnPropertySymbols === "function") + for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) { + if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) t[p[i]] = s[p[i]]; + } + return t; + }; +var __importDefault = + (this && this.__importDefault) || + function (mod) { + return mod && mod.__esModule ? mod : { default: mod }; + }; +Object.defineProperty(exports, "__esModule", { value: true }); +const recipe_1 = __importDefault(require("../accountlinking/recipe")); +const normalisedURLPath_1 = __importDefault(require("../../normalisedURLPath")); +const __1 = require("../.."); +const recipeUserId_1 = __importDefault(require("../../recipeUserId")); +const constants_1 = require("../multitenancy/constants"); +const user_1 = require("../../user"); +const authUtils_1 = require("../../authUtils"); +const jose = __importStar(require("jose")); +function getRecipeInterface(querier, getWebauthnConfig) { + return { + registerOptions: async function (_a) { + var { + relyingPartyId, + relyingPartyName, + origin, + timeout, + attestation = "none", + tenantId, + userContext, + supportedAlgorithmIds, + } = _a, + rest = __rest(_a, [ + "relyingPartyId", + "relyingPartyName", + "origin", + "timeout", + "attestation", + "tenantId", + "userContext", + "supportedAlgorithmIds", + ]); + const emailInput = "email" in rest ? rest.email : undefined; + const recoverAccountTokenInput = "recoverAccountToken" in rest ? rest.recoverAccountToken : undefined; + let email; + if (emailInput !== undefined) { + email = emailInput; + } else if (recoverAccountTokenInput !== undefined) { + // 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 of the token will be done during consumeRecoverAccountToken + let decoded; + try { + decoded = await jose.decodeJwt(recoverAccountTokenInput); + } catch (e) { + console.error(e); + return { + status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR", + }; + } + email = decoded === null || decoded === void 0 ? void 0 : decoded.email; + } + if (!email) { + return { + status: "INVALID_EMAIL_ERROR", + err: "The email is missing", + }; + } + const err = await getWebauthnConfig().validateEmailAddress(email, tenantId); + if (err) { + return { + status: "INVALID_EMAIL_ERROR", + err, + }; + } + return await querier.sendPostRequest( + new normalisedURLPath_1.default( + `/${ + tenantId === undefined ? constants_1.DEFAULT_TENANT_ID : tenantId + }/recipe/webauthn/options/register` + ), + { + email, + relyingPartyName, + relyingPartyId, + origin, + timeout, + attestation, + supportedAlgorithmIds, + }, + userContext + ); + }, + signInOptions: async function ({ relyingPartyId, origin, timeout, tenantId, userContext }) { + // the input user ID can be a recipe or a primary user ID. + return await querier.sendPostRequest( + new normalisedURLPath_1.default( + `/${ + tenantId === undefined ? constants_1.DEFAULT_TENANT_ID : tenantId + }/recipe/webauthn/options/signin` + ), + { + relyingPartyId, + origin, + timeout, + }, + userContext + ); + }, + signUp: async function ({ + webauthnGeneratedOptionsId, + credential, + tenantId, + session, + shouldTryLinkingWithSessionUser, + userContext, + }) { + const response = await this.createNewRecipeUser({ + credential, + webauthnGeneratedOptionsId, + tenantId, + userContext, + }); + if (response.status !== "OK") { + return response; + } + let updatedUser = response.user; + const linkResult = await authUtils_1.AuthUtils.linkToSessionIfRequiredElseCreatePrimaryUserIdOrLinkByAccountInfo( + { + tenantId, + inputUser: response.user, + recipeUserId: response.recipeUserId, + session, + shouldTryLinkingWithSessionUser, + userContext, + } + ); + if (linkResult.status != "OK") { + return linkResult; + } + updatedUser = linkResult.user; + return { + status: "OK", + user: updatedUser, + recipeUserId: response.recipeUserId, + }; + }, + signIn: async function ({ + credential, + webauthnGeneratedOptionsId, + tenantId, + session, + shouldTryLinkingWithSessionUser, + userContext, + }) { + const response = await this.verifyCredentials({ + credential, + webauthnGeneratedOptionsId, + tenantId, + userContext, + }); + if (response.status !== "OK") { + return response; + } + const loginMethod = response.user.loginMethods.find( + (lm) => lm.recipeUserId.getAsString() === response.recipeUserId.getAsString() + ); + if (!loginMethod.verified) { + await recipe_1.default.getInstance().verifyEmailForRecipeUserIfLinkedAccountsAreVerified({ + user: response.user, + recipeUserId: response.recipeUserId, + userContext, + }); + // Unlike in the sign up recipe function, we do not do account linking here + // cause we do not want sign in to change the potentially user ID of a user + // due to linking when this function is called by the dev in their API - + // for example in their update password API. If we did account linking + // then we would have to ask the dev to also change the session + // in such API calls. + // In the case of sign up, since we are creating a new user, it's fine + // to link there since there is no user id change really from the dev's + // point of view who is calling the sign up recipe function. + // We do this so that we get the updated user (in case the above + // function updated the verification status) and can return that + response.user = await __1.getUser(response.recipeUserId.getAsString(), userContext); + } + const linkResult = await authUtils_1.AuthUtils.linkToSessionIfRequiredElseCreatePrimaryUserIdOrLinkByAccountInfo( + { + tenantId, + inputUser: response.user, + recipeUserId: response.recipeUserId, + session, + shouldTryLinkingWithSessionUser, + userContext, + } + ); + if (linkResult.status === "LINKING_TO_SESSION_USER_FAILED") { + return linkResult; + } + response.user = linkResult.user; + return response; + }, + verifyCredentials: async function ({ credential, webauthnGeneratedOptionsId, tenantId, userContext }) { + const response = await querier.sendPostRequest( + new normalisedURLPath_1.default( + `/${tenantId === undefined ? constants_1.DEFAULT_TENANT_ID : tenantId}/recipe/webauthn/signin` + ), + { + credential, + webauthnGeneratedOptionsId, + }, + userContext + ); + if (response.status === "OK") { + return { + status: "OK", + user: new user_1.User(response.user), + recipeUserId: new recipeUserId_1.default(response.recipeUserId), + }; + } + return { + status: "WRONG_CREDENTIALS_ERROR", + }; + }, + createNewRecipeUser: async function (input) { + const resp = await querier.sendPostRequest( + new normalisedURLPath_1.default( + `/${ + input.tenantId === undefined ? constants_1.DEFAULT_TENANT_ID : input.tenantId + }/recipe/webauthn/signup` + ), + { + webauthnGeneratedOptionsId: input.webauthnGeneratedOptionsId, + credential: input.credential, + }, + input.userContext + ); + if (resp.status === "OK") { + return { + status: "OK", + user: new user_1.User(resp.user), + recipeUserId: new recipeUserId_1.default(resp.recipeUserId), + }; + } + return resp; + }, + generateRecoverAccountToken: async function ({ userId, email, tenantId, userContext }) { + // the input user ID can be a recipe or a primary user ID. + return await querier.sendPostRequest( + new normalisedURLPath_1.default( + `/${ + tenantId === undefined ? constants_1.DEFAULT_TENANT_ID : tenantId + }/recipe/webauthn/user/recover/token` + ), + { + userId, + email, + }, + userContext + ); + }, + consumeRecoverAccountToken: async function ({ token, tenantId, userContext }) { + return await querier.sendPostRequest( + new normalisedURLPath_1.default( + `/${ + tenantId === undefined ? constants_1.DEFAULT_TENANT_ID : tenantId + }/recipe/webauthn/user/recover/token/consume` + ), + { + token, + }, + userContext + ); + }, + registerCredential: async function ({ webauthnGeneratedOptionsId, credential, userContext, recipeUserId }) { + return await querier.sendPostRequest( + new normalisedURLPath_1.default(`/recipe/webauthn/user/${recipeUserId}/credential/register`), + { + webauthnGeneratedOptionsId, + credential, + }, + userContext + ); + }, + decodeCredential: async function ({ credential, userContext }) { + const response = await querier.sendPostRequest( + new normalisedURLPath_1.default(`/recipe/webauthn/credential/decode`), + { + credential, + }, + userContext + ); + if (response.status === "OK") { + return response; + } + return { + status: "WRONG_CREDENTIALS_ERROR", + }; + }, + getUserFromRecoverAccountToken: async function ({ token, tenantId, userContext }) { + return await querier.sendGetRequest( + new normalisedURLPath_1.default( + `/${ + tenantId === undefined ? constants_1.DEFAULT_TENANT_ID : tenantId + }/recipe/webauthn/user/recover/token/${token}` + ), + {}, + userContext + ); + }, + removeCredential: async function ({ webauthnCredentialId, recipeUserId, userContext }) { + return await querier.sendDeleteRequest( + new normalisedURLPath_1.default( + `/recipe/webauthn/user/${recipeUserId}/credential/${webauthnCredentialId}` + ), + {}, + {}, + userContext + ); + }, + getCredential: async function ({ webauthnCredentialId, recipeUserId, userContext }) { + return await querier.sendGetRequest( + new normalisedURLPath_1.default( + `/recipe/webauthn/user/${recipeUserId}/credential/${webauthnCredentialId}` + ), + {}, + userContext + ); + }, + listCredentials: async function ({ recipeUserId, userContext }) { + return await querier.sendGetRequest( + new normalisedURLPath_1.default(`/recipe/webauthn/user/${recipeUserId}/credential/list`), + {}, + userContext + ); + }, + removeGeneratedOptions: async function ({ webauthnGeneratedOptionsId, tenantId, userContext }) { + return await querier.sendDeleteRequest( + new normalisedURLPath_1.default( + `/${ + tenantId === undefined ? constants_1.DEFAULT_TENANT_ID : tenantId + }/recipe/webauthn/options/${webauthnGeneratedOptionsId}` + ), + {}, + {}, + userContext + ); + }, + getGeneratedOptions: async function ({ webauthnGeneratedOptionsId, tenantId, userContext }) { + return await querier.sendGetRequest( + new normalisedURLPath_1.default( + `/${ + tenantId === undefined ? constants_1.DEFAULT_TENANT_ID : tenantId + }/recipe/webauthn/options/${webauthnGeneratedOptionsId}` + ), + {}, + userContext + ); + }, + }; +} +exports.default = getRecipeInterface; diff --git a/lib/build/recipe/webauthn/types.d.ts b/lib/build/recipe/webauthn/types.d.ts new file mode 100644 index 000000000..ff545f52b --- /dev/null +++ b/lib/build/recipe/webauthn/types.d.ts @@ -0,0 +1,673 @@ +// @ts-nocheck +import type { BaseRequest, BaseResponse } from "../../framework"; +import OverrideableBuilder from "supertokens-js-override"; +import { SessionContainerInterface } from "../session/types"; +import { + TypeInput as EmailDeliveryTypeInput, + TypeInputWithService as EmailDeliveryTypeInputWithService, +} from "../../ingredients/emaildelivery/types"; +import EmailDeliveryIngredient from "../../ingredients/emaildelivery"; +import { GeneralErrorResponse, NormalisedAppinfo, User, UserContext } from "../../types"; +import RecipeUserId from "../../recipeUserId"; +export declare type TypeNormalisedInput = { + relyingPartyId: TypeNormalisedInputRelyingPartyId; + relyingPartyName: TypeNormalisedInputRelyingPartyName; + getOrigin: TypeNormalisedInputGetOrigin; + getEmailDeliveryConfig: ( + isInServerlessEnv: boolean + ) => EmailDeliveryTypeInputWithService; + validateEmailAddress: TypeNormalisedInputValidateEmailAddress; + override: { + functions: ( + originalImplementation: RecipeInterface, + builder?: OverrideableBuilder + ) => RecipeInterface; + apis: (originalImplementation: APIInterface, builder?: OverrideableBuilder) => APIInterface; + }; +}; +export declare type TypeNormalisedInputRelyingPartyId = (input: { + tenantId: string; + request: BaseRequest | undefined; + userContext: UserContext; +}) => Promise; +export declare type TypeNormalisedInputRelyingPartyName = (input: { + tenantId: string; + userContext: UserContext; +}) => Promise; +export declare type TypeNormalisedInputGetOrigin = (input: { + tenantId: string; + request: BaseRequest; + userContext: UserContext; +}) => Promise; +export declare type TypeNormalisedInputValidateEmailAddress = ( + email: string, + tenantId: string +) => Promise | string | undefined; +export declare type TypeInput = { + emailDelivery?: EmailDeliveryTypeInput; + relyingPartyId?: TypeInputRelyingPartyId; + relyingPartyName?: TypeInputRelyingPartyName; + validateEmailAddress?: TypeInputValidateEmailAddress; + getOrigin?: TypeInputGetOrigin; + override?: { + functions?: ( + originalImplementation: RecipeInterface, + builder?: OverrideableBuilder + ) => RecipeInterface; + apis?: (originalImplementation: APIInterface, builder?: OverrideableBuilder) => APIInterface; + }; +}; +export declare type TypeInputRelyingPartyId = + | string + | ((input: { tenantId: string; request: BaseRequest | undefined; userContext: UserContext }) => Promise); +export declare type TypeInputRelyingPartyName = + | string + | ((input: { tenantId: string; userContext: UserContext }) => Promise); +export declare type TypeInputGetOrigin = (input: { + tenantId: string; + request: BaseRequest; + userContext: UserContext; +}) => Promise; +export declare type TypeInputValidateEmailAddress = ( + email: string, + tenantId: string +) => Promise | string | undefined; +declare type Base64URLString = string; +export declare type RecipeInterface = { + registerOptions( + input: { + relyingPartyId: string; + relyingPartyName: string; + origin: string; + requireResidentKey: boolean | undefined; + residentKey: "required" | "preferred" | "discouraged" | undefined; + userVerification: "required" | "preferred" | "discouraged" | undefined; + attestation: "none" | "indirect" | "direct" | "enterprise" | undefined; + supportedAlgorithmIds: number[] | undefined; + timeout: number | undefined; + tenantId: string; + userContext: UserContext; + } & ( + | { + 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: "INVALID_EMAIL_ERROR"; + err: string; + } + >; + signInOptions(input: { + email?: string; + relyingPartyId: string; + origin: string; + userVerification: "required" | "preferred" | "discouraged" | undefined; + timeout: number | undefined; + tenantId: string; + userContext: UserContext; + }): Promise< + | { + status: "OK"; + webauthnGeneratedOptionsId: string; + challenge: string; + timeout: number; + userVerification: "required" | "preferred" | "discouraged"; + } + | { + status: "WRONG_CREDENTIALS_ERROR"; + } + >; + signUp(input: { + webauthnGeneratedOptionsId: string; + credential: CredentialPayload; + session: SessionContainerInterface | undefined; + shouldTryLinkingWithSessionUser: boolean | undefined; + tenantId: string; + userContext: UserContext; + }): Promise< + | { + status: "OK"; + user: User; + recipeUserId: RecipeUserId; + } + | { + status: "EMAIL_ALREADY_EXISTS_ERROR"; + } + | { + status: "WRONG_CREDENTIALS_ERROR"; + } + | { + status: "INVALID_AUTHENTICATOR_ERROR"; + reason: string; + } + | { + 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"; + } + >; + signIn(input: { + webauthnGeneratedOptionsId: string; + credential: CredentialPayload; + session: SessionContainerInterface | undefined; + shouldTryLinkingWithSessionUser: boolean | undefined; + tenantId: string; + userContext: UserContext; + }): 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"; + } + >; + verifyCredentials(input: { + webauthnGeneratedOptionsId: string; + credential: CredentialPayload; + tenantId: string; + userContext: UserContext; + }): Promise< + | { + status: "OK"; + user: User; + recipeUserId: RecipeUserId; + } + | { + status: "WRONG_CREDENTIALS_ERROR"; + } + >; + createNewRecipeUser(input: { + webauthnGeneratedOptionsId: string; + credential: CredentialPayload; + tenantId: string; + userContext: UserContext; + }): Promise< + | { + status: "OK"; + user: User; + recipeUserId: RecipeUserId; + } + | { + status: "WRONG_CREDENTIALS_ERROR"; + } + | { + status: "INVALID_AUTHENTICATOR_ERROR"; + reason: string; + } + | { + status: "EMAIL_ALREADY_EXISTS_ERROR"; + } + >; + /** + * We pass in the email as well to this function cause the input userId + * may not be associated with an webauthn account. In this case, we + * need to know which email to use to create an webauthn account later on. + */ + generateRecoverAccountToken(input: { + userId: string; + email: string; + tenantId: string; + userContext: UserContext; + }): Promise< + | { + status: "OK"; + token: string; + } + | { + status: "UNKNOWN_USER_ID_ERROR"; + } + >; + consumeRecoverAccountToken(input: { + token: string; + tenantId: string; + userContext: UserContext; + }): Promise< + | { + status: "OK"; + email: string; + userId: string; + } + | { + status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR"; + } + >; + registerCredential(input: { + webauthnGeneratedOptionsId: string; + credential: CredentialPayload; + userContext: UserContext; + recipeUserId: RecipeUserId; + }): Promise< + | { + status: "OK"; + } + | { + status: "WRONG_CREDENTIALS_ERROR"; + } + | { + status: "INVALID_AUTHENTICATOR_ERROR"; + reason: string; + } + >; + decodeCredential(input: { + credential: CredentialPayload; + userContext: UserContext; + }): Promise< + | { + status: "OK"; + credential: { + id: string; + rawId: string; + response: { + clientDataJSON: { + type: string; + challenge: string; + origin: string; + crossOrigin?: boolean; + tokenBinding?: { + id?: string; + status: "present" | "supported" | "not-supported"; + }; + }; + attestationObject: { + fmt: "packed" | "tpm" | "android-key" | "android-safetynet" | "fido-u2f" | "none"; + authData: { + rpIdHash: string; + flags: { + up: boolean; + uv: boolean; + be: boolean; + bs: boolean; + at: boolean; + ed: boolean; + flagsInt: number; + }; + counter: number; + aaguid?: string; + credentialID?: string; + credentialPublicKey?: string; + extensionsData?: unknown; + }; + attStmt: { + sig?: Base64URLString; + x5c?: Base64URLString[]; + response?: Base64URLString; + alg?: number; + ver?: string; + certInfo?: Base64URLString; + pubArea?: Base64URLString; + size: number; + }; + }; + transports?: ("ble" | "cable" | "hybrid" | "internal" | "nfc" | "smart-card" | "usb")[]; + userHandle: string; + }; + authenticatorAttachment: "platform" | "cross-platform"; + clientExtensionResults: Record; + type: string; + }; + } + | { + status: "WRONG_CREDENTIALS_ERROR"; + } + >; + getUserFromRecoverAccountToken(input: { + token: string; + tenantId: string; + userContext: UserContext; + }): Promise< + | { + status: "OK"; + user: User; + recipeUserId: RecipeUserId; + } + | { + status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR"; + } + >; + removeCredential(input: { + webauthnCredentialId: string; + recipeUserId: RecipeUserId; + userContext: UserContext; + }): Promise< + | { + status: "OK"; + } + | { + status: "CREDENTIAL_NOT_FOUND_ERROR"; + } + >; + getCredential(input: { + webauthnCredentialId: string; + recipeUserId: RecipeUserId; + userContext: UserContext; + }): Promise< + | { + status: "OK"; + id: string; + relyingPartyId: string; + recipeUserId: RecipeUserId; + createdAt: number; + } + | { + status: "CREDENTIAL_NOT_FOUND_ERROR"; + } + >; + listCredentials(input: { + recipeUserId: RecipeUserId; + userContext: UserContext; + }): Promise<{ + status: "OK"; + credentials: { + id: string; + relyingPartyId: string; + createdAt: number; + }[]; + }>; + removeGeneratedOptions(input: { + webauthnGeneratedOptionsId: string; + tenantId: string; + userContext: UserContext; + }): Promise< + | { + status: "OK"; + } + | { + status: "GENERATED_OPTIONS_NOT_FOUND_ERROR"; + } + >; + getGeneratedOptions(input: { + webauthnGeneratedOptionsId: string; + tenantId: string; + userContext: UserContext; + }): Promise< + | { + status: "OK"; + id: string; + relyingPartyId: string; + origin: string; + email: string; + timeout: string; + challenge: string; + } + | { + status: "GENERATED_OPTIONS_NOT_FOUND_ERROR"; + } + >; +}; +export declare type APIOptions = { + recipeImplementation: RecipeInterface; + appInfo: NormalisedAppinfo; + config: TypeNormalisedInput; + recipeId: string; + isInServerlessEnv: boolean; + req: BaseRequest; + res: BaseResponse; + emailDelivery: EmailDeliveryIngredient; +}; +export declare type APIInterface = { + registerOptionsPOST: + | undefined + | (( + input: { + tenantId: string; + options: APIOptions; + userContext: UserContext; + } & ( + | { + 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; + excludeCredentials: { + id: string; + type: "public-key"; + 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"; + }; + } + | GeneralErrorResponse + | { + status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR"; + } + | { + status: "INVALID_EMAIL_ERROR"; + err: string; + } + >); + signInOptionsPOST: + | undefined + | ((input: { + email?: string; + tenantId: string; + options: APIOptions; + userContext: UserContext; + }) => Promise< + | { + status: "OK"; + webauthnGeneratedOptionsId: string; + challenge: string; + timeout: number; + userVerification: "required" | "preferred" | "discouraged"; + } + | GeneralErrorResponse + | { + status: "WRONG_CREDENTIALS_ERROR"; + } + >); + signUpPOST: + | undefined + | ((input: { + webauthnGeneratedOptionsId: string; + credential: CredentialPayload; + tenantId: string; + session: SessionContainerInterface | undefined; + shouldTryLinkingWithSessionUser: boolean | undefined; + options: APIOptions; + userContext: UserContext; + }) => Promise< + | { + status: "OK"; + user: User; + session: SessionContainerInterface; + } + | GeneralErrorResponse + | { + status: "SIGN_UP_NOT_ALLOWED"; + reason: string; + } + | { + status: "EMAIL_ALREADY_EXISTS_ERROR"; + } + | { + status: "WRONG_CREDENTIALS_ERROR"; + } + | { + status: "INVALID_AUTHENTICATOR_ERROR"; + reason: string; + } + >); + signInPOST: + | undefined + | ((input: { + webauthnGeneratedOptionsId: string; + credential: CredentialPayload; + tenantId: string; + session: SessionContainerInterface | undefined; + shouldTryLinkingWithSessionUser: boolean | undefined; + options: APIOptions; + userContext: UserContext; + }) => Promise< + | { + status: "OK"; + user: User; + session: SessionContainerInterface; + } + | GeneralErrorResponse + | { + status: "SIGN_IN_NOT_ALLOWED"; + reason: string; + } + | { + status: "WRONG_CREDENTIALS_ERROR"; + } + >); + generateRecoverAccountTokenPOST: + | undefined + | ((input: { + email: string; + tenantId: string; + options: APIOptions; + userContext: UserContext; + }) => Promise< + | { + status: "OK"; + } + | GeneralErrorResponse + | { + status: "RECOVER_ACCOUNT_NOT_ALLOWED"; + reason: string; + } + >); + recoverAccountPOST: + | undefined + | ((input: { + token: string; + webauthnGeneratedOptionsId: string; + credential: CredentialPayload; + tenantId: string; + options: APIOptions; + userContext: UserContext; + }) => Promise< + | { + status: "OK"; + user: User; + email: string; + } + | GeneralErrorResponse + | { + status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR"; + } + | { + status: "WRONG_CREDENTIALS_ERROR"; + } + | { + status: "INVALID_AUTHENTICATOR_ERROR"; + reason: string; + } + >); + emailExistsGET: + | undefined + | ((input: { + email: string; + tenantId: string; + options: APIOptions; + userContext: UserContext; + }) => Promise< + | { + status: "OK"; + exists: boolean; + } + | GeneralErrorResponse + >); +}; +export declare type TypeWebauthnRecoverAccountEmailDeliveryInput = { + type: "RECOVER_ACCOUNT"; + user: { + id: string; + recipeUserId: RecipeUserId | undefined; + email: string; + }; + recoverAccountLink: string; + tenantId: string; +}; +export declare type TypeWebauthnEmailDeliveryInput = TypeWebauthnRecoverAccountEmailDeliveryInput; +export declare 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"; +}; +export {}; diff --git a/lib/build/recipe/webauthn/types.js b/lib/build/recipe/webauthn/types.js new file mode 100644 index 000000000..a098ca1d7 --- /dev/null +++ b/lib/build/recipe/webauthn/types.js @@ -0,0 +1,16 @@ +"use strict"; +/* 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. + */ +Object.defineProperty(exports, "__esModule", { value: true }); diff --git a/lib/build/recipe/webauthn/utils.d.ts b/lib/build/recipe/webauthn/utils.d.ts new file mode 100644 index 000000000..e4492d9f0 --- /dev/null +++ b/lib/build/recipe/webauthn/utils.d.ts @@ -0,0 +1,20 @@ +// @ts-nocheck +import Recipe from "./recipe"; +import { TypeInput, TypeNormalisedInput } from "./types"; +import { NormalisedAppinfo, UserContext } from "../../types"; +import { BaseRequest } from "../../framework"; +export declare function validateAndNormaliseUserInput( + recipeInstance: Recipe, + appInfo: NormalisedAppinfo, + config?: TypeInput +): TypeNormalisedInput; +export declare function defaultEmailValidator( + value: any +): Promise<"Development bug: Please make sure the email field yields a string" | "Email is invalid" | undefined>; +export declare function getRecoverAccountLink(input: { + appInfo: NormalisedAppinfo; + token: string; + tenantId: string; + request: BaseRequest | undefined; + userContext: UserContext; +}): string; diff --git a/lib/build/recipe/webauthn/utils.js b/lib/build/recipe/webauthn/utils.js new file mode 100644 index 000000000..191551342 --- /dev/null +++ b/lib/build/recipe/webauthn/utils.js @@ -0,0 +1,163 @@ +"use strict"; +/* 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. + */ +Object.defineProperty(exports, "__esModule", { value: true }); +exports.getRecoverAccountLink = exports.defaultEmailValidator = exports.validateAndNormaliseUserInput = void 0; +function validateAndNormaliseUserInput(recipeInstance, appInfo, config) { + let relyingPartyId = validateAndNormaliseRelyingPartyIdConfig( + recipeInstance, + appInfo, + config === null || config === void 0 ? void 0 : config.relyingPartyId + ); + let relyingPartyName = validateAndNormaliseRelyingPartyNameConfig( + recipeInstance, + appInfo, + config === null || config === void 0 ? void 0 : config.relyingPartyName + ); + let getOrigin = validateAndNormaliseGetOriginConfig( + recipeInstance, + appInfo, + config === null || config === void 0 ? void 0 : config.getOrigin + ); + let validateEmailAddress = validateAndNormaliseValidateEmailAddressConfig( + recipeInstance, + appInfo, + config === null || config === void 0 ? void 0 : config.validateEmailAddress + ); + let override = Object.assign( + { + functions: (originalImplementation) => originalImplementation, + apis: (originalImplementation) => originalImplementation, + }, + config === null || config === void 0 ? void 0 : config.override + ); + function getEmailDeliveryConfig(isInServerlessEnv) { + var _a; + let emailService = + (_a = config === null || config === void 0 ? void 0 : config.emailDelivery) === null || _a === void 0 + ? void 0 + : _a.service; + console.log("emailService", emailService); + console.log("isInServerlessEnv", isInServerlessEnv); + /** + * If the user has not passed even that config, we use the default + * createAndSendCustomEmail implementation which calls our supertokens API + */ + // if (emailService === undefined) { + // emailService = new BackwardCompatibilityService(appInfo, isInServerlessEnv); + // } + return Object.assign(Object.assign({}, config === null || config === void 0 ? void 0 : config.emailDelivery), { + /** + * if we do + * let emailDelivery = { + * service: emailService, + * ...config.emailDelivery, + * }; + * + * and if the user has passed service as undefined, + * it it again get set to undefined, so we + * set service at the end + */ + // todo implemenet this + service: null, + }); + } + return { + override, + getOrigin, + relyingPartyId, + relyingPartyName, + validateEmailAddress, + getEmailDeliveryConfig, + }; +} +exports.validateAndNormaliseUserInput = validateAndNormaliseUserInput; +function validateAndNormaliseRelyingPartyIdConfig(_, __, relyingPartyIdConfig) { + return (props) => { + if (typeof relyingPartyIdConfig === "string") { + return Promise.resolve(relyingPartyIdConfig); + } else if (typeof relyingPartyIdConfig === "function") { + return relyingPartyIdConfig(props); + } else { + return Promise.resolve( + __.getOrigin({ request: props.request, userContext: props.userContext }).getAsStringDangerous() + ); + } + }; +} +function validateAndNormaliseRelyingPartyNameConfig(_, __, relyingPartyNameConfig) { + return (props) => { + if (typeof relyingPartyNameConfig === "string") { + return Promise.resolve(relyingPartyNameConfig); + } else if (typeof relyingPartyNameConfig === "function") { + return relyingPartyNameConfig(props); + } else { + return Promise.resolve(__.appName); + } + }; +} +function validateAndNormaliseGetOriginConfig(_, __, getOriginConfig) { + return (props) => { + if (typeof getOriginConfig === "function") { + return getOriginConfig(props); + } else { + return Promise.resolve( + __.getOrigin({ request: props.request, userContext: props.userContext }).getAsStringDangerous() + ); + } + }; +} +function validateAndNormaliseValidateEmailAddressConfig(_, __, validateEmailAddressConfig) { + return (email, tenantId) => { + if (typeof validateEmailAddressConfig === "function") { + return validateEmailAddressConfig(email, tenantId); + } else { + return defaultEmailValidator(email); + } + }; +} +async function defaultEmailValidator(value) { + // We check if the email syntax is correct + // As per https://github.com/supertokens/supertokens-auth-react/issues/5#issuecomment-709512438 + // Regex from https://stackoverflow.com/a/46181/3867175 + if (typeof value !== "string") { + return "Development bug: Please make sure the email field yields a string"; + } + if ( + value.match( + /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/ + ) === null + ) { + return "Email is invalid"; + } + return undefined; +} +exports.defaultEmailValidator = defaultEmailValidator; +function getRecoverAccountLink(input) { + return ( + input.appInfo + .getOrigin({ + request: input.request, + userContext: input.userContext, + }) + .getAsStringDangerous() + + input.appInfo.websiteBasePath.getAsStringDangerous() + + "/recover-account?token=" + + input.token + + "&tenantId=" + + input.tenantId + ); +} +exports.getRecoverAccountLink = getRecoverAccountLink; diff --git a/lib/build/types.d.ts b/lib/build/types.d.ts index c45e6de40..c1abe0c67 100644 --- a/lib/build/types.d.ts +++ b/lib/build/types.d.ts @@ -87,6 +87,9 @@ export declare type User = { id: string; userId: string; }[]; + webauthn: { + credentialIds: string[]; + }; loginMethods: (RecipeLevelUser & { verified: boolean; hasSameEmailAs: (email: string | undefined) => boolean; diff --git a/lib/build/user.d.ts b/lib/build/user.d.ts index ccdddb396..9bc4835a5 100644 --- a/lib/build/user.d.ts +++ b/lib/build/user.d.ts @@ -9,6 +9,7 @@ export declare class LoginMethod implements RecipeLevelUser { readonly email?: string; readonly phoneNumber?: string; readonly thirdParty?: RecipeLevelUser["thirdParty"]; + readonly webauthn?: RecipeLevelUser["webauthn"]; readonly verified: boolean; readonly timeJoined: number; constructor(loginMethod: UserWithoutHelperFunctions["loginMethods"][number]); @@ -27,6 +28,9 @@ export declare class User implements UserType { id: string; userId: string; }[]; + readonly webauthn: { + credentialIds: string[]; + }; readonly loginMethods: LoginMethod[]; readonly timeJoined: number; constructor(user: UserWithoutHelperFunctions); @@ -43,8 +47,11 @@ export declare type UserWithoutHelperFunctions = { id: string; userId: string; }[]; + webauthn: { + credentialIds: string[]; + }; loginMethods: { - recipeId: "emailpassword" | "thirdparty" | "passwordless"; + recipeId: "emailpassword" | "thirdparty" | "passwordless" | "webauthn"; recipeUserId: string; tenantIds: string[]; email?: string; @@ -53,6 +60,9 @@ export declare type UserWithoutHelperFunctions = { id: string; userId: string; }; + webauthn?: { + credentialIds: string[]; + }; verified: boolean; timeJoined: number; }[]; diff --git a/lib/build/user.js b/lib/build/user.js index 5179ebc6f..fe045dde2 100644 --- a/lib/build/user.js +++ b/lib/build/user.js @@ -16,6 +16,7 @@ class LoginMethod { this.email = loginMethod.email; this.phoneNumber = loginMethod.phoneNumber; this.thirdParty = loginMethod.thirdParty; + this.webauthn = loginMethod.webauthn; this.timeJoined = loginMethod.timeJoined; this.verified = loginMethod.verified; } @@ -61,6 +62,7 @@ class LoginMethod { email: this.email, phoneNumber: this.phoneNumber, thirdParty: this.thirdParty, + webauthn: this.webauthn, timeJoined: this.timeJoined, verified: this.verified, }; @@ -75,6 +77,7 @@ class User { this.emails = user.emails; this.phoneNumbers = user.phoneNumbers; this.thirdParty = user.thirdParty; + this.webauthn = user.webauthn; this.timeJoined = user.timeJoined; this.loginMethods = user.loginMethods.map((m) => new LoginMethod(m)); } @@ -86,6 +89,7 @@ class User { emails: this.emails, phoneNumbers: this.phoneNumbers, thirdParty: this.thirdParty, + webauthn: this.webauthn, loginMethods: this.loginMethods.map((m) => m.toJson()), timeJoined: this.timeJoined, }; diff --git a/lib/ts/recipe/webauthn/core-mock.ts b/lib/ts/recipe/webauthn/core-mock.ts index 7ee65cb53..488b2ba5d 100644 --- a/lib/ts/recipe/webauthn/core-mock.ts +++ b/lib/ts/recipe/webauthn/core-mock.ts @@ -10,6 +10,9 @@ export const getMockQuerier = (recipeId: string) => { body: any, userContext: UserContext ): Promise => { + console.log("body", body); + console.log("userContext", userContext); + if (path.getAsStringDangerous().includes("/recipe/webauthn/options/register")) { // @ts-ignore return { diff --git a/lib/ts/recipe/webauthn/types.ts b/lib/ts/recipe/webauthn/types.ts index 6275e7464..f91c489d1 100644 --- a/lib/ts/recipe/webauthn/types.ts +++ b/lib/ts/recipe/webauthn/types.ts @@ -98,44 +98,44 @@ export type TypeInputValidateEmailAddress = ( ) => Promise | string | undefined; // centralize error types in order to prevent missing cascading errors -type RegisterOptionsErrorResponse = { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" }; +// type RegisterOptionsErrorResponse = { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" }; -type SignInOptionsErrorResponse = { status: "WRONG_CREDENTIALS_ERROR" }; +// type SignInOptionsErrorResponse = { status: "WRONG_CREDENTIALS_ERROR" }; -type SignUpErrorResponse = - | { status: "EMAIL_ALREADY_EXISTS_ERROR" } - | { status: "WRONG_CREDENTIALS_ERROR" } - | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string }; +// type SignUpErrorResponse = +// | { status: "EMAIL_ALREADY_EXISTS_ERROR" } +// | { status: "WRONG_CREDENTIALS_ERROR" } +// | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string }; -type SignInErrorResponse = { status: "WRONG_CREDENTIALS_ERROR" }; +// type SignInErrorResponse = { status: "WRONG_CREDENTIALS_ERROR" }; -type VerifyCredentialsErrorResponse = { status: "WRONG_CREDENTIALS_ERROR" }; +// type VerifyCredentialsErrorResponse = { status: "WRONG_CREDENTIALS_ERROR" }; -type GenerateRecoverAccountTokenErrorResponse = { status: "UNKNOWN_USER_ID_ERROR" }; +// type GenerateRecoverAccountTokenErrorResponse = { status: "UNKNOWN_USER_ID_ERROR" }; -type ConsumeRecoverAccountTokenErrorResponse = { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" }; +// type ConsumeRecoverAccountTokenErrorResponse = { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" }; -type RegisterCredentialErrorResponse = - | { 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 }; +// type RegisterCredentialErrorResponse = +// | { 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 }; -type CreateNewRecipeUserErrorResponse = - | { status: "WRONG_CREDENTIALS_ERROR" } - | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string } - | { status: "EMAIL_ALREADY_EXISTS_ERROR" }; +// type CreateNewRecipeUserErrorResponse = +// | { status: "WRONG_CREDENTIALS_ERROR" } +// | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string } +// | { status: "EMAIL_ALREADY_EXISTS_ERROR" }; -type DecodeCredentialErrorResponse = { status: "WRONG_CREDENTIALS_ERROR" }; +// type DecodeCredentialErrorResponse = { status: "WRONG_CREDENTIALS_ERROR" }; -type GetUserFromRecoverAccountTokenErrorResponse = { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" }; +// type GetUserFromRecoverAccountTokenErrorResponse = { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" }; -type RemoveCredentialErrorResponse = { status: "CREDENTIAL_NOT_FOUND_ERROR" }; +// type RemoveCredentialErrorResponse = { status: "CREDENTIAL_NOT_FOUND_ERROR" }; -type GetCredentialErrorResponse = { status: "CREDENTIAL_NOT_FOUND_ERROR" }; +// type GetCredentialErrorResponse = { status: "CREDENTIAL_NOT_FOUND_ERROR" }; -type RemoveGeneratedOptionsErrorResponse = { status: "GENERATED_OPTIONS_NOT_FOUND_ERROR" }; +// type RemoveGeneratedOptionsErrorResponse = { status: "GENERATED_OPTIONS_NOT_FOUND_ERROR" }; -type GetGeneratedOptionsErrorResponse = { status: "GENERATED_OPTIONS_NOT_FOUND_ERROR" }; +// type GetGeneratedOptionsErrorResponse = { status: "GENERATED_OPTIONS_NOT_FOUND_ERROR" }; type Base64URLString = string; @@ -510,37 +510,37 @@ export type APIOptions = { emailDelivery: EmailDeliveryIngredient; }; -type RegisterOptionsPOSTErrorResponse = - | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } - | { status: "INVALID_EMAIL_ERROR"; err: string }; - -type SignInOptionsPOSTErrorResponse = { status: "WRONG_CREDENTIALS_ERROR" }; - -type SignUpPOSTErrorResponse = - | { - status: "SIGN_UP_NOT_ALLOWED"; - reason: string; - } - | { status: "EMAIL_ALREADY_EXISTS_ERROR" } - | { status: "WRONG_CREDENTIALS_ERROR" } - | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string }; - -type SignInPOSTErrorResponse = - | { status: "WRONG_CREDENTIALS_ERROR" } - | { - status: "SIGN_IN_NOT_ALLOWED"; - reason: string; - }; - -type GenerateRecoverAccountTokenPOSTErrorResponse = { - status: "RECOVER_ACCOUNT_NOT_ALLOWED"; - reason: string; -}; - -type RecoverAccountPOSTErrorResponse = - | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } - | { status: "WRONG_CREDENTIALS_ERROR" } - | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string }; +// type RegisterOptionsPOSTErrorResponse = +// | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } +// | { status: "INVALID_EMAIL_ERROR"; err: string }; + +// type SignInOptionsPOSTErrorResponse = { status: "WRONG_CREDENTIALS_ERROR" }; + +// type SignUpPOSTErrorResponse = +// | { +// status: "SIGN_UP_NOT_ALLOWED"; +// reason: string; +// } +// | { status: "EMAIL_ALREADY_EXISTS_ERROR" } +// | { status: "WRONG_CREDENTIALS_ERROR" } +// | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string }; + +// type SignInPOSTErrorResponse = +// | { status: "WRONG_CREDENTIALS_ERROR" } +// | { +// status: "SIGN_IN_NOT_ALLOWED"; +// reason: string; +// }; + +// type GenerateRecoverAccountTokenPOSTErrorResponse = { +// status: "RECOVER_ACCOUNT_NOT_ALLOWED"; +// reason: string; +// }; + +// type RecoverAccountPOSTErrorResponse = +// | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } +// | { status: "WRONG_CREDENTIALS_ERROR" } +// | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string }; export type APIInterface = { registerOptionsPOST: diff --git a/lib/ts/recipe/webauthn/utils.ts b/lib/ts/recipe/webauthn/utils.ts index 9f94448c0..7a0a5d733 100644 --- a/lib/ts/recipe/webauthn/utils.ts +++ b/lib/ts/recipe/webauthn/utils.ts @@ -56,6 +56,8 @@ export function validateAndNormaliseUserInput( function getEmailDeliveryConfig(isInServerlessEnv: boolean) { let emailService = config?.emailDelivery?.service; + console.log("emailService", emailService); + console.log("isInServerlessEnv", isInServerlessEnv); /** * If the user has not passed even that config, we use the default * createAndSendCustomEmail implementation which calls our supertokens API From b44752f84c6cc366fd87485db9bbbec4688f54e6 Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Fri, 8 Nov 2024 11:45:12 +0200 Subject: [PATCH 18/25] added basic build exports --- recipe/webauthn/emaildelivery/index.d.ts | 10 ++++++++++ recipe/webauthn/emaildelivery/index.js | 6 ++++++ recipe/webauthn/index.d.ts | 10 ++++++++++ recipe/webauthn/index.js | 6 ++++++ recipe/webauthn/types/index.d.ts | 10 ++++++++++ recipe/webauthn/types/index.js | 6 ++++++ 6 files changed, 48 insertions(+) create mode 100644 recipe/webauthn/emaildelivery/index.d.ts create mode 100644 recipe/webauthn/emaildelivery/index.js create mode 100644 recipe/webauthn/index.d.ts create mode 100644 recipe/webauthn/index.js create mode 100644 recipe/webauthn/types/index.d.ts create mode 100644 recipe/webauthn/types/index.js diff --git a/recipe/webauthn/emaildelivery/index.d.ts b/recipe/webauthn/emaildelivery/index.d.ts new file mode 100644 index 000000000..f48887942 --- /dev/null +++ b/recipe/webauthn/emaildelivery/index.d.ts @@ -0,0 +1,10 @@ +export * from "../../../lib/build/recipe/webauthn/emaildelivery/services"; +/** + * 'export *' does not re-export a default. + * import NextJS from "supertokens-node/nextjs"; + * the above import statement won't be possible unless either + * - user add "esModuleInterop": true in their tsconfig.json file + * - we do the following change: + */ +import * as _default from "../../../lib/build/recipe/webauthn/emaildelivery/services"; +export default _default; diff --git a/recipe/webauthn/emaildelivery/index.js b/recipe/webauthn/emaildelivery/index.js new file mode 100644 index 000000000..4b6f696e6 --- /dev/null +++ b/recipe/webauthn/emaildelivery/index.js @@ -0,0 +1,6 @@ +"use strict"; +function __export(m) { + for (var p in m) if (!exports.hasOwnProperty(p)) exports[p] = m[p]; +} +exports.__esModule = true; +__export(require("../../../lib/build/recipe/webauthn/emaildelivery/services")); diff --git a/recipe/webauthn/index.d.ts b/recipe/webauthn/index.d.ts new file mode 100644 index 000000000..d05fed10b --- /dev/null +++ b/recipe/webauthn/index.d.ts @@ -0,0 +1,10 @@ +export * from "../../lib/build/recipe/webauthn"; +/** + * 'export *' does not re-export a default. + * import NextJS from "supertokens-node/nextjs"; + * the above import statement won't be possible unless either + * - user add "esModuleInterop": true in their tsconfig.json file + * - we do the following change: + */ +import * as _default from "../../lib/build/recipe/webauthn"; +export default _default; diff --git a/recipe/webauthn/index.js b/recipe/webauthn/index.js new file mode 100644 index 000000000..16bab5c2b --- /dev/null +++ b/recipe/webauthn/index.js @@ -0,0 +1,6 @@ +"use strict"; +function __export(m) { + for (var p in m) if (!exports.hasOwnProperty(p)) exports[p] = m[p]; +} +exports.__esModule = true; +__export(require("../../lib/build/recipe/webauthn")); diff --git a/recipe/webauthn/types/index.d.ts b/recipe/webauthn/types/index.d.ts new file mode 100644 index 000000000..688bf1e8d --- /dev/null +++ b/recipe/webauthn/types/index.d.ts @@ -0,0 +1,10 @@ +export * from "../../../lib/build/recipe/webauthn/types"; +/** + * 'export *' does not re-export a default. + * import NextJS from "supertokens-node/nextjs"; + * the above import statement won't be possible unless either + * - user add "esModuleInterop": true in their tsconfig.json file + * - we do the following change: + */ +import * as _default from "../../../lib/build/recipe/webauthn/types"; +export default _default; diff --git a/recipe/webauthn/types/index.js b/recipe/webauthn/types/index.js new file mode 100644 index 000000000..7f828b74f --- /dev/null +++ b/recipe/webauthn/types/index.js @@ -0,0 +1,6 @@ +"use strict"; +function __export(m) { + for (var p in m) if (!exports.hasOwnProperty(p)) exports[p] = m[p]; +} +exports.__esModule = true; +__export(require("../../../lib/build/recipe/webauthn/types")); From dba5cda9e52d03fe281ccf31420a92dbe3348557 Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Fri, 8 Nov 2024 17:58:27 +0200 Subject: [PATCH 19/25] pr fixes --- lib/ts/recipe/webauthn/api/implementation.ts | 51 +++++------ lib/ts/recipe/webauthn/api/recoverAccount.ts | 6 +- lib/ts/recipe/webauthn/api/signin.ts | 4 +- lib/ts/recipe/webauthn/api/signup.ts | 14 +-- lib/ts/recipe/webauthn/api/utils.ts | 2 +- lib/ts/recipe/webauthn/error.ts | 19 +---- lib/ts/recipe/webauthn/index.ts | 90 +++++++++++++++++++- lib/ts/recipe/webauthn/types.ts | 37 ++++---- lib/ts/recipe/webauthn/utils.ts | 45 ++++------ 9 files changed, 157 insertions(+), 111 deletions(-) diff --git a/lib/ts/recipe/webauthn/api/implementation.ts b/lib/ts/recipe/webauthn/api/implementation.ts index 61f905625..55657c272 100644 --- a/lib/ts/recipe/webauthn/api/implementation.ts +++ b/lib/ts/recipe/webauthn/api/implementation.ts @@ -20,7 +20,7 @@ import { getRecoverAccountLink } from "../utils"; import { logDebugMessage } from "../../../logger"; import { RecipeLevelUser } from "../../accountlinking/types"; import { getUser } from "../../.."; -import { CredentialPayload } from "../types"; +import { CredentialPayload, ResidentKey, UserVerification } from "../types"; export default function getAPIImplementation(): APIInterface { return { @@ -60,19 +60,19 @@ export default function getAPIImplementation(): APIInterface { }[]; authenticatorSelection: { requireResidentKey: boolean; - residentKey: "required" | "preferred" | "discouraged"; - userVerification: "required" | "preferred" | "discouraged"; + residentKey: ResidentKey; + userVerification: UserVerification; }; } | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } | { status: "INVALID_EMAIL_ERROR"; err: string } > { - const relyingPartyId = await options.config.relyingPartyId({ + const relyingPartyId = await options.config.getRelyingPartyId({ tenantId, request: options.req, userContext, }); - const relyingPartyName = await options.config.relyingPartyName({ + const relyingPartyName = await options.config.getRelyingPartyName({ tenantId, userContext, }); @@ -139,12 +139,12 @@ export default function getAPIImplementation(): APIInterface { webauthnGeneratedOptionsId: string; challenge: string; timeout: number; - userVerification: "required" | "preferred" | "discouraged"; + userVerification: UserVerification; } | GeneralErrorResponse | { status: "WRONG_CREDENTIALS_ERROR" } > { - const relyingPartyId = await options.config.relyingPartyId({ + const relyingPartyId = await options.config.getRelyingPartyId({ tenantId, request: options.req, userContext, @@ -391,35 +391,30 @@ export default function getAPIImplementation(): APIInterface { const recipeId = "webauthn"; - // do the verification before in order to retrieve the user email - const verifyCredentialsResponse = await options.recipeImplementation.verifyCredentials({ - credential, + const generatedOptions = await options.recipeImplementation.getGeneratedOptions({ webauthnGeneratedOptionsId, tenantId, userContext, }); - const checkCredentialsOnTenant = async () => { - return verifyCredentialsResponse.status === "OK"; - }; - - // doing it like this because the email is only available after verifyCredentials is called - let email: string; - if (verifyCredentialsResponse.status == "OK") { - 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 { + if (generatedOptions.status !== "OK") { return { status: "WRONG_CREDENTIALS_ERROR", }; } + let email = generatedOptions.email; + + const checkCredentialsOnTenant = async () => { + return ( + ( + await options.recipeImplementation.verifyCredentials({ + credential, + webauthnGeneratedOptionsId, + tenantId, + userContext, + }) + ).status === "OK" + ); + }; const authenticatingUser = await AuthUtils.getAuthenticatingUserAndAddToCurrentTenantIfRequired({ accountInfo: { email }, diff --git a/lib/ts/recipe/webauthn/api/recoverAccount.ts b/lib/ts/recipe/webauthn/api/recoverAccount.ts index dfbc2a476..526a5d707 100644 --- a/lib/ts/recipe/webauthn/api/recoverAccount.ts +++ b/lib/ts/recipe/webauthn/api/recoverAccount.ts @@ -14,7 +14,7 @@ */ import { send200Response } from "../../../utils"; -import { validateCredentialOrThrowError, validatewebauthnGeneratedOptionsIdOrThrowError } from "./utils"; +import { validateCredentialOrThrowError, validateWebauthnGeneratedOptionsIdOrThrowError } from "./utils"; import STError from "../error"; import { APIInterface, APIOptions } from "../"; import { UserContext } from "../../../types"; @@ -25,14 +25,12 @@ export default async function recoverAccount( options: APIOptions, userContext: UserContext ): Promise { - // Logic as per https://github.com/supertokens/supertokens-node/issues/22#issuecomment-710512442 - if (apiImplementation.recoverAccountPOST === undefined) { return false; } const requestBody = await options.req.getJSONBody(); - let webauthnGeneratedOptionsId = await validatewebauthnGeneratedOptionsIdOrThrowError( + let webauthnGeneratedOptionsId = await validateWebauthnGeneratedOptionsIdOrThrowError( requestBody.webauthnGeneratedOptionsId ); let credential = await validateCredentialOrThrowError(requestBody.credential); diff --git a/lib/ts/recipe/webauthn/api/signin.ts b/lib/ts/recipe/webauthn/api/signin.ts index 1d4eca98c..6d17fce67 100644 --- a/lib/ts/recipe/webauthn/api/signin.ts +++ b/lib/ts/recipe/webauthn/api/signin.ts @@ -18,7 +18,7 @@ import { getNormalisedShouldTryLinkingWithSessionUserFlag, send200Response, } from "../../../utils"; -import { validatewebauthnGeneratedOptionsIdOrThrowError, validateCredentialOrThrowError } from "./utils"; +import { validateWebauthnGeneratedOptionsIdOrThrowError, validateCredentialOrThrowError } from "./utils"; import { APIInterface, APIOptions } from ".."; import { UserContext } from "../../../types"; import { AuthUtils } from "../../../authUtils"; @@ -34,7 +34,7 @@ export default async function signInAPI( } const requestBody = await options.req.getJSONBody(); - const webauthnGeneratedOptionsId = await validatewebauthnGeneratedOptionsIdOrThrowError( + const webauthnGeneratedOptionsId = await validateWebauthnGeneratedOptionsIdOrThrowError( requestBody.webauthnGeneratedOptionsId ); const credential = await validateCredentialOrThrowError(requestBody.credential); diff --git a/lib/ts/recipe/webauthn/api/signup.ts b/lib/ts/recipe/webauthn/api/signup.ts index 2be7713f8..ef4bec5d6 100644 --- a/lib/ts/recipe/webauthn/api/signup.ts +++ b/lib/ts/recipe/webauthn/api/signup.ts @@ -18,7 +18,7 @@ import { getNormalisedShouldTryLinkingWithSessionUserFlag, send200Response, } from "../../../utils"; -import { validatewebauthnGeneratedOptionsIdOrThrowError, validateCredentialOrThrowError } from "./utils"; +import { validateWebauthnGeneratedOptionsIdOrThrowError, validateCredentialOrThrowError } from "./utils"; import { APIInterface, APIOptions } from ".."; import STError from "../error"; import { UserContext } from "../../../types"; @@ -35,7 +35,7 @@ export default async function signUpAPI( } const requestBody = await options.req.getJSONBody(); - const webauthnGeneratedOptionsId = await validatewebauthnGeneratedOptionsIdOrThrowError( + const webauthnGeneratedOptionsId = await validateWebauthnGeneratedOptionsIdOrThrowError( requestBody.webauthnGeneratedOptionsId ); const credential = await validateCredentialOrThrowError(requestBody.credential); @@ -70,14 +70,8 @@ export default async function signUpAPI( send200Response(options.res, result); } else if (result.status === "EMAIL_ALREADY_EXISTS_ERROR") { throw new STError({ - type: STError.FIELD_ERROR, - payload: [ - { - id: "email", - error: "This email already exists. Please sign in instead.", - }, - ], - message: "Error in input formFields", + type: STError.BAD_INPUT_ERROR, + message: "This email already exists. Please sign in instead.", }); } else { send200Response(options.res, result); diff --git a/lib/ts/recipe/webauthn/api/utils.ts b/lib/ts/recipe/webauthn/api/utils.ts index 5e1fa497c..1311008c7 100644 --- a/lib/ts/recipe/webauthn/api/utils.ts +++ b/lib/ts/recipe/webauthn/api/utils.ts @@ -14,7 +14,7 @@ */ import STError from "../error"; -export async function validatewebauthnGeneratedOptionsIdOrThrowError( +export async function validateWebauthnGeneratedOptionsIdOrThrowError( webauthnGeneratedOptionsId: string ): Promise { if (webauthnGeneratedOptionsId === undefined) { diff --git a/lib/ts/recipe/webauthn/error.ts b/lib/ts/recipe/webauthn/error.ts index 7b984cbaf..e285ee704 100644 --- a/lib/ts/recipe/webauthn/error.ts +++ b/lib/ts/recipe/webauthn/error.ts @@ -16,26 +16,11 @@ import STError from "../../error"; export default class SessionError extends STError { - static FIELD_ERROR: "FIELD_ERROR" = "FIELD_ERROR"; - - constructor( - options: - | { - type: "FIELD_ERROR"; - payload: { - id: string; - error: string; - }[]; - message: string; - } - | { - type: "BAD_INPUT_ERROR"; - message: string; - } - ) { + constructor(options: { type: "BAD_INPUT_ERROR"; message: string }) { super({ ...options, }); + this.fromRecipe = "webauthn"; } } diff --git a/lib/ts/recipe/webauthn/index.ts b/lib/ts/recipe/webauthn/index.ts index a1da56d89..f3a3b2e92 100644 --- a/lib/ts/recipe/webauthn/index.ts +++ b/lib/ts/recipe/webauthn/index.ts @@ -15,7 +15,15 @@ import Recipe from "./recipe"; import SuperTokensError from "./error"; -import { RecipeInterface, APIOptions, APIInterface, TypeWebauthnEmailDeliveryInput, CredentialPayload } from "./types"; +import { + RecipeInterface, + APIOptions, + APIInterface, + TypeWebauthnEmailDeliveryInput, + CredentialPayload, + UserVerification, + ResidentKey, +} from "./types"; import RecipeUserId from "../../recipeUserId"; import { DEFAULT_TENANT_ID } from "../multitenancy/constants"; import { getRecoverAccountLink } from "./utils"; @@ -73,8 +81,8 @@ export default class Wrapper { }[]; authenticatorSelection: { requireResidentKey: boolean; - residentKey: "required" | "preferred" | "discouraged"; - userVerification: "required" | "preferred" | "discouraged"; + residentKey: ResidentKey; + userVerification: UserVerification; }; } | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } @@ -118,7 +126,7 @@ export default class Wrapper { webauthnGeneratedOptionsId: string; challenge: string; timeout: number; - userVerification: "required" | "preferred" | "discouraged"; + userVerification: UserVerification; } | { status: "WRONG_CREDENTIALS_ERROR" } > { @@ -132,6 +140,80 @@ export default class Wrapper { }); } + static signUp( + tenantId: string, + webauthnGeneratedOptionsId: string, + credential: CredentialPayload, + session?: undefined, + userContext?: Record + ): Promise< + | { + status: "OK"; + user: User; + recipeUserId: RecipeUserId; + } + | { status: "EMAIL_ALREADY_EXISTS_ERROR" } + | { status: "WRONG_CREDENTIALS_ERROR" } + | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string } + >; + static signUp( + tenantId: string, + webauthnGeneratedOptionsId: string, + credential: CredentialPayload, + session: SessionContainerInterface, + userContext?: Record + ): Promise< + | { + status: "OK"; + user: User; + recipeUserId: RecipeUserId; + } + | { status: "EMAIL_ALREADY_EXISTS_ERROR" } + | { status: "WRONG_CREDENTIALS_ERROR" } + | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string } + | { + 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 signUp( + tenantId: string, + webauthnGeneratedOptionsId: string, + credential: CredentialPayload, + session?: SessionContainerInterface, + userContext?: Record + ): Promise< + | { + status: "OK"; + user: User; + recipeUserId: RecipeUserId; + } + | { status: "EMAIL_ALREADY_EXISTS_ERROR" } + | { status: "WRONG_CREDENTIALS_ERROR" } + | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string } + | { + 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.signUp({ + webauthnGeneratedOptionsId, + credential, + session, + shouldTryLinkingWithSessionUser: !!session, + tenantId: tenantId === undefined ? DEFAULT_TENANT_ID : tenantId, + userContext: getUserContext(userContext), + }); + } + static signIn( tenantId: string, webauthnGeneratedOptionsId: string, diff --git a/lib/ts/recipe/webauthn/types.ts b/lib/ts/recipe/webauthn/types.ts index f91c489d1..154c75a14 100644 --- a/lib/ts/recipe/webauthn/types.ts +++ b/lib/ts/recipe/webauthn/types.ts @@ -25,8 +25,8 @@ import { GeneralErrorResponse, NormalisedAppinfo, User, UserContext } from "../. import RecipeUserId from "../../recipeUserId"; export type TypeNormalisedInput = { - relyingPartyId: TypeNormalisedInputRelyingPartyId; - relyingPartyName: TypeNormalisedInputRelyingPartyName; + getRelyingPartyId: TypeNormalisedInputRelyingPartyId; + getRelyingPartyName: TypeNormalisedInputRelyingPartyName; getOrigin: TypeNormalisedInputGetOrigin; getEmailDeliveryConfig: ( isInServerlessEnv: boolean @@ -65,8 +65,8 @@ export type TypeNormalisedInputValidateEmailAddress = ( export type TypeInput = { emailDelivery?: EmailDeliveryTypeInput; - relyingPartyId?: TypeInputRelyingPartyId; - relyingPartyName?: TypeInputRelyingPartyName; + getRelyingPartyId?: TypeInputRelyingPartyId; + getRelyingPartyName?: TypeInputRelyingPartyName; validateEmailAddress?: TypeInputValidateEmailAddress; getOrigin?: TypeInputGetOrigin; override?: { @@ -139,9 +139,11 @@ export type TypeInputValidateEmailAddress = ( type Base64URLString = string; +export type ResidentKey = "required" | "preferred" | "discouraged"; +export type UserVerification = "required" | "preferred" | "discouraged"; +export type Attestation = "none" | "indirect" | "direct" | "enterprise"; + export type RecipeInterface = { - // should have a way to access the user email: passed as a param, through session, or using recoverAccountToken - // it should have at least one of those 3 options registerOptions( input: { relyingPartyId: string; @@ -149,11 +151,11 @@ export type RecipeInterface = { 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; + residentKey: ResidentKey | undefined; // default to 'preferred' in order to verify the user (biometrics, pin, etc) based on the device preferences - userVerification: "required" | "preferred" | "discouraged" | undefined; + userVerification: UserVerification | undefined; // default to 'none' in order to allow any authenticator and not verify attestation - attestation: "none" | "indirect" | "direct" | "enterprise" | undefined; + attestation: Attestation | undefined; // default to [-8, -7, -257] as supported algorithms. See https://www.iana.org/assignments/cose/cose.xhtml#algorithms. supportedAlgorithmIds: number[] | undefined; // default to 5 seconds @@ -172,6 +174,7 @@ export type RecipeInterface = { | { status: "OK"; webauthnGeneratedOptionsId: string; + // for understanding the response, see https://www.w3.org/TR/webauthn-3/#sctn-registering-a-new-credential and https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredential rp: { id: string; name: string; @@ -188,7 +191,7 @@ export type RecipeInterface = { type: "public-key"; transports: ("ble" | "hybrid" | "internal" | "nfc" | "usb")[]; }[]; - attestation: "none" | "indirect" | "direct" | "enterprise"; + attestation: Attestation; pubKeyCredParams: { // we will default to [-8, -7, -257] as supported algorithms. See https://www.iana.org/assignments/cose/cose.xhtml#algorithms alg: number; @@ -196,8 +199,8 @@ export type RecipeInterface = { }[]; authenticatorSelection: { requireResidentKey: boolean; - residentKey: "required" | "preferred" | "discouraged"; - userVerification: "required" | "preferred" | "discouraged"; + residentKey: ResidentKey; + userVerification: UserVerification; }; } // | RegisterOptionsErrorResponse @@ -209,7 +212,7 @@ export type RecipeInterface = { email?: string; relyingPartyId: string; origin: string; - userVerification: "required" | "preferred" | "discouraged" | undefined; // see register options + userVerification: UserVerification | undefined; // see register options timeout: number | undefined; tenantId: string; userContext: UserContext; @@ -219,7 +222,7 @@ export type RecipeInterface = { webauthnGeneratedOptionsId: string; challenge: string; timeout: number; - userVerification: "required" | "preferred" | "discouraged"; + userVerification: UserVerification; } // | SignInOptionsErrorResponse | { status: "WRONG_CREDENTIALS_ERROR" } @@ -578,8 +581,8 @@ export type APIInterface = { }[]; authenticatorSelection: { requireResidentKey: boolean; - residentKey: "required" | "preferred" | "discouraged"; - userVerification: "required" | "preferred" | "discouraged"; + residentKey: ResidentKey; + userVerification: UserVerification; }; } | GeneralErrorResponse @@ -601,7 +604,7 @@ export type APIInterface = { webauthnGeneratedOptionsId: string; challenge: string; timeout: number; - userVerification: "required" | "preferred" | "discouraged"; + userVerification: UserVerification; } | GeneralErrorResponse // | SignInOptionsPOSTErrorResponse diff --git a/lib/ts/recipe/webauthn/utils.ts b/lib/ts/recipe/webauthn/utils.ts index 7a0a5d733..2731c8c2f 100644 --- a/lib/ts/recipe/webauthn/utils.ts +++ b/lib/ts/recipe/webauthn/utils.ts @@ -31,22 +31,14 @@ import { RecipeInterface, APIInterface } from "./types"; import { BaseRequest } from "../../framework"; export function validateAndNormaliseUserInput( - recipeInstance: Recipe, + _: Recipe, appInfo: NormalisedAppinfo, config?: TypeInput ): TypeNormalisedInput { - let relyingPartyId = validateAndNormaliseRelyingPartyIdConfig(recipeInstance, appInfo, config?.relyingPartyId); - let relyingPartyName = validateAndNormaliseRelyingPartyNameConfig( - recipeInstance, - appInfo, - config?.relyingPartyName - ); - let getOrigin = validateAndNormaliseGetOriginConfig(recipeInstance, appInfo, config?.getOrigin); - let validateEmailAddress = validateAndNormaliseValidateEmailAddressConfig( - recipeInstance, - appInfo, - config?.validateEmailAddress - ); + let getRelyingPartyId = validateAndNormaliseRelyingPartyIdConfig(appInfo, config?.getRelyingPartyId); + let getRelyingPartyName = validateAndNormaliseRelyingPartyNameConfig(appInfo, config?.getRelyingPartyName); + let getOrigin = validateAndNormaliseGetOriginConfig(appInfo, config?.getOrigin); + let validateEmailAddress = validateAndNormaliseValidateEmailAddressConfig(config?.validateEmailAddress); let override = { functions: (originalImplementation: RecipeInterface) => originalImplementation, @@ -56,8 +48,6 @@ export function validateAndNormaliseUserInput( function getEmailDeliveryConfig(isInServerlessEnv: boolean) { let emailService = config?.emailDelivery?.service; - console.log("emailService", emailService); - console.log("isInServerlessEnv", isInServerlessEnv); /** * If the user has not passed even that config, we use the default * createAndSendCustomEmail implementation which calls our supertokens API @@ -85,16 +75,15 @@ export function validateAndNormaliseUserInput( return { override, getOrigin, - relyingPartyId, - relyingPartyName, + getRelyingPartyId, + getRelyingPartyName, validateEmailAddress, getEmailDeliveryConfig, }; } function validateAndNormaliseRelyingPartyIdConfig( - _: Recipe, - __: NormalisedAppinfo, + normalisedAppinfo: NormalisedAppinfo, relyingPartyIdConfig: TypeInputRelyingPartyId | undefined ): TypeNormalisedInputRelyingPartyId { return (props) => { @@ -104,15 +93,16 @@ function validateAndNormaliseRelyingPartyIdConfig( return relyingPartyIdConfig(props); } else { return Promise.resolve( - __.getOrigin({ request: props.request, userContext: props.userContext }).getAsStringDangerous() + normalisedAppinfo + .getOrigin({ request: props.request, userContext: props.userContext }) + .getAsStringDangerous() ); } }; } function validateAndNormaliseRelyingPartyNameConfig( - _: Recipe, - __: NormalisedAppinfo, + normalisedAppInfo: NormalisedAppinfo, relyingPartyNameConfig: TypeInputRelyingPartyName | undefined ): TypeNormalisedInputRelyingPartyName { return (props) => { @@ -121,14 +111,13 @@ function validateAndNormaliseRelyingPartyNameConfig( } else if (typeof relyingPartyNameConfig === "function") { return relyingPartyNameConfig(props); } else { - return Promise.resolve(__.appName); + return Promise.resolve(normalisedAppInfo.appName); } }; } function validateAndNormaliseGetOriginConfig( - _: Recipe, - __: NormalisedAppinfo, + normalisedAppinfo: NormalisedAppinfo, getOriginConfig: TypeInputGetOrigin | undefined ): TypeNormalisedInputGetOrigin { return (props) => { @@ -136,15 +125,15 @@ function validateAndNormaliseGetOriginConfig( return getOriginConfig(props); } else { return Promise.resolve( - __.getOrigin({ request: props.request, userContext: props.userContext }).getAsStringDangerous() + normalisedAppinfo + .getOrigin({ request: props.request, userContext: props.userContext }) + .getAsStringDangerous() ); } }; } function validateAndNormaliseValidateEmailAddressConfig( - _: Recipe, - __: NormalisedAppinfo, validateEmailAddressConfig: TypeInputValidateEmailAddress | undefined ): TypeNormalisedInputValidateEmailAddress { return (email, tenantId) => { From c84a76a8716d6afe59aa0ea680de450afc7b54b4 Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Fri, 8 Nov 2024 18:02:58 +0200 Subject: [PATCH 20/25] pr fixes --- lib/ts/recipe/webauthn/api/emailExists.ts | 2 +- lib/ts/recipe/webauthn/api/generateRecoverAccountToken.ts | 2 +- lib/ts/recipe/webauthn/api/recoverAccount.ts | 2 +- lib/ts/recipe/webauthn/api/registerOptions.ts | 2 +- lib/ts/recipe/webauthn/api/signInOptions.ts | 2 +- lib/ts/recipe/webauthn/api/signin.ts | 2 +- lib/ts/recipe/webauthn/api/signup.ts | 2 +- lib/ts/recipe/webauthn/api/utils.ts | 2 +- lib/ts/recipe/webauthn/constants.ts | 2 +- lib/ts/recipe/webauthn/error.ts | 2 +- lib/ts/recipe/webauthn/index.ts | 2 +- lib/ts/recipe/webauthn/recipe.ts | 2 +- lib/ts/recipe/webauthn/types.ts | 2 +- lib/ts/recipe/webauthn/utils.ts | 2 +- 14 files changed, 14 insertions(+), 14 deletions(-) diff --git a/lib/ts/recipe/webauthn/api/emailExists.ts b/lib/ts/recipe/webauthn/api/emailExists.ts index 3ed5ecab6..3ffe59a33 100644 --- a/lib/ts/recipe/webauthn/api/emailExists.ts +++ b/lib/ts/recipe/webauthn/api/emailExists.ts @@ -1,4 +1,4 @@ -/* Copyright (c) 2021, VRAI Labs and/or its affiliates. All rights reserved. +/* Copyright (c) 2024, 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. diff --git a/lib/ts/recipe/webauthn/api/generateRecoverAccountToken.ts b/lib/ts/recipe/webauthn/api/generateRecoverAccountToken.ts index 4fe1ef211..c224a280e 100644 --- a/lib/ts/recipe/webauthn/api/generateRecoverAccountToken.ts +++ b/lib/ts/recipe/webauthn/api/generateRecoverAccountToken.ts @@ -1,4 +1,4 @@ -/* Copyright (c) 2021, VRAI Labs and/or its affiliates. All rights reserved. +/* Copyright (c) 2024, 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. diff --git a/lib/ts/recipe/webauthn/api/recoverAccount.ts b/lib/ts/recipe/webauthn/api/recoverAccount.ts index 526a5d707..dfd7df64c 100644 --- a/lib/ts/recipe/webauthn/api/recoverAccount.ts +++ b/lib/ts/recipe/webauthn/api/recoverAccount.ts @@ -1,4 +1,4 @@ -/* Copyright (c) 2021, VRAI Labs and/or its affiliates. All rights reserved. +/* Copyright (c) 2024, 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. diff --git a/lib/ts/recipe/webauthn/api/registerOptions.ts b/lib/ts/recipe/webauthn/api/registerOptions.ts index d2f1b1e58..3e2c7b4c9 100644 --- a/lib/ts/recipe/webauthn/api/registerOptions.ts +++ b/lib/ts/recipe/webauthn/api/registerOptions.ts @@ -1,4 +1,4 @@ -/* Copyright (c) 2021, VRAI Labs and/or its affiliates. All rights reserved. +/* Copyright (c) 2024, 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. diff --git a/lib/ts/recipe/webauthn/api/signInOptions.ts b/lib/ts/recipe/webauthn/api/signInOptions.ts index 9cf292f9f..f81fde810 100644 --- a/lib/ts/recipe/webauthn/api/signInOptions.ts +++ b/lib/ts/recipe/webauthn/api/signInOptions.ts @@ -1,4 +1,4 @@ -/* Copyright (c) 2021, VRAI Labs and/or its affiliates. All rights reserved. +/* Copyright (c) 2024, 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. diff --git a/lib/ts/recipe/webauthn/api/signin.ts b/lib/ts/recipe/webauthn/api/signin.ts index 6d17fce67..1cece7649 100644 --- a/lib/ts/recipe/webauthn/api/signin.ts +++ b/lib/ts/recipe/webauthn/api/signin.ts @@ -1,4 +1,4 @@ -/* Copyright (c) 2021, VRAI Labs and/or its affiliates. All rights reserved. +/* Copyright (c) 2024, 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. diff --git a/lib/ts/recipe/webauthn/api/signup.ts b/lib/ts/recipe/webauthn/api/signup.ts index ef4bec5d6..0b59150a6 100644 --- a/lib/ts/recipe/webauthn/api/signup.ts +++ b/lib/ts/recipe/webauthn/api/signup.ts @@ -1,4 +1,4 @@ -/* Copyright (c) 2021, VRAI Labs and/or its affiliates. All rights reserved. +/* Copyright (c) 2024, 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. diff --git a/lib/ts/recipe/webauthn/api/utils.ts b/lib/ts/recipe/webauthn/api/utils.ts index 1311008c7..2d68d610c 100644 --- a/lib/ts/recipe/webauthn/api/utils.ts +++ b/lib/ts/recipe/webauthn/api/utils.ts @@ -1,4 +1,4 @@ -/* Copyright (c) 2021, VRAI Labs and/or its affiliates. All rights reserved. +/* Copyright (c) 2024, 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. diff --git a/lib/ts/recipe/webauthn/constants.ts b/lib/ts/recipe/webauthn/constants.ts index 652ee6e46..7f9e86afa 100644 --- a/lib/ts/recipe/webauthn/constants.ts +++ b/lib/ts/recipe/webauthn/constants.ts @@ -1,4 +1,4 @@ -/* Copyright (c) 2021, VRAI Labs and/or its affiliates. All rights reserved. +/* Copyright (c) 2024, 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. diff --git a/lib/ts/recipe/webauthn/error.ts b/lib/ts/recipe/webauthn/error.ts index e285ee704..36e709cde 100644 --- a/lib/ts/recipe/webauthn/error.ts +++ b/lib/ts/recipe/webauthn/error.ts @@ -1,4 +1,4 @@ -/* Copyright (c) 2021, VRAI Labs and/or its affiliates. All rights reserved. +/* Copyright (c) 2024, 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. diff --git a/lib/ts/recipe/webauthn/index.ts b/lib/ts/recipe/webauthn/index.ts index f3a3b2e92..246dbbc89 100644 --- a/lib/ts/recipe/webauthn/index.ts +++ b/lib/ts/recipe/webauthn/index.ts @@ -1,4 +1,4 @@ -/* Copyright (c) 2021, VRAI Labs and/or its affiliates. All rights reserved. +/* Copyright (c) 2024, 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. diff --git a/lib/ts/recipe/webauthn/recipe.ts b/lib/ts/recipe/webauthn/recipe.ts index 6e0b94302..d24e54ac0 100644 --- a/lib/ts/recipe/webauthn/recipe.ts +++ b/lib/ts/recipe/webauthn/recipe.ts @@ -1,4 +1,4 @@ -/* Copyright (c) 2021, VRAI Labs and/or its affiliates. All rights reserved. +/* Copyright (c) 2024, 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. diff --git a/lib/ts/recipe/webauthn/types.ts b/lib/ts/recipe/webauthn/types.ts index 154c75a14..74deff6f9 100644 --- a/lib/ts/recipe/webauthn/types.ts +++ b/lib/ts/recipe/webauthn/types.ts @@ -1,4 +1,4 @@ -/* Copyright (c) 2021, VRAI Labs and/or its affiliates. All rights reserved. +/* Copyright (c) 2024, 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. diff --git a/lib/ts/recipe/webauthn/utils.ts b/lib/ts/recipe/webauthn/utils.ts index 2731c8c2f..0f5111a6b 100644 --- a/lib/ts/recipe/webauthn/utils.ts +++ b/lib/ts/recipe/webauthn/utils.ts @@ -1,4 +1,4 @@ -/* Copyright (c) 2021, VRAI Labs and/or its affiliates. All rights reserved. +/* Copyright (c) 2024, 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. From 3649b45deb1f4a0df4dce0c21d86b30513aa0715 Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Tue, 12 Nov 2024 18:35:42 +0200 Subject: [PATCH 21/25] pr fixes --- lib/ts/recipe/accountlinking/types.ts | 1 + lib/ts/recipe/webauthn/api/implementation.ts | 85 ++-- lib/ts/recipe/webauthn/core-mock.ts | 3 - lib/ts/recipe/webauthn/index.ts | 441 +++++++++++------- lib/ts/recipe/webauthn/recipe.ts | 9 +- .../recipe/webauthn/recipeImplementation.ts | 19 +- lib/ts/recipe/webauthn/types.ts | 79 ++-- 7 files changed, 396 insertions(+), 241 deletions(-) diff --git a/lib/ts/recipe/accountlinking/types.ts b/lib/ts/recipe/accountlinking/types.ts index 5559796c9..11fede4c3 100644 --- a/lib/ts/recipe/accountlinking/types.ts +++ b/lib/ts/recipe/accountlinking/types.ts @@ -201,6 +201,7 @@ export type AccountInfoWithRecipeId = { recipeId: "emailpassword" | "thirdparty" | "passwordless" | "webauthn"; } & AccountInfo; +// todo check if possible to split this into returnable (implementation) types because of webauthn credentialIds being an array export type RecipeLevelUser = { tenantIds: string[]; timeJoined: number; diff --git a/lib/ts/recipe/webauthn/api/implementation.ts b/lib/ts/recipe/webauthn/api/implementation.ts index 55657c272..2a6d5a621 100644 --- a/lib/ts/recipe/webauthn/api/implementation.ts +++ b/lib/ts/recipe/webauthn/api/implementation.ts @@ -33,6 +33,7 @@ export default function getAPIImplementation(): APIInterface { tenantId: string; options: APIOptions; userContext: UserContext; + displayName?: string; } & ({ email: string } | { recoverAccountToken: string })): Promise< | { status: "OK"; @@ -66,6 +67,7 @@ export default function getAPIImplementation(): APIInterface { } | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } | { status: "INVALID_EMAIL_ERROR"; err: string } + | { status: "INVALID_GENERATED_OPTIONS_ERROR" } > { const relyingPartyId = await options.config.getRelyingPartyId({ tenantId, @@ -142,7 +144,7 @@ export default function getAPIImplementation(): APIInterface { userVerification: UserVerification; } | GeneralErrorResponse - | { status: "WRONG_CREDENTIALS_ERROR" } + | { status: "INVALID_GENERATED_OPTIONS_ERROR" } > { const relyingPartyId = await options.config.getRelyingPartyId({ tenantId, @@ -211,17 +213,21 @@ export default function getAPIImplementation(): APIInterface { status: "SIGN_UP_NOT_ALLOWED"; reason: string; } - | { status: "EMAIL_ALREADY_EXISTS_ERROR" } - | { status: "WRONG_CREDENTIALS_ERROR" } + | { status: "INVALID_CREDENTIALS_ERROR" } + | { status: "GENERATED_OPTIONS_NOT_FOUND_ERROR" } + | { status: "INVALID_GENERATED_OPTIONS_ERROR" } | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string } + | { status: "EMAIL_ALREADY_EXISTS_ERROR" } > { + // TODO update error codes (ERR_CODE_XXX) after final implementation 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.", + INVALID_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)", @@ -240,7 +246,7 @@ export default function getAPIImplementation(): APIInterface { userContext, }); if (generatedOptions.status !== "OK") { - return { status: "WRONG_CREDENTIALS_ERROR" }; + return generatedOptions; } const email = generatedOptions.email; @@ -250,10 +256,11 @@ export default function getAPIImplementation(): APIInterface { // here to be on the safe side. if (!email) { throw new Error( - "Should never come here since we already check that the email value is a string in validateFormFieldsOrThrowError" + "Should never come here since we already check that the email value is a string in validateEmailAddress" ); } + // todo familiarize with this method const preAuthCheckRes = await AuthUtils.preAuthChecks({ authenticatingAccountInfo: { recipeId: "webauthn", @@ -318,6 +325,8 @@ export default function getAPIImplementation(): APIInterface { return AuthUtils.getErrorStatusResponseWithReason(signUpResponse, errorCodeMap, "SIGN_UP_NOT_ALLOWED"); } + // todo familiarize with this method + // todo check if we need to remove webauthn credential ids from the type - it is not used atm. const postAuthChecks = await AuthUtils.postAuthChecks({ authenticatedUser: signUpResponse.user, recipeUserId: signUpResponse.recipeUserId, @@ -367,7 +376,7 @@ export default function getAPIImplementation(): APIInterface { session: SessionContainerInterface; user: User; } - | { status: "WRONG_CREDENTIALS_ERROR" } + | { status: "INVALID_CREDENTIALS_ERROR" } | { status: "SIGN_IN_NOT_ALLOWED"; reason: string; @@ -391,6 +400,16 @@ export default function getAPIImplementation(): APIInterface { const recipeId = "webauthn"; + const verifyResult = await options.recipeImplementation.verifyCredentials({ + credential, + webauthnGeneratedOptionsId, + tenantId, + userContext, + }); + if (verifyResult.status !== "OK") { + return verifyResult; + } + const generatedOptions = await options.recipeImplementation.getGeneratedOptions({ webauthnGeneratedOptionsId, tenantId, @@ -398,24 +417,24 @@ export default function getAPIImplementation(): APIInterface { }); if (generatedOptions.status !== "OK") { return { - status: "WRONG_CREDENTIALS_ERROR", + status: "INVALID_CREDENTIALS_ERROR", }; } let email = generatedOptions.email; const checkCredentialsOnTenant = async () => { - return ( - ( - await options.recipeImplementation.verifyCredentials({ - credential, - webauthnGeneratedOptionsId, - tenantId, - userContext, - }) - ).status === "OK" - ); + return true; }; + // todo familiarize with this method + // todo make sure the section below (from getAuthenticatingUserAndAddToCurrentTenantIfRequired to isVerified) is correct + // const matchingLoginMethodsFromSessionUser = sessionUser.loginMethods.filter( + // (lm) => + // lm.recipeId === recipeId && + // (lm.hasSameEmailAs(accountInfo.email) || + // lm.hasSamePhoneNumberAs(accountInfo.phoneNumber) || + // lm.hasSameThirdPartyInfoAs(accountInfo.thirdParty)) + // ); const authenticatingUser = await AuthUtils.getAuthenticatingUserAndAddToCurrentTenantIfRequired({ accountInfo: { email }, userContext, @@ -432,7 +451,7 @@ export default function getAPIImplementation(): APIInterface { // isSignUpAllowed will be called as expected. if (authenticatingUser === undefined) { return { - status: "WRONG_CREDENTIALS_ERROR", + status: "INVALID_CREDENTIALS_ERROR", }; } const preAuthChecks = await AuthUtils.preAuthChecks({ @@ -461,7 +480,7 @@ export default function getAPIImplementation(): APIInterface { if (isFakeEmail(email) && preAuthChecks.isFirstFactor) { // Fake emails cannot be used as a first factor return { - status: "WRONG_CREDENTIALS_ERROR", + status: "INVALID_CREDENTIALS_ERROR", }; } @@ -474,7 +493,7 @@ export default function getAPIImplementation(): APIInterface { userContext, }); - if (signInResponse.status === "WRONG_CREDENTIALS_ERROR") { + if (signInResponse.status === "INVALID_CREDENTIALS_ERROR") { return signInResponse; } if (signInResponse.status !== "OK") { @@ -550,6 +569,11 @@ export default function getAPIImplementation(): APIInterface { tenantId, options, userContext, + }: { + email: string; + tenantId: string; + options: APIOptions; + userContext: UserContext; }): Promise< | { status: "OK"; @@ -834,7 +858,9 @@ export default function getAPIImplementation(): APIInterface { } | GeneralErrorResponse | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } - | { status: "WRONG_CREDENTIALS_ERROR" } + | { status: "INVALID_CREDENTIALS_ERROR" } // the credential is not valid for various reasons - will discover this during implementation + | { status: "GENERATED_OPTIONS_NOT_FOUND_ERROR" } // i.e. options not found + | { status: "INVALID_GENERATED_OPTIONS_ERROR" } // i.e. timeout expired | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string } > { async function markEmailAsVerified(recipeUserId: RecipeUserId, email: string) { @@ -870,7 +896,7 @@ export default function getAPIImplementation(): APIInterface { user: User; email: string; } - | { status: "WRONG_CREDENTIALS_ERROR" } + | { status: "INVALID_CREDENTIALS_ERROR" } | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string } | GeneralErrorResponse > { @@ -889,9 +915,9 @@ export default function getAPIImplementation(): APIInterface { status: "INVALID_AUTHENTICATOR_ERROR", reason: updateResponse.reason, }; - } else if (updateResponse.status === "WRONG_CREDENTIALS_ERROR") { + } else if (updateResponse.status === "INVALID_CREDENTIALS_ERROR") { return { - status: "WRONG_CREDENTIALS_ERROR", + status: "INVALID_CREDENTIALS_ERROR", }; } else { // status: "OK" @@ -1025,9 +1051,12 @@ export default function getAPIImplementation(): APIInterface { }); // todo decide how to handle these - if (createUserResponse.status === "WRONG_CREDENTIALS_ERROR") { - return createUserResponse; - } else if (createUserResponse.status === "INVALID_AUTHENTICATOR_ERROR") { + if ( + createUserResponse.status === "INVALID_CREDENTIALS_ERROR" || + createUserResponse.status === "GENERATED_OPTIONS_NOT_FOUND_ERROR" || + createUserResponse.status === "INVALID_GENERATED_OPTIONS_ERROR" || + 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 diff --git a/lib/ts/recipe/webauthn/core-mock.ts b/lib/ts/recipe/webauthn/core-mock.ts index 488b2ba5d..7ee65cb53 100644 --- a/lib/ts/recipe/webauthn/core-mock.ts +++ b/lib/ts/recipe/webauthn/core-mock.ts @@ -10,9 +10,6 @@ export const getMockQuerier = (recipeId: string) => { body: any, userContext: UserContext ): Promise => { - console.log("body", body); - console.log("userContext", userContext); - if (path.getAsStringDangerous().includes("/recipe/webauthn/options/register")) { // @ts-ignore return { diff --git a/lib/ts/recipe/webauthn/index.ts b/lib/ts/recipe/webauthn/index.ts index 246dbbc89..c2b10dbbe 100644 --- a/lib/ts/recipe/webauthn/index.ts +++ b/lib/ts/recipe/webauthn/index.ts @@ -17,12 +17,13 @@ import Recipe from "./recipe"; import SuperTokensError from "./error"; import { RecipeInterface, - APIOptions, APIInterface, + APIOptions, TypeWebauthnEmailDeliveryInput, CredentialPayload, UserVerification, ResidentKey, + Attestation, } from "./types"; import RecipeUserId from "../../recipeUserId"; import { DEFAULT_TENANT_ID } from "../multitenancy/constants"; @@ -37,24 +38,46 @@ import { DEFAULT_REGISTER_OPTIONS_SUPPORTED_ALGORITHM_IDS, DEFAULT_REGISTER_OPTIONS_USER_VERIFICATION, DEFAULT_SIGNIN_OPTIONS_USER_VERIFICATION, + DEFAULT_REGISTER_OPTIONS_TIMEOUT, + DEFAULT_REGISTER_OPTIONS_ATTESTATION, + DEFAULT_SIGNIN_OPTIONS_TIMEOUT, } from "./constants"; +import { BaseRequest } from "../../framework"; export default class Wrapper { static init = Recipe.init; static Error = SuperTokensError; - static async registerOptions( - email: string | undefined, - recoverAccountToken: string | undefined, - relyingPartyId: string, - relyingPartyName: string, - origin: string, - timeout: number, - attestation: "none" | "indirect" | "direct" | "enterprise" = "none", - tenantId: string, - userContext: Record - ): Promise< + static async registerOptions({ + requireResidentKey = DEFAULT_REGISTER_OPTIONS_REQUIRE_RESIDENT_KEY, + residentKey = DEFAULT_REGISTER_OPTIONS_RESIDENT_KEY, + userVerification = DEFAULT_REGISTER_OPTIONS_USER_VERIFICATION, + attestation = DEFAULT_REGISTER_OPTIONS_ATTESTATION, + supportedAlgorithmIds = DEFAULT_REGISTER_OPTIONS_SUPPORTED_ALGORITHM_IDS, + timeout = DEFAULT_REGISTER_OPTIONS_TIMEOUT, + tenantId = DEFAULT_TENANT_ID, + userContext, + ...rest + }: { + requireResidentKey?: boolean; + residentKey?: ResidentKey; + userVerification?: UserVerification; + attestation?: Attestation; + supportedAlgorithmIds?: number[]; + timeout?: number; + tenantId?: string; + userContext?: Record; + } & ( + | { relyingPartyId: string; relyingPartyName: string; origin: string } + | { request: BaseRequest; relyingPartyId?: string; relyingPartyName?: string; origin?: string } + ) & + ( + | { + email: string; + } + | { recoverAccountToken: string } + )): Promise< | { status: "OK"; webauthnGeneratedOptionsId: string; @@ -87,40 +110,92 @@ export default class Wrapper { } | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } | { status: "INVALID_EMAIL_ERROR"; err: string } + | { status: "INVALID_GENERATED_OPTIONS_ERROR" } > { - let payload: { email: string } | { recoverAccountToken: string } | null = email - ? { email } - : recoverAccountToken - ? { recoverAccountToken } - : null; - - if (!payload) { + let emailOrRecoverAccountToken: { email: string } | { recoverAccountToken: string }; + if ("email" in rest || "recoverAccountToken" in rest) { + if ("email" in rest) { + emailOrRecoverAccountToken = { email: rest.email }; + } else { + emailOrRecoverAccountToken = { recoverAccountToken: rest.recoverAccountToken }; + } + } else { return { status: "INVALID_EMAIL_ERROR", err: "Email is missing" }; } + let relyingPartyId: string; + let relyingPartyName: string; + let origin: string; + if ("request" in rest) { + origin = + rest.origin || + (await Recipe.getInstanceOrThrowError().config.getOrigin({ + request: rest.request, + tenantId: tenantId, + userContext: getUserContext(userContext), + })); + relyingPartyId = + rest.relyingPartyId || + (await Recipe.getInstanceOrThrowError().config.getRelyingPartyId({ + request: rest.request, + tenantId: tenantId, + userContext: getUserContext(userContext), + })); + relyingPartyName = + rest.relyingPartyName || + (await Recipe.getInstanceOrThrowError().config.getRelyingPartyName({ + tenantId: tenantId, + userContext: getUserContext(userContext), + })); + } else { + if (!rest.origin) { + throw new Error({ type: "BAD_INPUT_ERROR", message: "Origin missing from the input" }); + } + if (!rest.relyingPartyId) { + throw new Error({ type: "BAD_INPUT_ERROR", message: "RelyingPartyId missing from the input" }); + } + if (!rest.relyingPartyName) { + throw new Error({ type: "BAD_INPUT_ERROR", message: "RelyingPartyName missing from the input" }); + } + + origin = rest.origin; + relyingPartyId = rest.relyingPartyId; + relyingPartyName = rest.relyingPartyName; + } + return Recipe.getInstanceOrThrowError().recipeInterfaceImpl.registerOptions({ - requireResidentKey: DEFAULT_REGISTER_OPTIONS_REQUIRE_RESIDENT_KEY, - residentKey: DEFAULT_REGISTER_OPTIONS_RESIDENT_KEY, - userVerification: DEFAULT_REGISTER_OPTIONS_USER_VERIFICATION, - supportedAlgorithmIds: DEFAULT_REGISTER_OPTIONS_SUPPORTED_ALGORITHM_IDS, - ...payload, + ...emailOrRecoverAccountToken, + requireResidentKey, + residentKey, + userVerification, + supportedAlgorithmIds, relyingPartyId, relyingPartyName, origin, timeout, attestation, - tenantId: tenantId === undefined ? DEFAULT_TENANT_ID : tenantId, + tenantId, userContext: getUserContext(userContext), }); } - static signInOptions( - relyingPartyId: string, - origin: string, - timeout: number, - tenantId: string, - userContext: Record - ): Promise< + static async signInOptions({ + email, + tenantId = DEFAULT_TENANT_ID, + userVerification = DEFAULT_SIGNIN_OPTIONS_USER_VERIFICATION, + timeout = DEFAULT_SIGNIN_OPTIONS_TIMEOUT, + userContext, + ...rest + }: { + email?: string; + timeout?: number; + userVerification?: UserVerification; + tenantId?: string; + userContext?: Record; + } & ( + | { relyingPartyId: string; origin: string } + | { request: BaseRequest; relyingPartyId?: string; origin?: string } + )): Promise< | { status: "OK"; webauthnGeneratedOptionsId: string; @@ -128,72 +203,70 @@ export default class Wrapper { timeout: number; userVerification: UserVerification; } - | { status: "WRONG_CREDENTIALS_ERROR" } + | { status: "INVALID_GENERATED_OPTIONS_ERROR" } > { - return Recipe.getInstanceOrThrowError().recipeInterfaceImpl.signInOptions({ - userVerification: DEFAULT_SIGNIN_OPTIONS_USER_VERIFICATION, + let origin: string; + let relyingPartyId: string; + if ("request" in rest) { + relyingPartyId = + rest.relyingPartyId || + (await Recipe.getInstanceOrThrowError().config.getRelyingPartyId({ + request: rest.request, + tenantId: tenantId, + userContext: getUserContext(userContext), + })); + + origin = + rest.origin || + (await Recipe.getInstanceOrThrowError().config.getOrigin({ + request: rest.request, + tenantId: tenantId, + userContext: getUserContext(userContext), + })); + } else { + if (!rest.relyingPartyId) { + throw new Error({ type: "BAD_INPUT_ERROR", message: "RelyingPartyId missing from the input" }); + } + if (!rest.origin) { + throw new Error({ type: "BAD_INPUT_ERROR", message: "Origin missing from the input" }); + } + relyingPartyId = rest.relyingPartyId; + origin = rest.origin; + } + + return await Recipe.getInstanceOrThrowError().recipeInterfaceImpl.signInOptions({ + email, relyingPartyId, origin, timeout, - tenantId: tenantId === undefined ? DEFAULT_TENANT_ID : tenantId, + tenantId, + userVerification, userContext: getUserContext(userContext), }); } - static signUp( - tenantId: string, - webauthnGeneratedOptionsId: string, - credential: CredentialPayload, - session?: undefined, - userContext?: Record - ): Promise< - | { - status: "OK"; - user: User; - recipeUserId: RecipeUserId; - } - | { status: "EMAIL_ALREADY_EXISTS_ERROR" } - | { status: "WRONG_CREDENTIALS_ERROR" } - | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string } - >; - static signUp( - tenantId: string, - webauthnGeneratedOptionsId: string, - credential: CredentialPayload, - session: SessionContainerInterface, - userContext?: Record - ): Promise< - | { - status: "OK"; - user: User; - recipeUserId: RecipeUserId; - } - | { status: "EMAIL_ALREADY_EXISTS_ERROR" } - | { status: "WRONG_CREDENTIALS_ERROR" } - | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string } - | { - 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 signUp( - tenantId: string, - webauthnGeneratedOptionsId: string, - credential: CredentialPayload, - session?: SessionContainerInterface, - userContext?: Record - ): Promise< + static signUp({ + tenantId = DEFAULT_TENANT_ID, + webauthnGeneratedOptionsId, + credential, + session, + userContext, + }: { + tenantId?: string; + webauthnGeneratedOptionsId: string; + credential: CredentialPayload; + userContext?: Record; + session?: SessionContainerInterface; + }): Promise< | { status: "OK"; user: User; recipeUserId: RecipeUserId; } | { status: "EMAIL_ALREADY_EXISTS_ERROR" } - | { status: "WRONG_CREDENTIALS_ERROR" } + | { status: "INVALID_CREDENTIALS_ERROR" } + | { status: "GENERATED_OPTIONS_NOT_FOUND_ERROR" } + | { status: "INVALID_GENERATED_OPTIONS_ERROR" } | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string } | { status: "LINKING_TO_SESSION_USER_FAILED"; @@ -209,45 +282,26 @@ export default class Wrapper { credential, session, shouldTryLinkingWithSessionUser: !!session, - tenantId: tenantId === undefined ? DEFAULT_TENANT_ID : tenantId, + tenantId, userContext: getUserContext(userContext), }); } - static signIn( - tenantId: string, - webauthnGeneratedOptionsId: string, - credential: CredentialPayload, - session?: undefined, - userContext?: Record - ): Promise<{ status: "OK"; user: User; recipeUserId: RecipeUserId } | { status: "WRONG_CREDENTIALS_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: "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< + static signIn({ + tenantId = DEFAULT_TENANT_ID, + webauthnGeneratedOptionsId, + credential, + session, + userContext, + }: { + tenantId?: string; + webauthnGeneratedOptionsId: string; + credential: CredentialPayload; + session?: SessionContainerInterface; + userContext?: Record; + }): Promise< | { status: "OK"; user: User; recipeUserId: RecipeUserId } - | { status: "WRONG_CREDENTIALS_ERROR" } + | { status: "INVALID_CREDENTIALS_ERROR" } | { status: "LINKING_TO_SESSION_USER_FAILED"; reason: @@ -262,21 +316,26 @@ export default class Wrapper { credential, session, shouldTryLinkingWithSessionUser: !!session, - tenantId: tenantId === undefined ? DEFAULT_TENANT_ID : tenantId, + tenantId, userContext: getUserContext(userContext), }); } - static async verifyCredentials( - tenantId: string, - webauthnGeneratedOptionsId: string, - credential: CredentialPayload, - userContext?: Record - ): Promise<{ status: "OK" } | { status: "WRONG_CREDENTIALS_ERROR" }> { + static async verifyCredentials({ + tenantId = DEFAULT_TENANT_ID, + webauthnGeneratedOptionsId, + credential, + userContext, + }: { + tenantId?: string; + webauthnGeneratedOptionsId: string; + credential: CredentialPayload; + userContext?: Record; + }): Promise<{ status: "OK" } | { status: "INVALID_CREDENTIALS_ERROR" }> { const resp = await Recipe.getInstanceOrThrowError().recipeInterfaceImpl.verifyCredentials({ webauthnGeneratedOptionsId, credential, - tenantId: tenantId === undefined ? DEFAULT_TENANT_ID : tenantId, + tenantId, userContext: getUserContext(userContext), }); @@ -297,33 +356,48 @@ export default class Wrapper { * * 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" }> { + static generateRecoverAccountToken({ + tenantId = DEFAULT_TENANT_ID, + userId, + email, + userContext, + }: { + 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, + tenantId, userContext: getUserContext(userContext), }); } - static async recoverAccount( - tenantId: string, - webauthnGeneratedOptionsId: string, - token: string, - credential: CredentialPayload, - userContext?: Record - ): Promise< + static async recoverAccount({ + tenantId = DEFAULT_TENANT_ID, + webauthnGeneratedOptionsId, + token, + credential, + userContext, + }: { + tenantId?: string; + webauthnGeneratedOptionsId: string; + token: string; + credential: CredentialPayload; + userContext?: Record; + }): Promise< | { - status: "OK" | "WRONG_CREDENTIALS_ERROR" | "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR"; + status: "OK"; } + | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } + | { status: "INVALID_CREDENTIALS_ERROR" } + | { status: "GENERATED_OPTIONS_NOT_FOUND_ERROR" } + | { status: "INVALID_GENERATED_OPTIONS_ERROR" } | { status: "INVALID_AUTHENTICATOR_ERROR"; failureReason: string } > { - const consumeResp = await Wrapper.consumeRecoverAccountToken(tenantId, token, userContext); + const consumeResp = await Wrapper.consumeRecoverAccountToken({ tenantId, token, userContext }); if (consumeResp.status !== "OK") { return consumeResp; @@ -333,26 +407,29 @@ export default class Wrapper { 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< + static consumeRecoverAccountToken({ + tenantId = DEFAULT_TENANT_ID, + token, + userContext, + }: { + tenantId?: string; + token: string; + userContext?: Record; + }): Promise< | { status: "OK"; email: string; @@ -362,61 +439,79 @@ export default class Wrapper { > { return Recipe.getInstanceOrThrowError().recipeInterfaceImpl.consumeRecoverAccountToken({ token, - tenantId: tenantId === undefined ? DEFAULT_TENANT_ID : tenantId, + tenantId, userContext: getUserContext(userContext), }); } - static registerCredential(input: { + static registerCredential({ + recipeUserId, + webauthnGeneratedOptionsId, + credential, + userContext, + }: { recipeUserId: RecipeUserId; - tenantId: string; webauthnGeneratedOptionsId: string; credential: CredentialPayload; userContext?: Record; }): Promise< | { - status: "OK" | "WRONG_CREDENTIALS_ERROR"; + status: "OK"; } - | { status: "WRONG_CREDENTIALS_ERROR" } + | { status: "INVALID_CREDENTIALS_ERROR" } + | { status: "GENERATED_OPTIONS_NOT_FOUND_ERROR" } + | { status: "INVALID_GENERATED_OPTIONS_ERROR" } | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string } > { return Recipe.getInstanceOrThrowError().recipeInterfaceImpl.registerCredential({ - ...input, - userContext: getUserContext(input.userContext), + recipeUserId, + webauthnGeneratedOptionsId, + credential, + userContext: getUserContext(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); + static async createRecoverAccountLink({ + tenantId = DEFAULT_TENANT_ID, + userId, + email, + userContext, + }: { + tenantId?: string; + userId: string; + email: string; + userContext?: Record; + }): Promise<{ status: "OK"; link: string } | { status: "UNKNOWN_USER_ID_ERROR" }> { + let token = await this.generateRecoverAccountToken({ tenantId, userId, email, userContext }); if (token.status === "UNKNOWN_USER_ID_ERROR") { return token; } + const ctx = getUserContext(userContext); const recipeInstance = Recipe.getInstanceOrThrowError(); return { status: "OK", link: getRecoverAccountLink({ appInfo: recipeInstance.getAppInfo(), token: token.token, - tenantId: tenantId === undefined ? DEFAULT_TENANT_ID : tenantId, + tenantId, request: getRequestFromUserContext(ctx), userContext: ctx, }), }; } - static async sendRecoverAccountEmail( - tenantId: string, - userId: string, - email: string, - userContext?: Record - ): Promise<{ status: "OK" | "UNKNOWN_USER_ID_ERROR" }> { + static async sendRecoverAccountEmail({ + tenantId = DEFAULT_TENANT_ID, + userId, + email, + userContext, + }: { + 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" }; @@ -427,7 +522,7 @@ export default class Wrapper { return { status: "UNKNOWN_USER_ID_ERROR" }; } - let link = await this.createRecoverAccountLink(tenantId, userId, email, userContext); + let link = await this.createRecoverAccountLink({ tenantId, userId, email, userContext }); if (link.status === "UNKNOWN_USER_ID_ERROR") { return link; } @@ -455,7 +550,7 @@ export default class Wrapper { let recipeInstance = Recipe.getInstanceOrThrowError(); return await recipeInstance.emailDelivery.ingredientInterfaceImpl.sendEmail({ ...input, - tenantId: input.tenantId === undefined ? DEFAULT_TENANT_ID : input.tenantId, + tenantId: input.tenantId || DEFAULT_TENANT_ID, userContext: getUserContext(input.userContext), }); } diff --git a/lib/ts/recipe/webauthn/recipe.ts b/lib/ts/recipe/webauthn/recipe.ts index d24e54ac0..f34ec71cf 100644 --- a/lib/ts/recipe/webauthn/recipe.ts +++ b/lib/ts/recipe/webauthn/recipe.ts @@ -342,14 +342,7 @@ export default class Recipe extends RecipeModule { handleError = async (err: STError, _request: BaseRequest, response: BaseResponse): Promise => { if (err.fromRecipe === Recipe.RECIPE_ID) { - if (err.type === STError.FIELD_ERROR) { - return send200Response(response, { - status: "FIELD_ERROR", - formFields: err.payload, - }); - } else { - throw err; - } + throw err; } else { throw err; } diff --git a/lib/ts/recipe/webauthn/recipeImplementation.ts b/lib/ts/recipe/webauthn/recipeImplementation.ts index 062cef00d..525ee7fca 100644 --- a/lib/ts/recipe/webauthn/recipeImplementation.ts +++ b/lib/ts/recipe/webauthn/recipeImplementation.ts @@ -8,6 +8,7 @@ import { DEFAULT_TENANT_ID } from "../multitenancy/constants"; import { LoginMethod, User } from "../../user"; import { AuthUtils } from "../../authUtils"; import * as jose from "jose"; +import { isFakeEmail } from "../thirdparty/utils"; export default function getRecipeInterface( querier: Querier, @@ -63,12 +64,26 @@ export default function getRecipeInterface( }; } + // set a nice default display name + // if the user has a fake email, we use the username part of the email instead (which should be the recipe user id) + let displayName: string; + if (rest.displayName) { + displayName = rest.displayName; + } else { + if (isFakeEmail(email)) { + displayName = email.split("@")[0]; + } else { + displayName = email; + } + } + return await querier.sendPostRequest( new NormalisedURLPath( `/${tenantId === undefined ? DEFAULT_TENANT_ID : tenantId}/recipe/webauthn/options/register` ), { email, + displayName, relyingPartyName, relyingPartyId, origin, @@ -209,7 +224,7 @@ export default function getRecipeInterface( } return { - status: "WRONG_CREDENTIALS_ERROR", + status: "INVALID_CREDENTIALS_ERROR", }; }, @@ -289,7 +304,7 @@ export default function getRecipeInterface( } return { - status: "WRONG_CREDENTIALS_ERROR", + status: "INVALID_CREDENTIALS_ERROR", }; }, diff --git a/lib/ts/recipe/webauthn/types.ts b/lib/ts/recipe/webauthn/types.ts index 74deff6f9..2f694f1b1 100644 --- a/lib/ts/recipe/webauthn/types.ts +++ b/lib/ts/recipe/webauthn/types.ts @@ -98,34 +98,40 @@ export type TypeInputValidateEmailAddress = ( ) => Promise | string | undefined; // centralize error types in order to prevent missing cascading errors -// type RegisterOptionsErrorResponse = { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" }; +// type RegisterOptionsErrorResponse = { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } | { status: "INVALID_EMAIL_ERROR"; err: string } | { status: "INVALID_GENERATED_OPTIONS_ERROR" }; -// type SignInOptionsErrorResponse = { status: "WRONG_CREDENTIALS_ERROR" }; +// type SignInOptionsErrorResponse = { status: "INVALID_GENERATED_OPTIONS_ERROR" }; // type SignUpErrorResponse = // | { status: "EMAIL_ALREADY_EXISTS_ERROR" } -// | { status: "WRONG_CREDENTIALS_ERROR" } +// | { status: "INVALID_CREDENTIALS_ERROR" } +// | { status: "GENERATED_OPTIONS_NOT_FOUND_ERROR" } +// | { status: "INVALID_GENERATED_OPTIONS_ERROR" } // | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string }; -// type SignInErrorResponse = { status: "WRONG_CREDENTIALS_ERROR" }; +// type SignInErrorResponse = { status: "INVALID_CREDENTIALS_ERROR" }; -// type VerifyCredentialsErrorResponse = { status: "WRONG_CREDENTIALS_ERROR" }; +// type VerifyCredentialsErrorResponse = { status: "INVALID_CREDENTIALS_ERROR" }; // type GenerateRecoverAccountTokenErrorResponse = { status: "UNKNOWN_USER_ID_ERROR" }; // type ConsumeRecoverAccountTokenErrorResponse = { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" }; // type RegisterCredentialErrorResponse = -// | { status: "WRONG_CREDENTIALS_ERROR" } +// | { status: "INVALID_CREDENTIALS_ERROR" } +// | { status: "GENERATED_OPTIONS_NOT_FOUND_ERROR" } +// | { status: "INVALID_GENERATED_OPTIONS_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 }; // type CreateNewRecipeUserErrorResponse = -// | { status: "WRONG_CREDENTIALS_ERROR" } +// | { status: "INVALID_CREDENTIALS_ERROR" } // the credential is not valid for various reasons - will discover this during implementation +// | { status: "GENERATED_OPTIONS_NOT_FOUND_ERROR" } // i.e. options not found +// | { status: "INVALID_GENERATED_OPTIONS_ERROR" } // i.e. timeout expired // | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string } // | { status: "EMAIL_ALREADY_EXISTS_ERROR" }; -// type DecodeCredentialErrorResponse = { status: "WRONG_CREDENTIALS_ERROR" }; +// type DecodeCredentialErrorResponse = { status: "INVALID_CREDENTIALS_ERROR" }; // type GetUserFromRecoverAccountTokenErrorResponse = { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" }; @@ -148,6 +154,7 @@ export type RecipeInterface = { input: { relyingPartyId: string; relyingPartyName: string; + displayName?: 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 @@ -206,6 +213,7 @@ export type RecipeInterface = { // | RegisterOptionsErrorResponse | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } | { status: "INVALID_EMAIL_ERROR"; err: string } + | { status: "INVALID_GENERATED_OPTIONS_ERROR" } >; signInOptions(input: { @@ -225,7 +233,7 @@ export type RecipeInterface = { userVerification: UserVerification; } // | SignInOptionsErrorResponse - | { status: "WRONG_CREDENTIALS_ERROR" } + | { status: "INVALID_GENERATED_OPTIONS_ERROR" } >; signUp(input: { @@ -243,7 +251,9 @@ export type RecipeInterface = { } // | SignUpErrorResponse | { status: "EMAIL_ALREADY_EXISTS_ERROR" } - | { status: "WRONG_CREDENTIALS_ERROR" } + | { status: "INVALID_CREDENTIALS_ERROR" } + | { status: "GENERATED_OPTIONS_NOT_FOUND_ERROR" } + | { status: "INVALID_GENERATED_OPTIONS_ERROR" } | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string } | { status: "LINKING_TO_SESSION_USER_FAILED"; @@ -265,7 +275,7 @@ export type RecipeInterface = { }): Promise< | { status: "OK"; user: User; recipeUserId: RecipeUserId } // | SignInErrorResponse - | { status: "WRONG_CREDENTIALS_ERROR" } + | { status: "INVALID_CREDENTIALS_ERROR" } | { status: "LINKING_TO_SESSION_USER_FAILED"; reason: @@ -284,7 +294,7 @@ export type RecipeInterface = { }): Promise< | { status: "OK"; user: User; recipeUserId: RecipeUserId } // | VerifyCredentialsErrorResponse - | { status: "WRONG_CREDENTIALS_ERROR" } + | { status: "INVALID_CREDENTIALS_ERROR" } >; // this function is meant only for creating the recipe in the core and nothing else. @@ -303,7 +313,9 @@ export type RecipeInterface = { recipeUserId: RecipeUserId; } // | CreateNewRecipeUserErrorResponse - | { status: "WRONG_CREDENTIALS_ERROR" } + | { status: "INVALID_CREDENTIALS_ERROR" } // the credential is not valid for various reasons - will discover this during implementation + | { status: "GENERATED_OPTIONS_NOT_FOUND_ERROR" } // i.e. options not found + | { status: "INVALID_GENERATED_OPTIONS_ERROR" } // i.e. timeout expired | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string } | { status: "EMAIL_ALREADY_EXISTS_ERROR" } >; @@ -353,7 +365,9 @@ export type RecipeInterface = { status: "OK"; } // | RegisterCredentialErrorResponse - | { status: "WRONG_CREDENTIALS_ERROR" } + | { status: "INVALID_CREDENTIALS_ERROR" } + | { status: "GENERATED_OPTIONS_NOT_FOUND_ERROR" } + | { status: "INVALID_GENERATED_OPTIONS_ERROR" } | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string } >; @@ -416,7 +430,7 @@ export type RecipeInterface = { }; } // | DecodeCredentialErrorResponse - | { status: "WRONG_CREDENTIALS_ERROR" } + | { status: "INVALID_CREDENTIALS_ERROR" } >; // used for retrieving the user details (email) from the recover account token @@ -515,21 +529,25 @@ export type APIOptions = { // type RegisterOptionsPOSTErrorResponse = // | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } -// | { status: "INVALID_EMAIL_ERROR"; err: string }; +// | { status: "INVALID_EMAIL_ERROR"; err: string } +// | { status: "INVALID_GENERATED_OPTIONS_ERROR" }; -// type SignInOptionsPOSTErrorResponse = { status: "WRONG_CREDENTIALS_ERROR" }; +// type SignInOptionsPOSTErrorResponse = +// | { status: "INVALID_GENERATED_OPTIONS_ERROR" }; // type SignUpPOSTErrorResponse = // | { // status: "SIGN_UP_NOT_ALLOWED"; // reason: string; // } -// | { status: "EMAIL_ALREADY_EXISTS_ERROR" } -// | { status: "WRONG_CREDENTIALS_ERROR" } -// | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string }; +// | { status: "INVALID_CREDENTIALS_ERROR" } // the credential is not valid for various reasons - will discover this during implementation +// | { status: "GENERATED_OPTIONS_NOT_FOUND_ERROR" } // i.e. options not found +// | { status: "INVALID_GENERATED_OPTIONS_ERROR" } // i.e. timeout expired +// | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string } +// | { status: "EMAIL_ALREADY_EXISTS_ERROR" }; // type SignInPOSTErrorResponse = -// | { status: "WRONG_CREDENTIALS_ERROR" } +// | { status: "INVALID_CREDENTIALS_ERROR" } // | { // status: "SIGN_IN_NOT_ALLOWED"; // reason: string; @@ -542,7 +560,9 @@ export type APIOptions = { // type RecoverAccountPOSTErrorResponse = // | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } -// | { status: "WRONG_CREDENTIALS_ERROR" } +// | { status: "INVALID_CREDENTIALS_ERROR" } // the credential is not valid for various reasons - will discover this during implementation +// | { status: "GENERATED_OPTIONS_NOT_FOUND_ERROR" } // i.e. options not found +// | { status: "INVALID_GENERATED_OPTIONS_ERROR" } // i.e. timeout expired // | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string }; export type APIInterface = { @@ -589,6 +609,7 @@ export type APIInterface = { // | RegisterOptionsPOSTErrorResponse | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } | { status: "INVALID_EMAIL_ERROR"; err: string } + | { status: "INVALID_GENERATED_OPTIONS_ERROR" } >); signInOptionsPOST: @@ -608,7 +629,7 @@ export type APIInterface = { } | GeneralErrorResponse // | SignInOptionsPOSTErrorResponse - | { status: "WRONG_CREDENTIALS_ERROR" } + | { status: "INVALID_GENERATED_OPTIONS_ERROR" } >); signUpPOST: @@ -634,9 +655,11 @@ export type APIInterface = { status: "SIGN_UP_NOT_ALLOWED"; reason: string; } - | { status: "EMAIL_ALREADY_EXISTS_ERROR" } - | { status: "WRONG_CREDENTIALS_ERROR" } + | { status: "INVALID_CREDENTIALS_ERROR" } // the credential is not valid for various reasons - will discover this during implementation + | { status: "GENERATED_OPTIONS_NOT_FOUND_ERROR" } // i.e. options not found + | { status: "INVALID_GENERATED_OPTIONS_ERROR" } // i.e. timeout expired | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string } + | { status: "EMAIL_ALREADY_EXISTS_ERROR" } >); signInPOST: @@ -661,7 +684,7 @@ export type APIInterface = { status: "SIGN_IN_NOT_ALLOWED"; reason: string; } - | { status: "WRONG_CREDENTIALS_ERROR" } + | { status: "INVALID_CREDENTIALS_ERROR" } >); generateRecoverAccountTokenPOST: @@ -701,7 +724,9 @@ export type APIInterface = { | GeneralErrorResponse // | RecoverAccountPOSTErrorResponse | { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR" } - | { status: "WRONG_CREDENTIALS_ERROR" } + | { status: "INVALID_CREDENTIALS_ERROR" } // the credential is not valid for various reasons - will discover this during implementation + | { status: "GENERATED_OPTIONS_NOT_FOUND_ERROR" } // i.e. options not found + | { status: "INVALID_GENERATED_OPTIONS_ERROR" } // i.e. timeout expired | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string } >); From 3bb7234cca9563739006949a4a5956e7352ae403 Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Tue, 12 Nov 2024 18:37:04 +0200 Subject: [PATCH 22/25] pr fixes --- lib/ts/recipe/webauthn/api/implementation.ts | 1 - lib/ts/recipe/webauthn/recipe.ts | 1 - lib/ts/recipe/webauthn/recipeImplementation.ts | 3 +-- 3 files changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/ts/recipe/webauthn/api/implementation.ts b/lib/ts/recipe/webauthn/api/implementation.ts index 2a6d5a621..bc321c4f6 100644 --- a/lib/ts/recipe/webauthn/api/implementation.ts +++ b/lib/ts/recipe/webauthn/api/implementation.ts @@ -1050,7 +1050,6 @@ export default function getAPIImplementation(): APIInterface { userContext, }); - // todo decide how to handle these if ( createUserResponse.status === "INVALID_CREDENTIALS_ERROR" || createUserResponse.status === "GENERATED_OPTIONS_NOT_FOUND_ERROR" || diff --git a/lib/ts/recipe/webauthn/recipe.ts b/lib/ts/recipe/webauthn/recipe.ts index f34ec71cf..23cd5a717 100644 --- a/lib/ts/recipe/webauthn/recipe.ts +++ b/lib/ts/recipe/webauthn/recipe.ts @@ -97,7 +97,6 @@ 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) { diff --git a/lib/ts/recipe/webauthn/recipeImplementation.ts b/lib/ts/recipe/webauthn/recipeImplementation.ts index 525ee7fca..db6e693f6 100644 --- a/lib/ts/recipe/webauthn/recipeImplementation.ts +++ b/lib/ts/recipe/webauthn/recipeImplementation.ts @@ -33,8 +33,7 @@ export default function getRecipeInterface( if (emailInput !== undefined) { email = emailInput; } else if (recoverAccountTokenInput !== undefined) { - // 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 of the token will be done during consumeRecoverAccountToken + // the actual validation of the token will be done during consumeRecoverAccountToken let decoded: jose.JWTPayload | undefined; try { decoded = await jose.decodeJwt(recoverAccountTokenInput); From ce371d1497544fc3e6fc7783e406afd1e62dfe10 Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Tue, 12 Nov 2024 18:38:05 +0200 Subject: [PATCH 23/25] pr fixes --- lib/ts/recipe/webauthn/api/implementation.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/ts/recipe/webauthn/api/implementation.ts b/lib/ts/recipe/webauthn/api/implementation.ts index bc321c4f6..2a00b910c 100644 --- a/lib/ts/recipe/webauthn/api/implementation.ts +++ b/lib/ts/recipe/webauthn/api/implementation.ts @@ -907,7 +907,6 @@ export default function getAPIImplementation(): APIInterface { 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. @@ -977,7 +976,6 @@ export default function getAPIImplementation(): APIInterface { userContext, }); - // todo decide how to handle these if (tokenConsumptionResponse.status === "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR") { return tokenConsumptionResponse; } From d356701f980208bf9a43a3673e5c2fd6fb8f4abd Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Mon, 25 Nov 2024 13:44:43 +0200 Subject: [PATCH 24/25] added missing default email delivery implementation --- .../services/backwardCompatibility/index.ts | 93 ++ .../webauthn/emaildelivery/services/index.ts | 17 + .../emaildelivery/services/smtp/index.ts | 50 + .../services/smtp/recoverAccount.ts | 945 ++++++++++++++++++ .../smtp/serviceImplementation/index.ts | 57 ++ lib/ts/recipe/webauthn/recipe.ts | 5 +- lib/ts/recipe/webauthn/utils.ts | 25 +- 7 files changed, 1181 insertions(+), 11 deletions(-) create mode 100644 lib/ts/recipe/webauthn/emaildelivery/services/backwardCompatibility/index.ts create mode 100644 lib/ts/recipe/webauthn/emaildelivery/services/index.ts create mode 100644 lib/ts/recipe/webauthn/emaildelivery/services/smtp/index.ts create mode 100644 lib/ts/recipe/webauthn/emaildelivery/services/smtp/recoverAccount.ts create mode 100644 lib/ts/recipe/webauthn/emaildelivery/services/smtp/serviceImplementation/index.ts diff --git a/lib/ts/recipe/webauthn/emaildelivery/services/backwardCompatibility/index.ts b/lib/ts/recipe/webauthn/emaildelivery/services/backwardCompatibility/index.ts new file mode 100644 index 000000000..1e2e57d5b --- /dev/null +++ b/lib/ts/recipe/webauthn/emaildelivery/services/backwardCompatibility/index.ts @@ -0,0 +1,93 @@ +/* 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 { TypeWebauthnEmailDeliveryInput } from "../../../types"; +import { NormalisedAppinfo, UserContext } from "../../../../../types"; +import { EmailDeliveryInterface } from "../../../../../ingredients/emaildelivery/types"; +import { isTestEnv, postWithFetch } from "../../../../../utils"; + +async function createAndSendEmailUsingSupertokensService(input: { + appInfo: NormalisedAppinfo; + // Where the message should be delivered. + user: { email: string; id: string }; + // This has to be entered on the starting device to finish sign in/up + recoverAccountLink: string; +}): Promise { + if (isTestEnv()) { + return; + } + const result = await postWithFetch( + "https://api.supertokens.io/0/st/auth/webauthn/recover", + { + "api-version": "0", + "content-type": "application/json; charset=utf-8", + }, + { + email: input.user.email, + appName: input.appInfo.appName, + recoverAccountURL: input.recoverAccountLink, + }, + { + successLog: `Email sent to ${input.user.email}`, + errorLogHeader: "Error sending webauthn recover account email", + } + ); + if ("error" in result) { + throw result.error; + } + + if (result.resp && result.resp.status >= 400) { + if (result.resp.body.err) { + /** + * if the error is thrown from API, the response object + * will be of type `{err: string}` + */ + throw new Error(result.resp.body.err); + } else { + throw new Error(`Request failed with status code ${result.resp.status}`); + } + } +} + +export default class BackwardCompatibilityService implements EmailDeliveryInterface { + private isInServerlessEnv: boolean; + private appInfo: NormalisedAppinfo; + + constructor(appInfo: NormalisedAppinfo, isInServerlessEnv: boolean) { + this.isInServerlessEnv = isInServerlessEnv; + this.appInfo = appInfo; + } + + sendEmail = async (input: TypeWebauthnEmailDeliveryInput & { userContext: UserContext }) => { + // we add this here cause the user may have overridden the sendEmail function + // to change the input email and if we don't do this, the input email + // will get reset by the getUserById call above. + try { + if (!this.isInServerlessEnv) { + createAndSendEmailUsingSupertokensService({ + appInfo: this.appInfo, + user: input.user, + recoverAccountLink: input.recoverAccountLink, + }).catch((_) => {}); + } else { + // see https://github.com/supertokens/supertokens-node/pull/135 + await createAndSendEmailUsingSupertokensService({ + appInfo: this.appInfo, + user: input.user, + recoverAccountLink: input.recoverAccountLink, + }); + } + } catch (_) {} + }; +} diff --git a/lib/ts/recipe/webauthn/emaildelivery/services/index.ts b/lib/ts/recipe/webauthn/emaildelivery/services/index.ts new file mode 100644 index 000000000..d70e3f387 --- /dev/null +++ b/lib/ts/recipe/webauthn/emaildelivery/services/index.ts @@ -0,0 +1,17 @@ +/* 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 SMTP from "./smtp"; +export let SMTPService = SMTP; diff --git a/lib/ts/recipe/webauthn/emaildelivery/services/smtp/index.ts b/lib/ts/recipe/webauthn/emaildelivery/services/smtp/index.ts new file mode 100644 index 000000000..3498b20f3 --- /dev/null +++ b/lib/ts/recipe/webauthn/emaildelivery/services/smtp/index.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 { ServiceInterface, TypeInput } from "../../../../../ingredients/emaildelivery/services/smtp"; +import { TypeWebauthnEmailDeliveryInput } from "../../../types"; +import { EmailDeliveryInterface } from "../../../../../ingredients/emaildelivery/types"; +import { createTransport } from "nodemailer"; +import OverrideableBuilder from "supertokens-js-override"; +import { getServiceImplementation } from "./serviceImplementation"; +import { UserContext } from "../../../../../types"; + +export default class SMTPService implements EmailDeliveryInterface { + serviceImpl: ServiceInterface; + + constructor(config: TypeInput) { + const transporter = createTransport({ + host: config.smtpSettings.host, + port: config.smtpSettings.port, + auth: { + user: config.smtpSettings.authUsername || config.smtpSettings.from.email, + pass: config.smtpSettings.password, + }, + secure: config.smtpSettings.secure, + }); + let builder = new OverrideableBuilder(getServiceImplementation(transporter, config.smtpSettings.from)); + if (config.override !== undefined) { + builder = builder.override(config.override); + } + this.serviceImpl = builder.build(); + } + + sendEmail = async (input: TypeWebauthnEmailDeliveryInput & { userContext: UserContext }) => { + let content = await this.serviceImpl.getContent(input); + await this.serviceImpl.sendRawEmail({ + ...content, + userContext: input.userContext, + }); + }; +} diff --git a/lib/ts/recipe/webauthn/emaildelivery/services/smtp/recoverAccount.ts b/lib/ts/recipe/webauthn/emaildelivery/services/smtp/recoverAccount.ts new file mode 100644 index 000000000..cd95aba07 --- /dev/null +++ b/lib/ts/recipe/webauthn/emaildelivery/services/smtp/recoverAccount.ts @@ -0,0 +1,945 @@ +/* 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 { TypeWebauthnRecoverAccountEmailDeliveryInput } from "../../../types"; +import { GetContentResult } from "../../../../../ingredients/emaildelivery/services/smtp"; +import Supertokens from "../../../../../supertokens"; + +export default function getRecoverAccountEmailContent( + input: TypeWebauthnRecoverAccountEmailDeliveryInput +): GetContentResult { + let supertokens = Supertokens.getInstanceOrThrowError(); + let appName = supertokens.appInfo.appName; + let body = getRecoverAccountEmailHTML(appName, input.user.email, input.recoverAccountLink); + + return { + body, + toEmail: input.user.email, + subject: "Account recovery instructions", + isHtml: true, + }; +} + +export function getRecoverAccountEmailHTML(appName: string, email: string, resetLink: string) { + return ` + + + + + + + + *|MC:SUBJECT|* + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+ + + + + +
+ +
+ + + + + +
+ + + + + + +
+ + +
+
+ +

+ An account recovery request for your account on + ${appName} has been received. +

+ + +
+
+

+ Alternatively, you can directly paste this link + in your browser
+ ${resetLink} +

+
+
+ + + + +
+ + + + + + +
+

+ This email is meant for ${email} +

+
+
+ +
+ + + + + +
+ +
+ +
+
+ + + + `; +} diff --git a/lib/ts/recipe/webauthn/emaildelivery/services/smtp/serviceImplementation/index.ts b/lib/ts/recipe/webauthn/emaildelivery/services/smtp/serviceImplementation/index.ts new file mode 100644 index 000000000..4f141d7dd --- /dev/null +++ b/lib/ts/recipe/webauthn/emaildelivery/services/smtp/serviceImplementation/index.ts @@ -0,0 +1,57 @@ +/* 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 { TypeWebauthnEmailDeliveryInput } from "../../../../types"; +import { Transporter } from "nodemailer"; +import { + ServiceInterface, + TypeInputSendRawEmail, + GetContentResult, +} from "../../../../../../ingredients/emaildelivery/services/smtp"; +import getRecoverAccountEmailContent from "../recoverAccount"; +import { UserContext } from "../../../../../../types"; + +export function getServiceImplementation( + transporter: Transporter, + from: { + name: string; + email: string; + } +): ServiceInterface { + return { + sendRawEmail: async function (input: TypeInputSendRawEmail) { + if (input.isHtml) { + await transporter.sendMail({ + from: `${from.name} <${from.email}>`, + to: input.toEmail, + subject: input.subject, + html: input.body, + }); + } else { + await transporter.sendMail({ + from: `${from.name} <${from.email}>`, + to: input.toEmail, + subject: input.subject, + text: input.body, + }); + } + }, + getContent: async function ( + input: TypeWebauthnEmailDeliveryInput & { userContext: UserContext } + ): Promise { + return getRecoverAccountEmailContent(input); + }, + }; +} diff --git a/lib/ts/recipe/webauthn/recipe.ts b/lib/ts/recipe/webauthn/recipe.ts index 23cd5a717..8620c3fde 100644 --- a/lib/ts/recipe/webauthn/recipe.ts +++ b/lib/ts/recipe/webauthn/recipe.ts @@ -35,7 +35,7 @@ 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 { isTestEnv } from "../../utils"; import RecipeImplementation from "./recipeImplementation"; import APIImplementation from "./api/implementation"; import type { BaseRequest, BaseResponse } from "../../framework"; @@ -322,6 +322,7 @@ export default class Recipe extends RecipeModule { emailDelivery: this.emailDelivery, appInfo: this.getAppInfo(), }; + if (id === REGISTER_OPTIONS_API) { return await registerOptionsAPI(this.apiImpl, tenantId, options, userContext); } else if (id === SIGNIN_OPTIONS_API) { @@ -339,7 +340,7 @@ export default class Recipe extends RecipeModule { } else return false; }; - handleError = async (err: STError, _request: BaseRequest, response: BaseResponse): Promise => { + handleError = async (err: STError, _request: BaseRequest, _response: BaseResponse): Promise => { if (err.fromRecipe === Recipe.RECIPE_ID) { throw err; } else { diff --git a/lib/ts/recipe/webauthn/utils.ts b/lib/ts/recipe/webauthn/utils.ts index 0f5111a6b..1bf968fc7 100644 --- a/lib/ts/recipe/webauthn/utils.ts +++ b/lib/ts/recipe/webauthn/utils.ts @@ -29,6 +29,7 @@ import { import { NormalisedAppinfo, UserContext } from "../../types"; import { RecipeInterface, APIInterface } from "./types"; import { BaseRequest } from "../../framework"; +import BackwardCompatibilityService from "./emaildelivery/services/backwardCompatibility"; export function validateAndNormaliseUserInput( _: Recipe, @@ -52,9 +53,10 @@ export function validateAndNormaliseUserInput( * If the user has not passed even that config, we use the default * createAndSendCustomEmail implementation which calls our supertokens API */ - // if (emailService === undefined) { - // emailService = new BackwardCompatibilityService(appInfo, isInServerlessEnv); - // } + if (emailService === undefined) { + emailService = new BackwardCompatibilityService(appInfo, isInServerlessEnv); + } + return { ...config?.emailDelivery, /** @@ -69,7 +71,7 @@ export function validateAndNormaliseUserInput( * set service at the end */ // todo implemenet this - service: null as any, + service: emailService, }; } return { @@ -92,11 +94,16 @@ function validateAndNormaliseRelyingPartyIdConfig( } else if (typeof relyingPartyIdConfig === "function") { return relyingPartyIdConfig(props); } else { - return Promise.resolve( - normalisedAppinfo - .getOrigin({ request: props.request, userContext: props.userContext }) - .getAsStringDangerous() - ); + const urlString = normalisedAppinfo + .getOrigin({ request: props.request, userContext: props.userContext }) + .getAsStringDangerous(); + + // should let this throw if the url is invalid + const url = new URL(urlString); + + const hostname = url.hostname; + + return Promise.resolve(hostname); } }; } From e743046fbf8a7af5152617b7791ee86c414d6c3d Mon Sep 17 00:00:00 2001 From: Victor Bojica Date: Mon, 25 Nov 2024 13:45:11 +0200 Subject: [PATCH 25/25] added basic tests and mock --- lib/build/recipe/webauthn/api/emailExists.js | 2 +- .../api/generateRecoverAccountToken.js | 2 +- .../recipe/webauthn/api/implementation.js | 83 +- .../recipe/webauthn/api/recoverAccount.js | 5 +- .../recipe/webauthn/api/registerOptions.js | 2 +- .../recipe/webauthn/api/signInOptions.js | 2 +- lib/build/recipe/webauthn/api/signin.js | 4 +- lib/build/recipe/webauthn/api/signup.js | 14 +- lib/build/recipe/webauthn/api/utils.d.ts | 2 +- lib/build/recipe/webauthn/api/utils.js | 8 +- lib/build/recipe/webauthn/constants.js | 2 +- lib/build/recipe/webauthn/core-mock.js | 78 +- .../services/backwardCompatibility/index.d.ts | 14 + .../services/backwardCompatibility/index.js | 66 ++ .../emaildelivery/services/index.d.ts | 3 + .../webauthn/emaildelivery/services/index.js | 24 + .../emaildelivery/services/smtp/index.d.ts | 14 + .../emaildelivery/services/smtp/index.js | 37 + .../services/smtp/recoverAccount.d.ts | 7 + .../services/smtp/recoverAccount.js | 934 ++++++++++++++++++ .../smtp/serviceImplementation/index.d.ts | 11 + .../smtp/serviceImplementation/index.js | 48 + lib/build/recipe/webauthn/error.d.ts | 17 +- lib/build/recipe/webauthn/error.js | 3 +- lib/build/recipe/webauthn/index.d.ts | 306 ++++-- lib/build/recipe/webauthn/index.js | 254 +++-- lib/build/recipe/webauthn/recipe.d.ts | 2 +- lib/build/recipe/webauthn/recipe.js | 14 +- .../recipe/webauthn/recipeImplementation.js | 21 +- lib/build/recipe/webauthn/types.d.ts | 94 +- lib/build/recipe/webauthn/types.js | 2 +- lib/build/recipe/webauthn/utils.d.ts | 2 +- lib/build/recipe/webauthn/utils.js | 61 +- lib/ts/recipe/webauthn/core-mock.ts | 64 +- package-lock.json | 148 +++ package.json | 1 + test/test-server/src/webauthn.ts | 31 + test/utils.js | 2 + test/webauthn/apis.test.js | 149 +++ test/webauthn/config.test.js | 123 +++ 40 files changed, 2317 insertions(+), 339 deletions(-) create mode 100644 lib/build/recipe/webauthn/emaildelivery/services/backwardCompatibility/index.d.ts create mode 100644 lib/build/recipe/webauthn/emaildelivery/services/backwardCompatibility/index.js create mode 100644 lib/build/recipe/webauthn/emaildelivery/services/index.d.ts create mode 100644 lib/build/recipe/webauthn/emaildelivery/services/index.js create mode 100644 lib/build/recipe/webauthn/emaildelivery/services/smtp/index.d.ts create mode 100644 lib/build/recipe/webauthn/emaildelivery/services/smtp/index.js create mode 100644 lib/build/recipe/webauthn/emaildelivery/services/smtp/recoverAccount.d.ts create mode 100644 lib/build/recipe/webauthn/emaildelivery/services/smtp/recoverAccount.js create mode 100644 lib/build/recipe/webauthn/emaildelivery/services/smtp/serviceImplementation/index.d.ts create mode 100644 lib/build/recipe/webauthn/emaildelivery/services/smtp/serviceImplementation/index.js create mode 100644 test/test-server/src/webauthn.ts create mode 100644 test/webauthn/apis.test.js create mode 100644 test/webauthn/config.test.js diff --git a/lib/build/recipe/webauthn/api/emailExists.js b/lib/build/recipe/webauthn/api/emailExists.js index b76ac08c7..878fa5c58 100644 --- a/lib/build/recipe/webauthn/api/emailExists.js +++ b/lib/build/recipe/webauthn/api/emailExists.js @@ -1,5 +1,5 @@ "use strict"; -/* Copyright (c) 2021, VRAI Labs and/or its affiliates. All rights reserved. +/* Copyright (c) 2024, 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. diff --git a/lib/build/recipe/webauthn/api/generateRecoverAccountToken.js b/lib/build/recipe/webauthn/api/generateRecoverAccountToken.js index 651c5a398..0bec60d05 100644 --- a/lib/build/recipe/webauthn/api/generateRecoverAccountToken.js +++ b/lib/build/recipe/webauthn/api/generateRecoverAccountToken.js @@ -1,5 +1,5 @@ "use strict"; -/* Copyright (c) 2021, VRAI Labs and/or its affiliates. All rights reserved. +/* Copyright (c) 2024, 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. diff --git a/lib/build/recipe/webauthn/api/implementation.js b/lib/build/recipe/webauthn/api/implementation.js index 6eef006ec..ed63c0ed8 100644 --- a/lib/build/recipe/webauthn/api/implementation.js +++ b/lib/build/recipe/webauthn/api/implementation.js @@ -30,12 +30,12 @@ function getAPIImplementation() { registerOptionsPOST: async function (_a) { var { tenantId, options, userContext } = _a, props = __rest(_a, ["tenantId", "options", "userContext"]); - const relyingPartyId = await options.config.relyingPartyId({ + const relyingPartyId = await options.config.getRelyingPartyId({ tenantId, request: options.req, userContext, }); - const relyingPartyName = await options.config.relyingPartyName({ + const relyingPartyName = await options.config.getRelyingPartyName({ tenantId, userContext, }); @@ -82,7 +82,7 @@ function getAPIImplementation() { }; }, signInOptionsPOST: async function ({ email, tenantId, options, userContext }) { - const relyingPartyId = await options.config.relyingPartyId({ + const relyingPartyId = await options.config.getRelyingPartyId({ tenantId, request: options.req, userContext, @@ -124,13 +124,15 @@ function getAPIImplementation() { options, userContext, }) { + // TODO update error codes (ERR_CODE_XXX) after final implementation 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.", + INVALID_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)", @@ -148,7 +150,7 @@ function getAPIImplementation() { userContext, }); if (generatedOptions.status !== "OK") { - return { status: "WRONG_CREDENTIALS_ERROR" }; + return generatedOptions; } const email = generatedOptions.email; // NOTE: Following checks will likely never throw an error as the @@ -156,9 +158,10 @@ function getAPIImplementation() { // here to be on the safe side. if (!email) { throw new Error( - "Should never come here since we already check that the email value is a string in validateFormFieldsOrThrowError" + "Should never come here since we already check that the email value is a string in validateEmailAddress" ); } + // todo familiarize with this method const preAuthCheckRes = await authUtils_1.AuthUtils.preAuthChecks({ authenticatingAccountInfo: { recipeId: "webauthn", @@ -228,6 +231,8 @@ function getAPIImplementation() { "SIGN_UP_NOT_ALLOWED" ); } + // todo familiarize with this method + // todo check if we need to remove webauthn credential ids from the type - it is not used atm. const postAuthChecks = await authUtils_1.AuthUtils.postAuthChecks({ authenticatedUser: signUpResponse.user, recipeUserId: signUpResponse.recipeUserId, @@ -280,34 +285,38 @@ function getAPIImplementation() { }, }; const recipeId = "webauthn"; - // do the verification before in order to retrieve the user email - const verifyCredentialsResponse = await options.recipeImplementation.verifyCredentials({ + const verifyResult = await options.recipeImplementation.verifyCredentials({ credential, webauthnGeneratedOptionsId, tenantId, userContext, }); - const checkCredentialsOnTenant = async () => { - return verifyCredentialsResponse.status === "OK"; - }; - // doing it like this because the email is only available after verifyCredentials is called - let email; - if (verifyCredentialsResponse.status == "OK") { - 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_1.AuthUtils.getErrorStatusResponseWithReason( - verifyCredentialsResponse, - errorCodeMap, - "SIGN_IN_NOT_ALLOWED" - ); - } - email = loginMethod === null || loginMethod === void 0 ? void 0 : loginMethod.email; - } else { + if (verifyResult.status !== "OK") { + return verifyResult; + } + const generatedOptions = await options.recipeImplementation.getGeneratedOptions({ + webauthnGeneratedOptionsId, + tenantId, + userContext, + }); + if (generatedOptions.status !== "OK") { return { - status: "WRONG_CREDENTIALS_ERROR", + status: "INVALID_CREDENTIALS_ERROR", }; } + let email = generatedOptions.email; + const checkCredentialsOnTenant = async () => { + return true; + }; + // todo familiarize with this method + // todo make sure the section below (from getAuthenticatingUserAndAddToCurrentTenantIfRequired to isVerified) is correct + // const matchingLoginMethodsFromSessionUser = sessionUser.loginMethods.filter( + // (lm) => + // lm.recipeId === recipeId && + // (lm.hasSameEmailAs(accountInfo.email) || + // lm.hasSamePhoneNumberAs(accountInfo.phoneNumber) || + // lm.hasSameThirdPartyInfoAs(accountInfo.thirdParty)) + // ); const authenticatingUser = await authUtils_1.AuthUtils.getAuthenticatingUserAndAddToCurrentTenantIfRequired( { accountInfo: { email }, @@ -325,7 +334,7 @@ function getAPIImplementation() { // isSignUpAllowed will be called as expected. if (authenticatingUser === undefined) { return { - status: "WRONG_CREDENTIALS_ERROR", + status: "INVALID_CREDENTIALS_ERROR", }; } const preAuthChecks = await authUtils_1.AuthUtils.preAuthChecks({ @@ -358,7 +367,7 @@ function getAPIImplementation() { if (utils_1.isFakeEmail(email) && preAuthChecks.isFirstFactor) { // Fake emails cannot be used as a first factor return { - status: "WRONG_CREDENTIALS_ERROR", + status: "INVALID_CREDENTIALS_ERROR", }; } const signInResponse = await options.recipeImplementation.signIn({ @@ -369,7 +378,7 @@ function getAPIImplementation() { tenantId, userContext, }); - if (signInResponse.status === "WRONG_CREDENTIALS_ERROR") { + if (signInResponse.status === "INVALID_CREDENTIALS_ERROR") { return signInResponse; } if (signInResponse.status !== "OK") { @@ -690,7 +699,6 @@ function getAPIImplementation() { 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. @@ -698,9 +706,9 @@ function getAPIImplementation() { status: "INVALID_AUTHENTICATOR_ERROR", reason: updateResponse.reason, }; - } else if (updateResponse.status === "WRONG_CREDENTIALS_ERROR") { + } else if (updateResponse.status === "INVALID_CREDENTIALS_ERROR") { return { - status: "WRONG_CREDENTIALS_ERROR", + status: "INVALID_CREDENTIALS_ERROR", }; } else { // status: "OK" @@ -755,7 +763,6 @@ function getAPIImplementation() { tenantId, userContext, }); - // todo decide how to handle these if (tokenConsumptionResponse.status === "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR") { return tokenConsumptionResponse; } @@ -818,10 +825,12 @@ function getAPIImplementation() { credential, userContext, }); - // todo decide how to handle these - if (createUserResponse.status === "WRONG_CREDENTIALS_ERROR") { - return createUserResponse; - } else if (createUserResponse.status === "INVALID_AUTHENTICATOR_ERROR") { + if ( + createUserResponse.status === "INVALID_CREDENTIALS_ERROR" || + createUserResponse.status === "GENERATED_OPTIONS_NOT_FOUND_ERROR" || + createUserResponse.status === "INVALID_GENERATED_OPTIONS_ERROR" || + 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 diff --git a/lib/build/recipe/webauthn/api/recoverAccount.js b/lib/build/recipe/webauthn/api/recoverAccount.js index 9e63a577b..acb883f39 100644 --- a/lib/build/recipe/webauthn/api/recoverAccount.js +++ b/lib/build/recipe/webauthn/api/recoverAccount.js @@ -1,5 +1,5 @@ "use strict"; -/* Copyright (c) 2021, VRAI Labs and/or its affiliates. All rights reserved. +/* Copyright (c) 2024, 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. @@ -23,12 +23,11 @@ const utils_1 = require("../../../utils"); const utils_2 = require("./utils"); const error_1 = __importDefault(require("../error")); async function recoverAccount(apiImplementation, tenantId, options, userContext) { - // Logic as per https://github.com/supertokens/supertokens-node/issues/22#issuecomment-710512442 if (apiImplementation.recoverAccountPOST === undefined) { return false; } const requestBody = await options.req.getJSONBody(); - let webauthnGeneratedOptionsId = await utils_2.validatewebauthnGeneratedOptionsIdOrThrowError( + let webauthnGeneratedOptionsId = await utils_2.validateWebauthnGeneratedOptionsIdOrThrowError( requestBody.webauthnGeneratedOptionsId ); let credential = await utils_2.validateCredentialOrThrowError(requestBody.credential); diff --git a/lib/build/recipe/webauthn/api/registerOptions.js b/lib/build/recipe/webauthn/api/registerOptions.js index 6ea9bf0ec..9d6ed7036 100644 --- a/lib/build/recipe/webauthn/api/registerOptions.js +++ b/lib/build/recipe/webauthn/api/registerOptions.js @@ -1,5 +1,5 @@ "use strict"; -/* Copyright (c) 2021, VRAI Labs and/or its affiliates. All rights reserved. +/* Copyright (c) 2024, 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. diff --git a/lib/build/recipe/webauthn/api/signInOptions.js b/lib/build/recipe/webauthn/api/signInOptions.js index 3c04d2808..25034546a 100644 --- a/lib/build/recipe/webauthn/api/signInOptions.js +++ b/lib/build/recipe/webauthn/api/signInOptions.js @@ -1,5 +1,5 @@ "use strict"; -/* Copyright (c) 2021, VRAI Labs and/or its affiliates. All rights reserved. +/* Copyright (c) 2024, 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. diff --git a/lib/build/recipe/webauthn/api/signin.js b/lib/build/recipe/webauthn/api/signin.js index 44cc8bb6f..7a9c59de5 100644 --- a/lib/build/recipe/webauthn/api/signin.js +++ b/lib/build/recipe/webauthn/api/signin.js @@ -1,5 +1,5 @@ "use strict"; -/* Copyright (c) 2021, VRAI Labs and/or its affiliates. All rights reserved. +/* Copyright (c) 2024, 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. @@ -22,7 +22,7 @@ async function signInAPI(apiImplementation, tenantId, options, userContext) { return false; } const requestBody = await options.req.getJSONBody(); - const webauthnGeneratedOptionsId = await utils_2.validatewebauthnGeneratedOptionsIdOrThrowError( + const webauthnGeneratedOptionsId = await utils_2.validateWebauthnGeneratedOptionsIdOrThrowError( requestBody.webauthnGeneratedOptionsId ); const credential = await utils_2.validateCredentialOrThrowError(requestBody.credential); diff --git a/lib/build/recipe/webauthn/api/signup.js b/lib/build/recipe/webauthn/api/signup.js index 8eb70b124..c196907c7 100644 --- a/lib/build/recipe/webauthn/api/signup.js +++ b/lib/build/recipe/webauthn/api/signup.js @@ -1,5 +1,5 @@ "use strict"; -/* Copyright (c) 2021, VRAI Labs and/or its affiliates. All rights reserved. +/* Copyright (c) 2024, 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. @@ -28,7 +28,7 @@ async function signUpAPI(apiImplementation, tenantId, options, userContext) { return false; } const requestBody = await options.req.getJSONBody(); - const webauthnGeneratedOptionsId = await utils_2.validatewebauthnGeneratedOptionsIdOrThrowError( + const webauthnGeneratedOptionsId = await utils_2.validateWebauthnGeneratedOptionsIdOrThrowError( requestBody.webauthnGeneratedOptionsId ); const credential = await utils_2.validateCredentialOrThrowError(requestBody.credential); @@ -63,14 +63,8 @@ async function signUpAPI(apiImplementation, tenantId, options, userContext) { utils_1.send200Response(options.res, result); } else if (result.status === "EMAIL_ALREADY_EXISTS_ERROR") { throw new error_1.default({ - type: error_1.default.FIELD_ERROR, - payload: [ - { - id: "email", - error: "This email already exists. Please sign in instead.", - }, - ], - message: "Error in input formFields", + type: error_1.default.BAD_INPUT_ERROR, + message: "This email already exists. Please sign in instead.", }); } else { utils_1.send200Response(options.res, result); diff --git a/lib/build/recipe/webauthn/api/utils.d.ts b/lib/build/recipe/webauthn/api/utils.d.ts index 8bd411782..881337429 100644 --- a/lib/build/recipe/webauthn/api/utils.d.ts +++ b/lib/build/recipe/webauthn/api/utils.d.ts @@ -1,5 +1,5 @@ // @ts-nocheck -export declare function validatewebauthnGeneratedOptionsIdOrThrowError( +export declare function validateWebauthnGeneratedOptionsIdOrThrowError( webauthnGeneratedOptionsId: string ): Promise; export declare function validateCredentialOrThrowError(credential: T): Promise; diff --git a/lib/build/recipe/webauthn/api/utils.js b/lib/build/recipe/webauthn/api/utils.js index bfc2cd2e9..ae0a5d6fd 100644 --- a/lib/build/recipe/webauthn/api/utils.js +++ b/lib/build/recipe/webauthn/api/utils.js @@ -5,8 +5,8 @@ var __importDefault = return mod && mod.__esModule ? mod : { default: mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); -exports.validateCredentialOrThrowError = exports.validatewebauthnGeneratedOptionsIdOrThrowError = void 0; -/* Copyright (c) 2021, VRAI Labs and/or its affiliates. All rights reserved. +exports.validateCredentialOrThrowError = exports.validateWebauthnGeneratedOptionsIdOrThrowError = void 0; +/* Copyright (c) 2024, 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. @@ -21,13 +21,13 @@ exports.validateCredentialOrThrowError = exports.validatewebauthnGeneratedOption * under the License. */ const error_1 = __importDefault(require("../error")); -async function validatewebauthnGeneratedOptionsIdOrThrowError(webauthnGeneratedOptionsId) { +async function validateWebauthnGeneratedOptionsIdOrThrowError(webauthnGeneratedOptionsId) { if (webauthnGeneratedOptionsId === undefined) { throw newBadRequestError("webauthnGeneratedOptionsId is required"); } return webauthnGeneratedOptionsId; } -exports.validatewebauthnGeneratedOptionsIdOrThrowError = validatewebauthnGeneratedOptionsIdOrThrowError; +exports.validateWebauthnGeneratedOptionsIdOrThrowError = validateWebauthnGeneratedOptionsIdOrThrowError; async function validateCredentialOrThrowError(credential) { if (credential === undefined) { throw newBadRequestError("credential is required"); diff --git a/lib/build/recipe/webauthn/constants.js b/lib/build/recipe/webauthn/constants.js index 2e86a4747..2bb0063da 100644 --- a/lib/build/recipe/webauthn/constants.js +++ b/lib/build/recipe/webauthn/constants.js @@ -1,5 +1,5 @@ "use strict"; -/* Copyright (c) 2021, VRAI Labs and/or its affiliates. All rights reserved. +/* Copyright (c) 2024, 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. diff --git a/lib/build/recipe/webauthn/core-mock.js b/lib/build/recipe/webauthn/core-mock.js index 723cce9f8..c0ac30bd0 100644 --- a/lib/build/recipe/webauthn/core-mock.js +++ b/lib/build/recipe/webauthn/core-mock.js @@ -1,39 +1,67 @@ "use strict"; +var __importDefault = + (this && this.__importDefault) || + function (mod) { + return mod && mod.__esModule ? mod : { default: mod }; + }; Object.defineProperty(exports, "__esModule", { value: true }); exports.getMockQuerier = void 0; const querier_1 = require("../../querier"); +const server_1 = require("@simplewebauthn/server"); +const crypto_1 = __importDefault(require("crypto")); +const db = { + generatedOptions: {}, +}; +const writeDb = (table, key, value) => { + db[table][key] = value; +}; +// const readDb = (table: keyof typeof db, key: string) => { +// return db[table][key]; +// }; const getMockQuerier = (recipeId) => { const querier = querier_1.Querier.getNewInstanceOrThrowError(recipeId); - const sendPostRequest = async (path, body, userContext) => { - console.log("body", body); - console.log("userContext", userContext); + const sendPostRequest = async (path, body, _userContext) => { if (path.getAsStringDangerous().includes("/recipe/webauthn/options/register")) { - // @ts-ignore - return { - status: "OK", - webauthnGeneratedOptionsId: "7ab03f6a-61b8-4f65-992f-b8b8469bc18f", - rp: { id: "example.com", name: "Example App" }, - user: { id: "dummy-user-id", name: "user@example.com", displayName: "User" }, - challenge: "dummy-challenge", - timeout: 60000, - excludeCredentials: [], - attestation: "none", - pubKeyCredParams: [{ alg: -7, type: "public-key" }], + const registrationOptions = await server_1.generateRegistrationOptions({ + rpID: body.relyingPartyId, + rpName: body.relyingPartyName, + userName: body.email, + timeout: body.timeout, + attestationType: body.attestation || "none", authenticatorSelection: { - requireResidentKey: false, - residentKey: "preferred", - userVerification: "preferred", + userVerification: body.userVerification || "preferred", + requireResidentKey: body.requireResidentKey || false, + residentKey: body.residentKey || "required", }, - }; + supportedAlgorithmIDs: body.supportedAlgorithmIDs || [-8, -7, -257], + userDisplayName: body.displayName || body.email, + }); + const id = crypto_1.default.randomUUID(); + writeDb( + "generatedOptions", + id, + Object.assign(Object.assign({}, registrationOptions), { + id, + origin: body.origin, + tenantId: body.tenantId, + }) + ); + // @ts-ignore + return Object.assign({ status: "OK", webauthnGeneratedOptionsId: id }, registrationOptions); } else if (path.getAsStringDangerous().includes("/recipe/webauthn/options/signin")) { + const signInOptions = await server_1.generateAuthenticationOptions({ + rpID: body.relyingPartyId, + timeout: body.timeout, + userVerification: body.userVerification || "preferred", + }); + const id = crypto_1.default.randomUUID(); + writeDb( + "generatedOptions", + id, + Object.assign(Object.assign({}, signInOptions), { id, origin: body.origin, tenantId: body.tenantId }) + ); // @ts-ignore - return { - status: "OK", - webauthnGeneratedOptionsId: "18302759-87c6-4d88-990d-c7cab43653cc", - challenge: "dummy-signin-challenge", - timeout: 60000, - userVerification: "preferred", - }; + return Object.assign({ status: "OK", webauthnGeneratedOptionsId: id }, signInOptions); // } else if (path.getAsStringDangerous().includes("/recipe/webauthn/user/recover/token")) { // // @ts-ignore // return { diff --git a/lib/build/recipe/webauthn/emaildelivery/services/backwardCompatibility/index.d.ts b/lib/build/recipe/webauthn/emaildelivery/services/backwardCompatibility/index.d.ts new file mode 100644 index 000000000..b4ac552b5 --- /dev/null +++ b/lib/build/recipe/webauthn/emaildelivery/services/backwardCompatibility/index.d.ts @@ -0,0 +1,14 @@ +// @ts-nocheck +import { TypeWebauthnEmailDeliveryInput } from "../../../types"; +import { NormalisedAppinfo, UserContext } from "../../../../../types"; +import { EmailDeliveryInterface } from "../../../../../ingredients/emaildelivery/types"; +export default class BackwardCompatibilityService implements EmailDeliveryInterface { + private isInServerlessEnv; + private appInfo; + constructor(appInfo: NormalisedAppinfo, isInServerlessEnv: boolean); + sendEmail: ( + input: TypeWebauthnEmailDeliveryInput & { + userContext: UserContext; + } + ) => Promise; +} diff --git a/lib/build/recipe/webauthn/emaildelivery/services/backwardCompatibility/index.js b/lib/build/recipe/webauthn/emaildelivery/services/backwardCompatibility/index.js new file mode 100644 index 000000000..8c1477788 --- /dev/null +++ b/lib/build/recipe/webauthn/emaildelivery/services/backwardCompatibility/index.js @@ -0,0 +1,66 @@ +"use strict"; +Object.defineProperty(exports, "__esModule", { value: true }); +const utils_1 = require("../../../../../utils"); +async function createAndSendEmailUsingSupertokensService(input) { + if (utils_1.isTestEnv()) { + return; + } + const result = await utils_1.postWithFetch( + "https://api.supertokens.io/0/st/auth/webauthn/recover", + { + "api-version": "0", + "content-type": "application/json; charset=utf-8", + }, + { + email: input.user.email, + appName: input.appInfo.appName, + recoverAccountURL: input.recoverAccountLink, + }, + { + successLog: `Email sent to ${input.user.email}`, + errorLogHeader: "Error sending webauthn recover account email", + } + ); + if ("error" in result) { + throw result.error; + } + if (result.resp && result.resp.status >= 400) { + if (result.resp.body.err) { + /** + * if the error is thrown from API, the response object + * will be of type `{err: string}` + */ + throw new Error(result.resp.body.err); + } else { + throw new Error(`Request failed with status code ${result.resp.status}`); + } + } +} +class BackwardCompatibilityService { + constructor(appInfo, isInServerlessEnv) { + this.sendEmail = async (input) => { + // we add this here cause the user may have overridden the sendEmail function + // to change the input email and if we don't do this, the input email + // will get reset by the getUserById call above. + try { + if (!this.isInServerlessEnv) { + createAndSendEmailUsingSupertokensService({ + appInfo: this.appInfo, + user: input.user, + recoverAccountLink: input.recoverAccountLink, + }).catch((_) => {}); + } else { + // see https://github.com/supertokens/supertokens-node/pull/135 + await createAndSendEmailUsingSupertokensService({ + appInfo: this.appInfo, + user: input.user, + recoverAccountLink: input.recoverAccountLink, + }); + } + } catch (_) {} + }; + this.isInServerlessEnv = isInServerlessEnv; + this.appInfo = appInfo; + } +} +exports.default = BackwardCompatibilityService; diff --git a/lib/build/recipe/webauthn/emaildelivery/services/index.d.ts b/lib/build/recipe/webauthn/emaildelivery/services/index.d.ts new file mode 100644 index 000000000..4de04d983 --- /dev/null +++ b/lib/build/recipe/webauthn/emaildelivery/services/index.d.ts @@ -0,0 +1,3 @@ +// @ts-nocheck +import SMTP from "./smtp"; +export declare let SMTPService: typeof SMTP; diff --git a/lib/build/recipe/webauthn/emaildelivery/services/index.js b/lib/build/recipe/webauthn/emaildelivery/services/index.js new file mode 100644 index 000000000..91700aeaf --- /dev/null +++ b/lib/build/recipe/webauthn/emaildelivery/services/index.js @@ -0,0 +1,24 @@ +"use strict"; +/* 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. + */ +var __importDefault = + (this && this.__importDefault) || + function (mod) { + return mod && mod.__esModule ? mod : { default: mod }; + }; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.SMTPService = void 0; +const smtp_1 = __importDefault(require("./smtp")); +exports.SMTPService = smtp_1.default; diff --git a/lib/build/recipe/webauthn/emaildelivery/services/smtp/index.d.ts b/lib/build/recipe/webauthn/emaildelivery/services/smtp/index.d.ts new file mode 100644 index 000000000..d792d04cb --- /dev/null +++ b/lib/build/recipe/webauthn/emaildelivery/services/smtp/index.d.ts @@ -0,0 +1,14 @@ +// @ts-nocheck +import { ServiceInterface, TypeInput } from "../../../../../ingredients/emaildelivery/services/smtp"; +import { TypeWebauthnEmailDeliveryInput } from "../../../types"; +import { EmailDeliveryInterface } from "../../../../../ingredients/emaildelivery/types"; +import { UserContext } from "../../../../../types"; +export default class SMTPService implements EmailDeliveryInterface { + serviceImpl: ServiceInterface; + constructor(config: TypeInput); + sendEmail: ( + input: TypeWebauthnEmailDeliveryInput & { + userContext: UserContext; + } + ) => Promise; +} diff --git a/lib/build/recipe/webauthn/emaildelivery/services/smtp/index.js b/lib/build/recipe/webauthn/emaildelivery/services/smtp/index.js new file mode 100644 index 000000000..dedcbe33f --- /dev/null +++ b/lib/build/recipe/webauthn/emaildelivery/services/smtp/index.js @@ -0,0 +1,37 @@ +"use strict"; +var __importDefault = + (this && this.__importDefault) || + function (mod) { + return mod && mod.__esModule ? mod : { default: mod }; + }; +Object.defineProperty(exports, "__esModule", { value: true }); +const nodemailer_1 = require("nodemailer"); +const supertokens_js_override_1 = __importDefault(require("supertokens-js-override")); +const serviceImplementation_1 = require("./serviceImplementation"); +class SMTPService { + constructor(config) { + this.sendEmail = async (input) => { + let content = await this.serviceImpl.getContent(input); + await this.serviceImpl.sendRawEmail( + Object.assign(Object.assign({}, content), { userContext: input.userContext }) + ); + }; + const transporter = nodemailer_1.createTransport({ + host: config.smtpSettings.host, + port: config.smtpSettings.port, + auth: { + user: config.smtpSettings.authUsername || config.smtpSettings.from.email, + pass: config.smtpSettings.password, + }, + secure: config.smtpSettings.secure, + }); + let builder = new supertokens_js_override_1.default( + serviceImplementation_1.getServiceImplementation(transporter, config.smtpSettings.from) + ); + if (config.override !== undefined) { + builder = builder.override(config.override); + } + this.serviceImpl = builder.build(); + } +} +exports.default = SMTPService; diff --git a/lib/build/recipe/webauthn/emaildelivery/services/smtp/recoverAccount.d.ts b/lib/build/recipe/webauthn/emaildelivery/services/smtp/recoverAccount.d.ts new file mode 100644 index 000000000..3f85c452a --- /dev/null +++ b/lib/build/recipe/webauthn/emaildelivery/services/smtp/recoverAccount.d.ts @@ -0,0 +1,7 @@ +// @ts-nocheck +import { TypeWebauthnRecoverAccountEmailDeliveryInput } from "../../../types"; +import { GetContentResult } from "../../../../../ingredients/emaildelivery/services/smtp"; +export default function getRecoverAccountEmailContent( + input: TypeWebauthnRecoverAccountEmailDeliveryInput +): GetContentResult; +export declare function getRecoverAccountEmailHTML(appName: string, email: string, resetLink: string): string; diff --git a/lib/build/recipe/webauthn/emaildelivery/services/smtp/recoverAccount.js b/lib/build/recipe/webauthn/emaildelivery/services/smtp/recoverAccount.js new file mode 100644 index 000000000..39185d372 --- /dev/null +++ b/lib/build/recipe/webauthn/emaildelivery/services/smtp/recoverAccount.js @@ -0,0 +1,934 @@ +"use strict"; +var __importDefault = + (this && this.__importDefault) || + function (mod) { + return mod && mod.__esModule ? mod : { default: mod }; + }; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.getRecoverAccountEmailHTML = void 0; +const supertokens_1 = __importDefault(require("../../../../../supertokens")); +function getRecoverAccountEmailContent(input) { + let supertokens = supertokens_1.default.getInstanceOrThrowError(); + let appName = supertokens.appInfo.appName; + let body = getRecoverAccountEmailHTML(appName, input.user.email, input.recoverAccountLink); + return { + body, + toEmail: input.user.email, + subject: "Account recovery instructions", + isHtml: true, + }; +} +exports.default = getRecoverAccountEmailContent; +function getRecoverAccountEmailHTML(appName, email, resetLink) { + return ` + + + + + + + + *|MC:SUBJECT|* + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + +
+ + + + + +
+ +
+ + + + + +
+ + + + + + +
+ + +
+
+ +

+ An account recovery request for your account on + ${appName} has been received. +

+ + +
+
+

+ Alternatively, you can directly paste this link + in your browser
+ ${resetLink} +

+
+
+ + + + +
+ + + + + + +
+

+ This email is meant for ${email} +

+
+
+ +
+ + + + + +
+ +
+ +
+
+ + + + `; +} +exports.getRecoverAccountEmailHTML = getRecoverAccountEmailHTML; diff --git a/lib/build/recipe/webauthn/emaildelivery/services/smtp/serviceImplementation/index.d.ts b/lib/build/recipe/webauthn/emaildelivery/services/smtp/serviceImplementation/index.d.ts new file mode 100644 index 000000000..5eec27f1c --- /dev/null +++ b/lib/build/recipe/webauthn/emaildelivery/services/smtp/serviceImplementation/index.d.ts @@ -0,0 +1,11 @@ +// @ts-nocheck +import { TypeWebauthnEmailDeliveryInput } from "../../../../types"; +import { Transporter } from "nodemailer"; +import { ServiceInterface } from "../../../../../../ingredients/emaildelivery/services/smtp"; +export declare function getServiceImplementation( + transporter: Transporter, + from: { + name: string; + email: string; + } +): ServiceInterface; diff --git a/lib/build/recipe/webauthn/emaildelivery/services/smtp/serviceImplementation/index.js b/lib/build/recipe/webauthn/emaildelivery/services/smtp/serviceImplementation/index.js new file mode 100644 index 000000000..f097d8647 --- /dev/null +++ b/lib/build/recipe/webauthn/emaildelivery/services/smtp/serviceImplementation/index.js @@ -0,0 +1,48 @@ +"use strict"; +/* 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. + */ +var __importDefault = + (this && this.__importDefault) || + function (mod) { + return mod && mod.__esModule ? mod : { default: mod }; + }; +Object.defineProperty(exports, "__esModule", { value: true }); +exports.getServiceImplementation = void 0; +const recoverAccount_1 = __importDefault(require("../recoverAccount")); +function getServiceImplementation(transporter, from) { + return { + sendRawEmail: async function (input) { + if (input.isHtml) { + await transporter.sendMail({ + from: `${from.name} <${from.email}>`, + to: input.toEmail, + subject: input.subject, + html: input.body, + }); + } else { + await transporter.sendMail({ + from: `${from.name} <${from.email}>`, + to: input.toEmail, + subject: input.subject, + text: input.body, + }); + } + }, + getContent: async function (input) { + return recoverAccount_1.default(input); + }, + }; +} +exports.getServiceImplementation = getServiceImplementation; diff --git a/lib/build/recipe/webauthn/error.d.ts b/lib/build/recipe/webauthn/error.d.ts index d4dc2cf9b..486758b61 100644 --- a/lib/build/recipe/webauthn/error.d.ts +++ b/lib/build/recipe/webauthn/error.d.ts @@ -1,20 +1,5 @@ // @ts-nocheck import STError from "../../error"; export default class SessionError extends STError { - static FIELD_ERROR: "FIELD_ERROR"; - constructor( - options: - | { - type: "FIELD_ERROR"; - payload: { - id: string; - error: string; - }[]; - message: string; - } - | { - type: "BAD_INPUT_ERROR"; - message: string; - } - ); + constructor(options: { type: "BAD_INPUT_ERROR"; message: string }); } diff --git a/lib/build/recipe/webauthn/error.js b/lib/build/recipe/webauthn/error.js index 9cce55615..7de05e126 100644 --- a/lib/build/recipe/webauthn/error.js +++ b/lib/build/recipe/webauthn/error.js @@ -1,5 +1,5 @@ "use strict"; -/* Copyright (c) 2021, VRAI Labs and/or its affiliates. All rights reserved. +/* Copyright (c) 2024, 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. @@ -27,4 +27,3 @@ class SessionError extends error_1.default { } } exports.default = SessionError; -SessionError.FIELD_ERROR = "FIELD_ERROR"; diff --git a/lib/build/recipe/webauthn/index.d.ts b/lib/build/recipe/webauthn/index.d.ts index fc33da5b1..10ece7871 100644 --- a/lib/build/recipe/webauthn/index.d.ts +++ b/lib/build/recipe/webauthn/index.d.ts @@ -1,24 +1,63 @@ // @ts-nocheck import Recipe from "./recipe"; import SuperTokensError from "./error"; -import { RecipeInterface, APIOptions, APIInterface, TypeWebauthnEmailDeliveryInput, CredentialPayload } from "./types"; +import { + RecipeInterface, + APIInterface, + APIOptions, + TypeWebauthnEmailDeliveryInput, + CredentialPayload, + UserVerification, + ResidentKey, + Attestation, +} from "./types"; import RecipeUserId from "../../recipeUserId"; import { SessionContainerInterface } from "../session/types"; import { User } from "../../types"; +import { BaseRequest } from "../../framework"; export default class Wrapper { static init: typeof Recipe.init; static Error: typeof SuperTokensError; - static registerOptions( - email: string | undefined, - recoverAccountToken: string | undefined, - relyingPartyId: string, - relyingPartyName: string, - origin: string, - timeout: number, - attestation: "none" | "indirect" | "direct" | "enterprise" | undefined, - tenantId: string, - userContext: Record - ): Promise< + static registerOptions({ + requireResidentKey, + residentKey, + userVerification, + attestation, + supportedAlgorithmIds, + timeout, + tenantId, + userContext, + ...rest + }: { + requireResidentKey?: boolean; + residentKey?: ResidentKey; + userVerification?: UserVerification; + attestation?: Attestation; + supportedAlgorithmIds?: number[]; + timeout?: number; + tenantId?: string; + userContext?: Record; + } & ( + | { + relyingPartyId: string; + relyingPartyName: string; + origin: string; + } + | { + request: BaseRequest; + relyingPartyId?: string; + relyingPartyName?: string; + origin?: string; + } + ) & + ( + | { + email: string; + } + | { + recoverAccountToken: string; + } + )): Promise< | { status: "OK"; webauthnGeneratedOptionsId: string; @@ -45,8 +84,8 @@ export default class Wrapper { }[]; authenticatorSelection: { requireResidentKey: boolean; - residentKey: "required" | "preferred" | "discouraged"; - userVerification: "required" | "preferred" | "discouraged"; + residentKey: ResidentKey; + userVerification: UserVerification; }; } | { @@ -56,55 +95,108 @@ export default class Wrapper { status: "INVALID_EMAIL_ERROR"; err: string; } + | { + status: "INVALID_GENERATED_OPTIONS_ERROR"; + } >; - static signInOptions( - relyingPartyId: string, - origin: string, - timeout: number, - tenantId: string, - userContext: Record - ): Promise< + static signInOptions({ + email, + tenantId, + userVerification, + timeout, + userContext, + ...rest + }: { + email?: string; + timeout?: number; + userVerification?: UserVerification; + tenantId?: string; + userContext?: Record; + } & ( + | { + relyingPartyId: string; + origin: string; + } + | { + request: BaseRequest; + relyingPartyId?: string; + origin?: string; + } + )): Promise< | { status: "OK"; webauthnGeneratedOptionsId: string; challenge: string; timeout: number; - userVerification: "required" | "preferred" | "discouraged"; + userVerification: UserVerification; } | { - status: "WRONG_CREDENTIALS_ERROR"; + status: "INVALID_GENERATED_OPTIONS_ERROR"; } >; - static signIn( - tenantId: string, - webauthnGeneratedOptionsId: string, - credential: CredentialPayload, - session?: undefined, - userContext?: Record - ): Promise< + static signUp({ + tenantId, + webauthnGeneratedOptionsId, + credential, + session, + userContext, + }: { + tenantId?: string; + webauthnGeneratedOptionsId: string; + credential: CredentialPayload; + userContext?: Record; + session?: SessionContainerInterface; + }): Promise< | { status: "OK"; user: User; recipeUserId: RecipeUserId; } | { - status: "WRONG_CREDENTIALS_ERROR"; + status: "EMAIL_ALREADY_EXISTS_ERROR"; + } + | { + status: "INVALID_CREDENTIALS_ERROR"; + } + | { + status: "GENERATED_OPTIONS_NOT_FOUND_ERROR"; + } + | { + status: "INVALID_GENERATED_OPTIONS_ERROR"; + } + | { + status: "INVALID_AUTHENTICATOR_ERROR"; + reason: string; + } + | { + 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< + static signIn({ + tenantId, + webauthnGeneratedOptionsId, + credential, + session, + userContext, + }: { + tenantId?: string; + webauthnGeneratedOptionsId: string; + credential: CredentialPayload; + session?: SessionContainerInterface; + userContext?: Record; + }): Promise< | { status: "OK"; user: User; recipeUserId: RecipeUserId; } | { - status: "WRONG_CREDENTIALS_ERROR"; + status: "INVALID_CREDENTIALS_ERROR"; } | { status: "LINKING_TO_SESSION_USER_FAILED"; @@ -115,17 +207,22 @@ export default class Wrapper { | "SESSION_USER_ACCOUNT_INFO_ALREADY_ASSOCIATED_WITH_ANOTHER_PRIMARY_USER_ID_ERROR"; } >; - static verifyCredentials( - tenantId: string, - webauthnGeneratedOptionsId: string, - credential: CredentialPayload, - userContext?: Record - ): Promise< + static verifyCredentials({ + tenantId, + webauthnGeneratedOptionsId, + credential, + userContext, + }: { + tenantId?: string; + webauthnGeneratedOptionsId: string; + credential: CredentialPayload; + userContext?: Record; + }): Promise< | { status: "OK"; } | { - status: "WRONG_CREDENTIALS_ERROR"; + status: "INVALID_CREDENTIALS_ERROR"; } >; /** @@ -139,12 +236,17 @@ export default class Wrapper { * * And we want to allow primaryUserId being passed in. */ - static generateRecoverAccountToken( - tenantId: string, - userId: string, - email: string, - userContext?: Record - ): Promise< + static generateRecoverAccountToken({ + tenantId, + userId, + email, + userContext, + }: { + tenantId?: string; + userId: string; + email: string; + userContext?: Record; + }): Promise< | { status: "OK"; token: string; @@ -153,26 +255,48 @@ export default class Wrapper { status: "UNKNOWN_USER_ID_ERROR"; } >; - static recoverAccount( - tenantId: string, - webauthnGeneratedOptionsId: string, - token: string, - credential: CredentialPayload, - userContext?: Record - ): Promise< + static recoverAccount({ + tenantId, + webauthnGeneratedOptionsId, + token, + credential, + userContext, + }: { + tenantId?: string; + webauthnGeneratedOptionsId: string; + token: string; + credential: CredentialPayload; + userContext?: Record; + }): Promise< + | { + status: "OK"; + } | { - status: "OK" | "WRONG_CREDENTIALS_ERROR" | "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR"; + status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR"; + } + | { + status: "INVALID_CREDENTIALS_ERROR"; + } + | { + status: "GENERATED_OPTIONS_NOT_FOUND_ERROR"; + } + | { + status: "INVALID_GENERATED_OPTIONS_ERROR"; } | { status: "INVALID_AUTHENTICATOR_ERROR"; failureReason: string; } >; - static consumeRecoverAccountToken( - tenantId: string, - token: string, - userContext?: Record - ): Promise< + static consumeRecoverAccountToken({ + tenantId, + token, + userContext, + }: { + tenantId?: string; + token: string; + userContext?: Record; + }): Promise< | { status: "OK"; email: string; @@ -182,30 +306,45 @@ export default class Wrapper { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR"; } >; - static registerCredential(input: { + static registerCredential({ + recipeUserId, + webauthnGeneratedOptionsId, + credential, + userContext, + }: { recipeUserId: RecipeUserId; - tenantId: string; webauthnGeneratedOptionsId: string; credential: CredentialPayload; userContext?: Record; }): Promise< | { - status: "OK" | "WRONG_CREDENTIALS_ERROR"; + status: "OK"; + } + | { + status: "INVALID_CREDENTIALS_ERROR"; + } + | { + status: "GENERATED_OPTIONS_NOT_FOUND_ERROR"; } | { - status: "WRONG_CREDENTIALS_ERROR"; + status: "INVALID_GENERATED_OPTIONS_ERROR"; } | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string; } >; - static createRecoverAccountLink( - tenantId: string, - userId: string, - email: string, - userContext?: Record - ): Promise< + static createRecoverAccountLink({ + tenantId, + userId, + email, + userContext, + }: { + tenantId?: string; + userId: string; + email: string; + userContext?: Record; + }): Promise< | { status: "OK"; link: string; @@ -214,12 +353,17 @@ export default class Wrapper { status: "UNKNOWN_USER_ID_ERROR"; } >; - static sendRecoverAccountEmail( - tenantId: string, - userId: string, - email: string, - userContext?: Record - ): Promise<{ + static sendRecoverAccountEmail({ + tenantId, + userId, + email, + userContext, + }: { + tenantId?: string; + userId: string; + email: string; + userContext?: Record; + }): Promise<{ status: "OK" | "UNKNOWN_USER_ID_ERROR"; }>; static sendEmail( diff --git a/lib/build/recipe/webauthn/index.js b/lib/build/recipe/webauthn/index.js index 041a297b2..35d01c9e8 100644 --- a/lib/build/recipe/webauthn/index.js +++ b/lib/build/recipe/webauthn/index.js @@ -1,5 +1,5 @@ "use strict"; -/* Copyright (c) 2021, VRAI Labs and/or its affiliates. All rights reserved. +/* Copyright (c) 2024, 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. @@ -13,6 +13,17 @@ * License for the specific language governing permissions and limitations * under the License. */ +var __rest = + (this && this.__rest) || + function (s, e) { + var t = {}; + for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) t[p] = s[p]; + if (s != null && typeof Object.getOwnPropertySymbols === "function") + for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) { + if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) t[p[i]] = s[p[i]]; + } + return t; + }; var __importDefault = (this && this.__importDefault) || function (mod) { @@ -29,69 +40,182 @@ const __1 = require("../.."); const utils_2 = require("../../utils"); const constants_2 = require("./constants"); class Wrapper { - static async registerOptions( - email, - recoverAccountToken, - relyingPartyId, - relyingPartyName, - origin, - timeout, - attestation = "none", - tenantId, - userContext - ) { - let payload = email ? { email } : recoverAccountToken ? { recoverAccountToken } : null; - if (!payload) { + static async registerOptions(_a) { + var { + requireResidentKey = constants_2.DEFAULT_REGISTER_OPTIONS_REQUIRE_RESIDENT_KEY, + residentKey = constants_2.DEFAULT_REGISTER_OPTIONS_RESIDENT_KEY, + userVerification = constants_2.DEFAULT_REGISTER_OPTIONS_USER_VERIFICATION, + attestation = constants_2.DEFAULT_REGISTER_OPTIONS_ATTESTATION, + supportedAlgorithmIds = constants_2.DEFAULT_REGISTER_OPTIONS_SUPPORTED_ALGORITHM_IDS, + timeout = constants_2.DEFAULT_REGISTER_OPTIONS_TIMEOUT, + tenantId = constants_1.DEFAULT_TENANT_ID, + userContext, + } = _a, + rest = __rest(_a, [ + "requireResidentKey", + "residentKey", + "userVerification", + "attestation", + "supportedAlgorithmIds", + "timeout", + "tenantId", + "userContext", + ]); + let emailOrRecoverAccountToken; + if ("email" in rest || "recoverAccountToken" in rest) { + if ("email" in rest) { + emailOrRecoverAccountToken = { email: rest.email }; + } else { + emailOrRecoverAccountToken = { recoverAccountToken: rest.recoverAccountToken }; + } + } else { return { status: "INVALID_EMAIL_ERROR", err: "Email is missing" }; } - return recipe_1.default.getInstanceOrThrowError().recipeInterfaceImpl.registerOptions( - Object.assign( - Object.assign( - { - requireResidentKey: constants_2.DEFAULT_REGISTER_OPTIONS_REQUIRE_RESIDENT_KEY, - residentKey: constants_2.DEFAULT_REGISTER_OPTIONS_RESIDENT_KEY, - userVerification: constants_2.DEFAULT_REGISTER_OPTIONS_USER_VERIFICATION, - supportedAlgorithmIds: constants_2.DEFAULT_REGISTER_OPTIONS_SUPPORTED_ALGORITHM_IDS, - }, - payload - ), - { - relyingPartyId, - relyingPartyName, - origin, - timeout, - attestation, - tenantId: tenantId === undefined ? constants_1.DEFAULT_TENANT_ID : tenantId, + let relyingPartyId; + let relyingPartyName; + let origin; + if ("request" in rest) { + origin = + rest.origin || + (await recipe_1.default.getInstanceOrThrowError().config.getOrigin({ + request: rest.request, + tenantId: tenantId, + userContext: utils_2.getUserContext(userContext), + })); + relyingPartyId = + rest.relyingPartyId || + (await recipe_1.default.getInstanceOrThrowError().config.getRelyingPartyId({ + request: rest.request, + tenantId: tenantId, + userContext: utils_2.getUserContext(userContext), + })); + relyingPartyName = + rest.relyingPartyName || + (await recipe_1.default.getInstanceOrThrowError().config.getRelyingPartyName({ + tenantId: tenantId, userContext: utils_2.getUserContext(userContext), - } - ) + })); + } else { + if (!rest.origin) { + throw new exports.Error({ type: "BAD_INPUT_ERROR", message: "Origin missing from the input" }); + } + if (!rest.relyingPartyId) { + throw new exports.Error({ type: "BAD_INPUT_ERROR", message: "RelyingPartyId missing from the input" }); + } + if (!rest.relyingPartyName) { + throw new exports.Error({ + type: "BAD_INPUT_ERROR", + message: "RelyingPartyName missing from the input", + }); + } + origin = rest.origin; + relyingPartyId = rest.relyingPartyId; + relyingPartyName = rest.relyingPartyName; + } + return recipe_1.default.getInstanceOrThrowError().recipeInterfaceImpl.registerOptions( + Object.assign(Object.assign({}, emailOrRecoverAccountToken), { + requireResidentKey, + residentKey, + userVerification, + supportedAlgorithmIds, + relyingPartyId, + relyingPartyName, + origin, + timeout, + attestation, + tenantId, + userContext: utils_2.getUserContext(userContext), + }) ); } - static signInOptions(relyingPartyId, origin, timeout, tenantId, userContext) { - return recipe_1.default.getInstanceOrThrowError().recipeInterfaceImpl.signInOptions({ - userVerification: constants_2.DEFAULT_SIGNIN_OPTIONS_USER_VERIFICATION, + static async signInOptions(_a) { + var { + email, + tenantId = constants_1.DEFAULT_TENANT_ID, + userVerification = constants_2.DEFAULT_SIGNIN_OPTIONS_USER_VERIFICATION, + timeout = constants_2.DEFAULT_SIGNIN_OPTIONS_TIMEOUT, + userContext, + } = _a, + rest = __rest(_a, ["email", "tenantId", "userVerification", "timeout", "userContext"]); + let origin; + let relyingPartyId; + if ("request" in rest) { + relyingPartyId = + rest.relyingPartyId || + (await recipe_1.default.getInstanceOrThrowError().config.getRelyingPartyId({ + request: rest.request, + tenantId: tenantId, + userContext: utils_2.getUserContext(userContext), + })); + origin = + rest.origin || + (await recipe_1.default.getInstanceOrThrowError().config.getOrigin({ + request: rest.request, + tenantId: tenantId, + userContext: utils_2.getUserContext(userContext), + })); + } else { + if (!rest.relyingPartyId) { + throw new exports.Error({ type: "BAD_INPUT_ERROR", message: "RelyingPartyId missing from the input" }); + } + if (!rest.origin) { + throw new exports.Error({ type: "BAD_INPUT_ERROR", message: "Origin missing from the input" }); + } + relyingPartyId = rest.relyingPartyId; + origin = rest.origin; + } + return await recipe_1.default.getInstanceOrThrowError().recipeInterfaceImpl.signInOptions({ + email, relyingPartyId, origin, timeout, - tenantId: tenantId === undefined ? constants_1.DEFAULT_TENANT_ID : tenantId, + tenantId, + userVerification, userContext: utils_2.getUserContext(userContext), }); } - static signIn(tenantId, webauthnGeneratedOptionsId, credential, session, userContext) { + static signUp({ + tenantId = constants_1.DEFAULT_TENANT_ID, + webauthnGeneratedOptionsId, + credential, + session, + userContext, + }) { + return recipe_1.default.getInstanceOrThrowError().recipeInterfaceImpl.signUp({ + webauthnGeneratedOptionsId, + credential, + session, + shouldTryLinkingWithSessionUser: !!session, + tenantId, + userContext: utils_2.getUserContext(userContext), + }); + } + static signIn({ + tenantId = constants_1.DEFAULT_TENANT_ID, + webauthnGeneratedOptionsId, + credential, + session, + userContext, + }) { return recipe_1.default.getInstanceOrThrowError().recipeInterfaceImpl.signIn({ webauthnGeneratedOptionsId, credential, session, shouldTryLinkingWithSessionUser: !!session, - tenantId: tenantId === undefined ? constants_1.DEFAULT_TENANT_ID : tenantId, + tenantId, userContext: utils_2.getUserContext(userContext), }); } - static async verifyCredentials(tenantId, webauthnGeneratedOptionsId, credential, userContext) { + static async verifyCredentials({ + tenantId = constants_1.DEFAULT_TENANT_ID, + webauthnGeneratedOptionsId, + credential, + userContext, + }) { const resp = await recipe_1.default.getInstanceOrThrowError().recipeInterfaceImpl.verifyCredentials({ webauthnGeneratedOptionsId, credential, - tenantId: tenantId === undefined ? constants_1.DEFAULT_TENANT_ID : tenantId, + tenantId, userContext: utils_2.getUserContext(userContext), }); // Here we intentionally skip the user and recipeUserId props, because we do not want apps to accidentally use this to sign in @@ -110,16 +234,22 @@ class Wrapper { * * And we want to allow primaryUserId being passed in. */ - static generateRecoverAccountToken(tenantId, userId, email, userContext) { + static generateRecoverAccountToken({ tenantId = constants_1.DEFAULT_TENANT_ID, userId, email, userContext }) { return recipe_1.default.getInstanceOrThrowError().recipeInterfaceImpl.generateRecoverAccountToken({ userId, email, - tenantId: tenantId === undefined ? constants_1.DEFAULT_TENANT_ID : tenantId, + tenantId, userContext: utils_2.getUserContext(userContext), }); } - static async recoverAccount(tenantId, webauthnGeneratedOptionsId, token, credential, userContext) { - const consumeResp = await Wrapper.consumeRecoverAccountToken(tenantId, token, userContext); + static async recoverAccount({ + tenantId = constants_1.DEFAULT_TENANT_ID, + webauthnGeneratedOptionsId, + token, + credential, + userContext, + }) { + const consumeResp = await Wrapper.consumeRecoverAccountToken({ tenantId, token, userContext }); if (consumeResp.status !== "OK") { return consumeResp; } @@ -127,7 +257,6 @@ class Wrapper { recipeUserId: new recipeUserId_1.default(consumeResp.userId), webauthnGeneratedOptionsId, credential, - tenantId, userContext, }); if (result.status === "INVALID_AUTHENTICATOR_ERROR") { @@ -140,39 +269,40 @@ class Wrapper { status: result.status, }; } - static consumeRecoverAccountToken(tenantId, token, userContext) { + static consumeRecoverAccountToken({ tenantId = constants_1.DEFAULT_TENANT_ID, token, userContext }) { return recipe_1.default.getInstanceOrThrowError().recipeInterfaceImpl.consumeRecoverAccountToken({ token, - tenantId: tenantId === undefined ? constants_1.DEFAULT_TENANT_ID : tenantId, + tenantId, userContext: utils_2.getUserContext(userContext), }); } - static registerCredential(input) { - return recipe_1.default - .getInstanceOrThrowError() - .recipeInterfaceImpl.registerCredential( - Object.assign(Object.assign({}, input), { userContext: utils_2.getUserContext(input.userContext) }) - ); + static registerCredential({ recipeUserId, webauthnGeneratedOptionsId, credential, userContext }) { + return recipe_1.default.getInstanceOrThrowError().recipeInterfaceImpl.registerCredential({ + recipeUserId, + webauthnGeneratedOptionsId, + credential, + userContext: utils_2.getUserContext(userContext), + }); } - static async createRecoverAccountLink(tenantId, userId, email, userContext) { - const ctx = utils_2.getUserContext(userContext); - let token = await this.generateRecoverAccountToken(tenantId, userId, email, ctx); + static async createRecoverAccountLink({ tenantId = constants_1.DEFAULT_TENANT_ID, userId, email, userContext }) { + let token = await this.generateRecoverAccountToken({ tenantId, userId, email, userContext }); if (token.status === "UNKNOWN_USER_ID_ERROR") { return token; } + const ctx = utils_2.getUserContext(userContext); const recipeInstance = recipe_1.default.getInstanceOrThrowError(); return { status: "OK", link: utils_1.getRecoverAccountLink({ appInfo: recipeInstance.getAppInfo(), token: token.token, - tenantId: tenantId === undefined ? constants_1.DEFAULT_TENANT_ID : tenantId, + tenantId, request: __1.getRequestFromUserContext(ctx), userContext: ctx, }), }; } - static async sendRecoverAccountEmail(tenantId, userId, email, userContext) { + static async sendRecoverAccountEmail({ tenantId = constants_1.DEFAULT_TENANT_ID, userId, email, userContext }) { const user = await __1.getUser(userId, userContext); if (!user) { return { status: "UNKNOWN_USER_ID_ERROR" }; @@ -181,7 +311,7 @@ class Wrapper { if (!loginMethod) { return { status: "UNKNOWN_USER_ID_ERROR" }; } - let link = await this.createRecoverAccountLink(tenantId, userId, email, userContext); + let link = await this.createRecoverAccountLink({ tenantId, userId, email, userContext }); if (link.status === "UNKNOWN_USER_ID_ERROR") { return link; } @@ -204,7 +334,7 @@ class Wrapper { let recipeInstance = recipe_1.default.getInstanceOrThrowError(); return await recipeInstance.emailDelivery.ingredientInterfaceImpl.sendEmail( Object.assign(Object.assign({}, input), { - tenantId: input.tenantId === undefined ? constants_1.DEFAULT_TENANT_ID : input.tenantId, + tenantId: input.tenantId || constants_1.DEFAULT_TENANT_ID, userContext: utils_2.getUserContext(input.userContext), }) ); diff --git a/lib/build/recipe/webauthn/recipe.d.ts b/lib/build/recipe/webauthn/recipe.d.ts index 12c1bf86c..1eecfffbe 100644 --- a/lib/build/recipe/webauthn/recipe.d.ts +++ b/lib/build/recipe/webauthn/recipe.d.ts @@ -37,7 +37,7 @@ export default class Recipe extends RecipeModule { _method: HTTPMethod, userContext: UserContext ) => Promise; - handleError: (err: STError, _request: BaseRequest, response: BaseResponse) => Promise; + handleError: (err: STError, _request: BaseRequest, _response: BaseResponse) => Promise; getAllCORSHeaders: () => string[]; isErrorFromThisRecipe: (err: any) => err is STError; } diff --git a/lib/build/recipe/webauthn/recipe.js b/lib/build/recipe/webauthn/recipe.js index f3a82ef7c..6d76efb0d 100644 --- a/lib/build/recipe/webauthn/recipe.js +++ b/lib/build/recipe/webauthn/recipe.js @@ -1,5 +1,5 @@ "use strict"; -/* Copyright (c) 2021, VRAI Labs and/or its affiliates. All rights reserved. +/* Copyright (c) 2024, 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. @@ -121,16 +121,9 @@ class Recipe extends recipeModule_1.default { return await emailExists_1.default(this.apiImpl, tenantId, options, userContext); } else return false; }; - this.handleError = async (err, _request, response) => { + this.handleError = async (err, _request, _response) => { if (err.fromRecipe === Recipe.RECIPE_ID) { - if (err.type === error_1.default.FIELD_ERROR) { - return utils_2.send200Response(response, { - status: "FIELD_ERROR", - formFields: err.payload, - }); - } else { - throw err; - } + throw err; } else { throw err; } @@ -164,7 +157,6 @@ class Recipe extends recipeModule_1.default { ingredients.emailDelivery === undefined ? new emaildelivery_1.default(this.config.getEmailDeliveryConfig(this.isInServerlessEnv)) : ingredients.emailDelivery; - // todo check correctness postSuperTokensInitCallbacks_1.PostSuperTokensInitCallbacks.addPostInitCallback(() => { const mfaInstance = recipe_1.default.getInstance(); if (mfaInstance !== undefined) { diff --git a/lib/build/recipe/webauthn/recipeImplementation.js b/lib/build/recipe/webauthn/recipeImplementation.js index f664098c1..dc345bee9 100644 --- a/lib/build/recipe/webauthn/recipeImplementation.js +++ b/lib/build/recipe/webauthn/recipeImplementation.js @@ -60,6 +60,7 @@ const constants_1 = require("../multitenancy/constants"); const user_1 = require("../../user"); const authUtils_1 = require("../../authUtils"); const jose = __importStar(require("jose")); +const utils_1 = require("../thirdparty/utils"); function getRecipeInterface(querier, getWebauthnConfig) { return { registerOptions: async function (_a) { @@ -89,8 +90,7 @@ function getRecipeInterface(querier, getWebauthnConfig) { if (emailInput !== undefined) { email = emailInput; } else if (recoverAccountTokenInput !== undefined) { - // 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 of the token will be done during consumeRecoverAccountToken + // the actual validation of the token will be done during consumeRecoverAccountToken let decoded; try { decoded = await jose.decodeJwt(recoverAccountTokenInput); @@ -115,6 +115,18 @@ function getRecipeInterface(querier, getWebauthnConfig) { err, }; } + // set a nice default display name + // if the user has a fake email, we use the username part of the email instead (which should be the recipe user id) + let displayName; + if (rest.displayName) { + displayName = rest.displayName; + } else { + if (utils_1.isFakeEmail(email)) { + displayName = email.split("@")[0]; + } else { + displayName = email; + } + } return await querier.sendPostRequest( new normalisedURLPath_1.default( `/${ @@ -123,6 +135,7 @@ function getRecipeInterface(querier, getWebauthnConfig) { ), { email, + displayName, relyingPartyName, relyingPartyId, origin, @@ -261,7 +274,7 @@ function getRecipeInterface(querier, getWebauthnConfig) { }; } return { - status: "WRONG_CREDENTIALS_ERROR", + status: "INVALID_CREDENTIALS_ERROR", }; }, createNewRecipeUser: async function (input) { @@ -336,7 +349,7 @@ function getRecipeInterface(querier, getWebauthnConfig) { return response; } return { - status: "WRONG_CREDENTIALS_ERROR", + status: "INVALID_CREDENTIALS_ERROR", }; }, getUserFromRecoverAccountToken: async function ({ token, tenantId, userContext }) { diff --git a/lib/build/recipe/webauthn/types.d.ts b/lib/build/recipe/webauthn/types.d.ts index ff545f52b..d58b6c2cb 100644 --- a/lib/build/recipe/webauthn/types.d.ts +++ b/lib/build/recipe/webauthn/types.d.ts @@ -10,8 +10,8 @@ import EmailDeliveryIngredient from "../../ingredients/emaildelivery"; import { GeneralErrorResponse, NormalisedAppinfo, User, UserContext } from "../../types"; import RecipeUserId from "../../recipeUserId"; export declare type TypeNormalisedInput = { - relyingPartyId: TypeNormalisedInputRelyingPartyId; - relyingPartyName: TypeNormalisedInputRelyingPartyName; + getRelyingPartyId: TypeNormalisedInputRelyingPartyId; + getRelyingPartyName: TypeNormalisedInputRelyingPartyName; getOrigin: TypeNormalisedInputGetOrigin; getEmailDeliveryConfig: ( isInServerlessEnv: boolean @@ -45,8 +45,8 @@ export declare type TypeNormalisedInputValidateEmailAddress = ( ) => Promise | string | undefined; export declare type TypeInput = { emailDelivery?: EmailDeliveryTypeInput; - relyingPartyId?: TypeInputRelyingPartyId; - relyingPartyName?: TypeInputRelyingPartyName; + getRelyingPartyId?: TypeInputRelyingPartyId; + getRelyingPartyName?: TypeInputRelyingPartyName; validateEmailAddress?: TypeInputValidateEmailAddress; getOrigin?: TypeInputGetOrigin; override?: { @@ -73,16 +73,20 @@ export declare type TypeInputValidateEmailAddress = ( tenantId: string ) => Promise | string | undefined; declare type Base64URLString = string; +export declare type ResidentKey = "required" | "preferred" | "discouraged"; +export declare type UserVerification = "required" | "preferred" | "discouraged"; +export declare type Attestation = "none" | "indirect" | "direct" | "enterprise"; export declare type RecipeInterface = { registerOptions( input: { relyingPartyId: string; relyingPartyName: string; + displayName?: string; origin: string; requireResidentKey: boolean | undefined; - residentKey: "required" | "preferred" | "discouraged" | undefined; - userVerification: "required" | "preferred" | "discouraged" | undefined; - attestation: "none" | "indirect" | "direct" | "enterprise" | undefined; + residentKey: ResidentKey | undefined; + userVerification: UserVerification | undefined; + attestation: Attestation | undefined; supportedAlgorithmIds: number[] | undefined; timeout: number | undefined; tenantId: string; @@ -115,15 +119,15 @@ export declare type RecipeInterface = { type: "public-key"; transports: ("ble" | "hybrid" | "internal" | "nfc" | "usb")[]; }[]; - attestation: "none" | "indirect" | "direct" | "enterprise"; + attestation: Attestation; pubKeyCredParams: { alg: number; type: "public-key"; }[]; authenticatorSelection: { requireResidentKey: boolean; - residentKey: "required" | "preferred" | "discouraged"; - userVerification: "required" | "preferred" | "discouraged"; + residentKey: ResidentKey; + userVerification: UserVerification; }; } | { @@ -133,12 +137,15 @@ export declare type RecipeInterface = { status: "INVALID_EMAIL_ERROR"; err: string; } + | { + status: "INVALID_GENERATED_OPTIONS_ERROR"; + } >; signInOptions(input: { email?: string; relyingPartyId: string; origin: string; - userVerification: "required" | "preferred" | "discouraged" | undefined; + userVerification: UserVerification | undefined; timeout: number | undefined; tenantId: string; userContext: UserContext; @@ -148,10 +155,10 @@ export declare type RecipeInterface = { webauthnGeneratedOptionsId: string; challenge: string; timeout: number; - userVerification: "required" | "preferred" | "discouraged"; + userVerification: UserVerification; } | { - status: "WRONG_CREDENTIALS_ERROR"; + status: "INVALID_GENERATED_OPTIONS_ERROR"; } >; signUp(input: { @@ -171,7 +178,13 @@ export declare type RecipeInterface = { status: "EMAIL_ALREADY_EXISTS_ERROR"; } | { - status: "WRONG_CREDENTIALS_ERROR"; + status: "INVALID_CREDENTIALS_ERROR"; + } + | { + status: "GENERATED_OPTIONS_NOT_FOUND_ERROR"; + } + | { + status: "INVALID_GENERATED_OPTIONS_ERROR"; } | { status: "INVALID_AUTHENTICATOR_ERROR"; @@ -200,7 +213,7 @@ export declare type RecipeInterface = { recipeUserId: RecipeUserId; } | { - status: "WRONG_CREDENTIALS_ERROR"; + status: "INVALID_CREDENTIALS_ERROR"; } | { status: "LINKING_TO_SESSION_USER_FAILED"; @@ -223,7 +236,7 @@ export declare type RecipeInterface = { recipeUserId: RecipeUserId; } | { - status: "WRONG_CREDENTIALS_ERROR"; + status: "INVALID_CREDENTIALS_ERROR"; } >; createNewRecipeUser(input: { @@ -238,7 +251,13 @@ export declare type RecipeInterface = { recipeUserId: RecipeUserId; } | { - status: "WRONG_CREDENTIALS_ERROR"; + status: "INVALID_CREDENTIALS_ERROR"; + } + | { + status: "GENERATED_OPTIONS_NOT_FOUND_ERROR"; + } + | { + status: "INVALID_GENERATED_OPTIONS_ERROR"; } | { status: "INVALID_AUTHENTICATOR_ERROR"; @@ -291,7 +310,13 @@ export declare type RecipeInterface = { status: "OK"; } | { - status: "WRONG_CREDENTIALS_ERROR"; + status: "INVALID_CREDENTIALS_ERROR"; + } + | { + status: "GENERATED_OPTIONS_NOT_FOUND_ERROR"; + } + | { + status: "INVALID_GENERATED_OPTIONS_ERROR"; } | { status: "INVALID_AUTHENTICATOR_ERROR"; @@ -357,7 +382,7 @@ export declare type RecipeInterface = { }; } | { - status: "WRONG_CREDENTIALS_ERROR"; + status: "INVALID_CREDENTIALS_ERROR"; } >; getUserFromRecoverAccountToken(input: { @@ -497,8 +522,8 @@ export declare type APIInterface = { }[]; authenticatorSelection: { requireResidentKey: boolean; - residentKey: "required" | "preferred" | "discouraged"; - userVerification: "required" | "preferred" | "discouraged"; + residentKey: ResidentKey; + userVerification: UserVerification; }; } | GeneralErrorResponse @@ -509,6 +534,9 @@ export declare type APIInterface = { status: "INVALID_EMAIL_ERROR"; err: string; } + | { + status: "INVALID_GENERATED_OPTIONS_ERROR"; + } >); signInOptionsPOST: | undefined @@ -523,11 +551,11 @@ export declare type APIInterface = { webauthnGeneratedOptionsId: string; challenge: string; timeout: number; - userVerification: "required" | "preferred" | "discouraged"; + userVerification: UserVerification; } | GeneralErrorResponse | { - status: "WRONG_CREDENTIALS_ERROR"; + status: "INVALID_GENERATED_OPTIONS_ERROR"; } >); signUpPOST: @@ -552,15 +580,21 @@ export declare type APIInterface = { reason: string; } | { - status: "EMAIL_ALREADY_EXISTS_ERROR"; + status: "INVALID_CREDENTIALS_ERROR"; + } + | { + status: "GENERATED_OPTIONS_NOT_FOUND_ERROR"; } | { - status: "WRONG_CREDENTIALS_ERROR"; + status: "INVALID_GENERATED_OPTIONS_ERROR"; } | { status: "INVALID_AUTHENTICATOR_ERROR"; reason: string; } + | { + status: "EMAIL_ALREADY_EXISTS_ERROR"; + } >); signInPOST: | undefined @@ -584,7 +618,7 @@ export declare type APIInterface = { reason: string; } | { - status: "WRONG_CREDENTIALS_ERROR"; + status: "INVALID_CREDENTIALS_ERROR"; } >); generateRecoverAccountTokenPOST: @@ -624,7 +658,13 @@ export declare type APIInterface = { status: "RECOVER_ACCOUNT_TOKEN_INVALID_ERROR"; } | { - status: "WRONG_CREDENTIALS_ERROR"; + status: "INVALID_CREDENTIALS_ERROR"; + } + | { + status: "GENERATED_OPTIONS_NOT_FOUND_ERROR"; + } + | { + status: "INVALID_GENERATED_OPTIONS_ERROR"; } | { status: "INVALID_AUTHENTICATOR_ERROR"; diff --git a/lib/build/recipe/webauthn/types.js b/lib/build/recipe/webauthn/types.js index a098ca1d7..9f1237319 100644 --- a/lib/build/recipe/webauthn/types.js +++ b/lib/build/recipe/webauthn/types.js @@ -1,5 +1,5 @@ "use strict"; -/* Copyright (c) 2021, VRAI Labs and/or its affiliates. All rights reserved. +/* Copyright (c) 2024, 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. diff --git a/lib/build/recipe/webauthn/utils.d.ts b/lib/build/recipe/webauthn/utils.d.ts index e4492d9f0..b37ca89ff 100644 --- a/lib/build/recipe/webauthn/utils.d.ts +++ b/lib/build/recipe/webauthn/utils.d.ts @@ -4,7 +4,7 @@ import { TypeInput, TypeNormalisedInput } from "./types"; import { NormalisedAppinfo, UserContext } from "../../types"; import { BaseRequest } from "../../framework"; export declare function validateAndNormaliseUserInput( - recipeInstance: Recipe, + _: Recipe, appInfo: NormalisedAppinfo, config?: TypeInput ): TypeNormalisedInput; diff --git a/lib/build/recipe/webauthn/utils.js b/lib/build/recipe/webauthn/utils.js index 191551342..a6ef86349 100644 --- a/lib/build/recipe/webauthn/utils.js +++ b/lib/build/recipe/webauthn/utils.js @@ -1,5 +1,5 @@ "use strict"; -/* Copyright (c) 2021, VRAI Labs and/or its affiliates. All rights reserved. +/* Copyright (c) 2024, 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. @@ -13,27 +13,28 @@ * License for the specific language governing permissions and limitations * under the License. */ +var __importDefault = + (this && this.__importDefault) || + function (mod) { + return mod && mod.__esModule ? mod : { default: mod }; + }; Object.defineProperty(exports, "__esModule", { value: true }); exports.getRecoverAccountLink = exports.defaultEmailValidator = exports.validateAndNormaliseUserInput = void 0; -function validateAndNormaliseUserInput(recipeInstance, appInfo, config) { - let relyingPartyId = validateAndNormaliseRelyingPartyIdConfig( - recipeInstance, +const backwardCompatibility_1 = __importDefault(require("./emaildelivery/services/backwardCompatibility")); +function validateAndNormaliseUserInput(_, appInfo, config) { + let getRelyingPartyId = validateAndNormaliseRelyingPartyIdConfig( appInfo, - config === null || config === void 0 ? void 0 : config.relyingPartyId + config === null || config === void 0 ? void 0 : config.getRelyingPartyId ); - let relyingPartyName = validateAndNormaliseRelyingPartyNameConfig( - recipeInstance, + let getRelyingPartyName = validateAndNormaliseRelyingPartyNameConfig( appInfo, - config === null || config === void 0 ? void 0 : config.relyingPartyName + config === null || config === void 0 ? void 0 : config.getRelyingPartyName ); let getOrigin = validateAndNormaliseGetOriginConfig( - recipeInstance, appInfo, config === null || config === void 0 ? void 0 : config.getOrigin ); let validateEmailAddress = validateAndNormaliseValidateEmailAddressConfig( - recipeInstance, - appInfo, config === null || config === void 0 ? void 0 : config.validateEmailAddress ); let override = Object.assign( @@ -49,15 +50,13 @@ function validateAndNormaliseUserInput(recipeInstance, appInfo, config) { (_a = config === null || config === void 0 ? void 0 : config.emailDelivery) === null || _a === void 0 ? void 0 : _a.service; - console.log("emailService", emailService); - console.log("isInServerlessEnv", isInServerlessEnv); /** * If the user has not passed even that config, we use the default * createAndSendCustomEmail implementation which calls our supertokens API */ - // if (emailService === undefined) { - // emailService = new BackwardCompatibilityService(appInfo, isInServerlessEnv); - // } + if (emailService === undefined) { + emailService = new backwardCompatibility_1.default(appInfo, isInServerlessEnv); + } return Object.assign(Object.assign({}, config === null || config === void 0 ? void 0 : config.emailDelivery), { /** * if we do @@ -71,55 +70,61 @@ function validateAndNormaliseUserInput(recipeInstance, appInfo, config) { * set service at the end */ // todo implemenet this - service: null, + service: emailService, }); } return { override, getOrigin, - relyingPartyId, - relyingPartyName, + getRelyingPartyId, + getRelyingPartyName, validateEmailAddress, getEmailDeliveryConfig, }; } exports.validateAndNormaliseUserInput = validateAndNormaliseUserInput; -function validateAndNormaliseRelyingPartyIdConfig(_, __, relyingPartyIdConfig) { +function validateAndNormaliseRelyingPartyIdConfig(normalisedAppinfo, relyingPartyIdConfig) { return (props) => { if (typeof relyingPartyIdConfig === "string") { return Promise.resolve(relyingPartyIdConfig); } else if (typeof relyingPartyIdConfig === "function") { return relyingPartyIdConfig(props); } else { - return Promise.resolve( - __.getOrigin({ request: props.request, userContext: props.userContext }).getAsStringDangerous() - ); + const urlString = normalisedAppinfo + .getOrigin({ request: props.request, userContext: props.userContext }) + .getAsStringDangerous(); + // should let this throw if the url is invalid + const url = new URL(urlString); + const hostname = url.hostname; + return Promise.resolve(hostname); } }; } -function validateAndNormaliseRelyingPartyNameConfig(_, __, relyingPartyNameConfig) { +function validateAndNormaliseRelyingPartyNameConfig(normalisedAppInfo, relyingPartyNameConfig) { return (props) => { if (typeof relyingPartyNameConfig === "string") { return Promise.resolve(relyingPartyNameConfig); } else if (typeof relyingPartyNameConfig === "function") { return relyingPartyNameConfig(props); } else { - return Promise.resolve(__.appName); + return Promise.resolve(normalisedAppInfo.appName); } }; } -function validateAndNormaliseGetOriginConfig(_, __, getOriginConfig) { +function validateAndNormaliseGetOriginConfig(normalisedAppinfo, getOriginConfig) { return (props) => { if (typeof getOriginConfig === "function") { return getOriginConfig(props); } else { return Promise.resolve( - __.getOrigin({ request: props.request, userContext: props.userContext }).getAsStringDangerous() + normalisedAppinfo + .getOrigin({ request: props.request, userContext: props.userContext }) + .getAsStringDangerous() ); } }; } -function validateAndNormaliseValidateEmailAddressConfig(_, __, validateEmailAddressConfig) { +function validateAndNormaliseValidateEmailAddressConfig(validateEmailAddressConfig) { return (email, tenantId) => { if (typeof validateEmailAddressConfig === "function") { return validateEmailAddressConfig(email, tenantId); diff --git a/lib/ts/recipe/webauthn/core-mock.ts b/lib/ts/recipe/webauthn/core-mock.ts index 7ee65cb53..ecf81c0d8 100644 --- a/lib/ts/recipe/webauthn/core-mock.ts +++ b/lib/ts/recipe/webauthn/core-mock.ts @@ -1,6 +1,18 @@ import NormalisedURLPath from "../../normalisedURLPath"; import { Querier } from "../../querier"; import { UserContext } from "../../types"; +import { generateAuthenticationOptions, generateRegistrationOptions } from "@simplewebauthn/server"; +import crypto from "crypto"; + +const db = { + generatedOptions: {} as Record, +}; +const writeDb = (table: keyof typeof db, key: string, value: any) => { + db[table][key] = value; +}; +// const readDb = (table: keyof typeof db, key: string) => { +// return db[table][key]; +// }; export const getMockQuerier = (recipeId: string) => { const querier = Querier.getNewInstanceOrThrowError(recipeId); @@ -8,34 +20,50 @@ export const getMockQuerier = (recipeId: string) => { const sendPostRequest = async ( path: NormalisedURLPath, body: any, - userContext: UserContext + _userContext: UserContext ): Promise => { if (path.getAsStringDangerous().includes("/recipe/webauthn/options/register")) { + const registrationOptions = await generateRegistrationOptions({ + rpID: body.relyingPartyId, + rpName: body.relyingPartyName, + userName: body.email, + timeout: body.timeout, + attestationType: body.attestation || "none", + authenticatorSelection: { + userVerification: body.userVerification || "preferred", + requireResidentKey: body.requireResidentKey || false, + residentKey: body.residentKey || "required", + }, + supportedAlgorithmIDs: body.supportedAlgorithmIDs || [-8, -7, -257], + userDisplayName: body.displayName || body.email, + }); + const id = crypto.randomUUID(); + writeDb("generatedOptions", id, { + ...registrationOptions, + id, + origin: body.origin, + tenantId: body.tenantId, + }); // @ts-ignore return { status: "OK", - webauthnGeneratedOptionsId: "7ab03f6a-61b8-4f65-992f-b8b8469bc18f", - rp: { id: "example.com", name: "Example App" }, - user: { id: "dummy-user-id", name: "user@example.com", displayName: "User" }, - challenge: "dummy-challenge", - timeout: 60000, - excludeCredentials: [], - attestation: "none", - pubKeyCredParams: [{ alg: -7, type: "public-key" }], - authenticatorSelection: { - requireResidentKey: false, - residentKey: "preferred", - userVerification: "preferred", - }, + webauthnGeneratedOptionsId: id, + ...registrationOptions, }; } else if (path.getAsStringDangerous().includes("/recipe/webauthn/options/signin")) { + const signInOptions = await generateAuthenticationOptions({ + rpID: body.relyingPartyId, + timeout: body.timeout, + userVerification: body.userVerification || "preferred", + }); + const id = crypto.randomUUID(); + writeDb("generatedOptions", id, { ...signInOptions, id, origin: body.origin, tenantId: body.tenantId }); + // @ts-ignore return { status: "OK", - webauthnGeneratedOptionsId: "18302759-87c6-4d88-990d-c7cab43653cc", - challenge: "dummy-signin-challenge", - timeout: 60000, - userVerification: "preferred", + webauthnGeneratedOptionsId: id, + ...signInOptions, }; // } else if (path.getAsStringDangerous().includes("/recipe/webauthn/user/recover/token")) { // // @ts-ignore diff --git a/package-lock.json b/package-lock.json index 918e552e8..5e765d6c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "@loopback/core": "2.16.2", "@loopback/repository": "3.7.1", "@loopback/rest": "9.3.0", + "@simplewebauthn/server": "^11.0.0", "@types/aws-lambda": "8.10.77", "@types/brotli": "^1.3.4", "@types/co-body": "^5.1.1", @@ -799,6 +800,12 @@ "@hapi/hoek": "9.x.x" } }, + "node_modules/@hexagon/base64": { + "version": "1.1.28", + "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz", + "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==", + "dev": true + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -963,6 +970,12 @@ "node": ">= 8.0.0" } }, + "node_modules/@levischuck/tiny-cbor": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.2.tgz", + "integrity": "sha512-f5CnPw997Y2GQ8FAvtuVVC19FX8mwNNC+1XJcIi16n/LTJifKO6QBgGLgN3YEmqtGMk17SKSuoWES3imJVxAVw==", + "dev": true + }, "node_modules/@loopback/context": { "version": "3.18.0", "resolved": "https://registry.npmjs.org/@loopback/context/-/context-3.18.0.tgz", @@ -1227,6 +1240,74 @@ "node": ">=12" } }, + "node_modules/@peculiar/asn1-android": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.3.13.tgz", + "integrity": "sha512-0VTNazDGKrLS6a3BwTDZanqq6DR/I3SbvmDMuS8Be+OYpvM6x1SRDh9AGDsHVnaCOIztOspCPc6N1m+iUv1Xxw==", + "dev": true, + "dependencies": { + "@peculiar/asn1-schema": "^2.3.13", + "asn1js": "^3.0.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.3.14", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.3.14.tgz", + "integrity": "sha512-zWPyI7QZto6rnLv6zPniTqbGaLh6zBpJyI46r1yS/bVHJXT2amdMHCRRnbV5yst2H8+ppXG6uXu/M6lKakiQ8w==", + "dev": true, + "dependencies": { + "@peculiar/asn1-schema": "^2.3.13", + "@peculiar/asn1-x509": "^2.3.13", + "asn1js": "^3.0.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.3.13.tgz", + "integrity": "sha512-wBNQqCyRtmqvXkGkL4DR3WxZhHy8fDiYtOjTeCd7SFE5F6GBeafw3EJ94PX/V0OJJrjQ40SkRY2IZu3ZSyBqcg==", + "dev": true, + "dependencies": { + "@peculiar/asn1-schema": "^2.3.13", + "@peculiar/asn1-x509": "^2.3.13", + "asn1js": "^3.0.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.3.13.tgz", + "integrity": "sha512-3Xq3a01WkHRZL8X04Zsfg//mGaA21xlL4tlVn4v2xGT0JStiztATRkMwa5b+f/HXmY2smsiLXYK46Gwgzvfg3g==", + "dev": true, + "dependencies": { + "asn1js": "^3.0.5", + "pvtsutils": "^1.3.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.3.13", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.3.13.tgz", + "integrity": "sha512-PfeLQl2skXmxX2/AFFCVaWU8U6FKW1Db43mgBhShCOFS1bVxqtvusq1hVjfuEcuSQGedrLdCSvTgabluwN/M9A==", + "dev": true, + "dependencies": { + "@peculiar/asn1-schema": "^2.3.13", + "asn1js": "^3.0.5", + "ipaddr.js": "^2.1.0", + "pvtsutils": "^1.3.5", + "tslib": "^2.6.2" + } + }, + "node_modules/@peculiar/asn1-x509/node_modules/ipaddr.js": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz", + "integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==", + "dev": true, + "engines": { + "node": ">= 10" + } + }, "node_modules/@sideway/address": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", @@ -1248,6 +1329,41 @@ "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", "dev": true }, + "node_modules/@simplewebauthn/server": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-11.0.0.tgz", + "integrity": "sha512-zu8dxKcPiRUNSN2kmrnNOzNbRI8VaR/rL4ENCHUfC6PEE7SAAdIql9g5GBOd/wOVZolIsaZz3ccFxuGoVP0iaw==", + "dev": true, + "dependencies": { + "@hexagon/base64": "^1.1.27", + "@levischuck/tiny-cbor": "^0.2.2", + "@peculiar/asn1-android": "^2.3.10", + "@peculiar/asn1-ecc": "^2.3.8", + "@peculiar/asn1-rsa": "^2.3.8", + "@peculiar/asn1-schema": "^2.3.8", + "@peculiar/asn1-x509": "^2.3.8", + "@simplewebauthn/types": "^11.0.0", + "cross-fetch": "^4.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@simplewebauthn/server/node_modules/cross-fetch": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-4.0.0.tgz", + "integrity": "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==", + "dev": true, + "dependencies": { + "node-fetch": "^2.6.12" + } + }, + "node_modules/@simplewebauthn/types": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/types/-/types-11.0.0.tgz", + "integrity": "sha512-b2o0wC5u2rWts31dTgBkAtSNKGX0cvL6h8QedNsKmj8O4QoLFQFR3DBVBUlpyVEhYKA+mXGUaXbcOc4JdQ3HzA==", + "dev": true + }, "node_modules/@sinonjs/commons": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz", @@ -1861,6 +1977,20 @@ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", "dev": true }, + "node_modules/asn1js": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.5.tgz", + "integrity": "sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ==", + "dev": true, + "dependencies": { + "pvtsutils": "^1.3.2", + "pvutils": "^1.1.3", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/assertion-error": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", @@ -6500,6 +6630,24 @@ "node": ">=6" } }, + "node_modules/pvtsutils": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.5.tgz", + "integrity": "sha512-ARvb14YB9Nm2Xi6nBq1ZX6dAM0FsJnuk+31aUp4TrcZEdKUlSqOqsxJHUPJDNE3qiIp+iUPEIeR6Je/tgV7zsA==", + "dev": true, + "dependencies": { + "tslib": "^2.6.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.3.tgz", + "integrity": "sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/qs": { "version": "6.12.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.12.0.tgz", diff --git a/package.json b/package.json index 335447feb..0e4bc1c15 100644 --- a/package.json +++ b/package.json @@ -147,6 +147,7 @@ "@loopback/core": "2.16.2", "@loopback/repository": "3.7.1", "@loopback/rest": "9.3.0", + "@simplewebauthn/server": "^11.0.0", "@types/aws-lambda": "8.10.77", "@types/brotli": "^1.3.4", "@types/co-body": "^5.1.1", diff --git a/test/test-server/src/webauthn.ts b/test/test-server/src/webauthn.ts new file mode 100644 index 000000000..d2747ea2b --- /dev/null +++ b/test/test-server/src/webauthn.ts @@ -0,0 +1,31 @@ +import { Router } from "express"; +import EmailPassword from "../../../recipe/emailpassword"; +import Webauthn from "../../../recipe/webauthn"; +import { convertRequestSessionToSessionObject, serializeRecipeUserId, serializeResponse, serializeUser } from "./utils"; +import * as supertokens from "../../../lib/build"; +import { logger } from "./logger"; + +const namespace = "com.supertokens:node-test-server:emailpassword"; +const { logDebugMessage } = logger(namespace); + +const router = Router().post("/registeroptions", async (req, res, next) => { + try { + logDebugMessage("Webauthn:registerOptions %j", req.body); + const response = await Webauthn.registerOptions( + req.body.email, + req.body.recoverAccountToken, + req.body.relyingPartyId, + req.body.relyingPartyName, + req.body.origin, + req.body.timeout, + req.body.attestation, + req.body.tenantId || "public", + req.body.userContext + ); + res.json(response); + } catch (e) { + next(e); + } +}); + +export default router; diff --git a/test/utils.js b/test/utils.js index f679a6c3a..63a38025c 100644 --- a/test/utils.js +++ b/test/utils.js @@ -32,6 +32,7 @@ let MultitenancyRecipe = require("../lib/build/recipe/multitenancy/recipe").defa let MultiFactorAuthRecipe = require("../lib/build/recipe/multifactorauth/recipe").default; const UserRolesRecipe = require("../lib/build/recipe/userroles/recipe").default; const OAuth2Recipe = require("../lib/build/recipe/oauth2provider/recipe").default; +const WebAuthnRecipe = require("../lib/build/recipe/webauthn/recipe").default; let { ProcessState } = require("../lib/build/processState"); let { Querier } = require("../lib/build/querier"); let { maxVersion } = require("../lib/build/utils"); @@ -268,6 +269,7 @@ module.exports.resetAll = function (disableLogging = true) { TotpRecipe.reset(); MultiFactorAuthRecipe.reset(); OAuth2Recipe.reset(); + WebAuthnRecipe.reset(); if (disableLogging) { debug.disable(); } diff --git a/test/webauthn/apis.test.js b/test/webauthn/apis.test.js new file mode 100644 index 000000000..85d26917b --- /dev/null +++ b/test/webauthn/apis.test.js @@ -0,0 +1,149 @@ +/* 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. + */ +const { + printPath, + setupST, + startST, + startSTWithMultitenancy, + killAllST, + cleanST, + setKeyValueInConfig, + stopST, +} = require("../utils"); +let STExpress = require("../../"); +let Session = require("../../recipe/session"); +let WebAuthn = require("../../recipe/webauthn"); +let assert = require("assert"); +let { ProcessState } = require("../../lib/build/processState"); +let SuperTokens = require("../../lib/build/supertokens").default; +const request = require("supertest"); +const express = require("express"); +let { middleware, errorHandler } = require("../../framework/express"); +let { isCDIVersionCompatible } = require("../utils"); +const { default: RecipeUserId } = require("../../lib/build/recipeUserId"); + +describe(`apisFunctions: ${printPath("[test/webauthn/apis.test.js]")}`, function () { + beforeEach(async function () { + await killAllST(); + await setupST(); + ProcessState.getInstance().reset(); + }); + + after(async function () { + await killAllST(); + await cleanST(); + }); + + it("test registerOptionsAPI with default values", async function () { + const connectionURI = await startST(); + + STExpress.init({ + supertokens: { + connectionURI, + }, + appInfo: { + apiDomain: "api.supertokens.io", + appName: "SuperTokensplm", + websiteDomain: "supertokens.io", + }, + recipeList: [WebAuthn.init()], + }); + + // run test if current CDI version >= 2.11 + if (!(await isCDIVersionCompatible("2.11"))) return; + + const app = express(); + app.use(middleware()); + app.use(errorHandler()); + + // passing valid field + let validCreateCodeResponse = await new Promise((resolve) => + request(app) + .post("/auth/webauthn/options/register") + .send({ + email: "test@example.com", + }) + .expect(200) + .end((err, res) => { + if (err) { + console.log(err); + resolve(undefined); + } else { + resolve(JSON.parse(res.text)); + } + }) + ); + console.log(validCreateCodeResponse); + + assert(validCreateCodeResponse.status === "OK"); + + assert(typeof validCreateCodeResponse.challenge === "string"); + assert(validCreateCodeResponse.attestation === "none"); + assert(validCreateCodeResponse.rp.id === "supertokens.io"); + assert(validCreateCodeResponse.rp.name === "SuperTokensplm"); + assert(validCreateCodeResponse.user.name === "test@example.com"); + assert(validCreateCodeResponse.user.displayName === "test@example.com"); + assert(Number.isInteger(validCreateCodeResponse.timeout)); + assert(validCreateCodeResponse.authenticatorSelection.userVerification === "preferred"); + assert(validCreateCodeResponse.authenticatorSelection.requireResidentKey === true); + assert(validCreateCodeResponse.authenticatorSelection.residentKey === "required"); + }); +}); + +function checkConsumeResponse(validUserInputCodeResponse, { email, phoneNumber, isNew, isPrimary }) { + assert.strictEqual(validUserInputCodeResponse.status, "OK"); + assert.strictEqual(validUserInputCodeResponse.createdNewRecipeUser, isNew); + + assert.strictEqual(typeof validUserInputCodeResponse.user.id, "string"); + assert.strictEqual(typeof validUserInputCodeResponse.user.timeJoined, "number"); + assert.strictEqual(validUserInputCodeResponse.user.isPrimaryUser, isPrimary); + + assert(validUserInputCodeResponse.user.emails instanceof Array); + if (email !== undefined) { + assert.strictEqual(validUserInputCodeResponse.user.emails.length, 1); + assert.strictEqual(validUserInputCodeResponse.user.emails[0], email); + } else { + assert.strictEqual(validUserInputCodeResponse.user.emails.length, 0); + } + + assert(validUserInputCodeResponse.user.phoneNumbers instanceof Array); + if (phoneNumber !== undefined) { + assert.strictEqual(validUserInputCodeResponse.user.phoneNumbers.length, 1); + assert.strictEqual(validUserInputCodeResponse.user.phoneNumbers[0], phoneNumber); + } else { + assert.strictEqual(validUserInputCodeResponse.user.phoneNumbers.length, 0); + } + + assert.strictEqual(validUserInputCodeResponse.user.thirdParty.length, 0); + + assert.strictEqual(validUserInputCodeResponse.user.loginMethods.length, 1); + const loginMethod = { + recipeId: "passwordless", + recipeUserId: validUserInputCodeResponse.user.id, + timeJoined: validUserInputCodeResponse.user.timeJoined, + verified: true, + tenantIds: ["public"], + }; + if (email) { + loginMethod.email = email; + } + if (phoneNumber) { + loginMethod.phoneNumber = phoneNumber; + } + assert.deepStrictEqual(validUserInputCodeResponse.user.loginMethods, [loginMethod]); + + assert.strictEqual(Object.keys(validUserInputCodeResponse.user).length, 8); + assert.strictEqual(Object.keys(validUserInputCodeResponse).length, 3); +} diff --git a/test/webauthn/config.test.js b/test/webauthn/config.test.js new file mode 100644 index 000000000..a6331db28 --- /dev/null +++ b/test/webauthn/config.test.js @@ -0,0 +1,123 @@ +/* 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. + */ +const { printPath, setupST, startST, stopST, killAllST, cleanST, resetAll } = require("../utils"); +let STExpress = require("../../"); +let Session = require("../../recipe/session"); +let SessionRecipe = require("../../lib/build/recipe/session/recipe").default; +let assert = require("assert"); +let { ProcessState } = require("../../lib/build/processState"); +let { normaliseURLPathOrThrowError } = require("../../lib/build/normalisedURLPath"); +let { normaliseURLDomainOrThrowError } = require("../../lib/build/normalisedURLDomain"); +let { normaliseSessionScopeOrThrowError } = require("../../lib/build/recipe/session/utils"); +const { Querier } = require("../../lib/build/querier"); +let WebAuthn = require("../../recipe/webauthn"); +let WebAuthnRecipe = require("../../lib/build/recipe/webauthn/recipe").default; +let utils = require("../../lib/build/recipe/webauthn/utils"); +let { middleware, errorHandler } = require("../../framework/express"); + +describe(`configTest: ${printPath("[test/webauthn/config.test.js]")}`, function () { + beforeEach(async function () { + await killAllST(); + await setupST(); + ProcessState.getInstance().reset(); + }); + + after(async function () { + await killAllST(); + await cleanST(); + }); + + // test config for emailpassword module + // Failure condition: passing custom data or data of invalid type/ syntax to the module + it("test default config for webauthn module", async function () { + const connectionURI = await startST(); + STExpress.init({ + supertokens: { + connectionURI, + }, + appInfo: { + apiDomain: "api.supertokens.io", + appName: "SuperTokens", + websiteDomain: "supertokens.io", + }, + recipeList: [WebAuthn.init()], + debug: true, + }); + + let webauthn = await WebAuthnRecipe.getInstanceOrThrowError(); + + assert(!!webauthn); + const origin = await webauthn.config.getOrigin({ userContext: {} }); + const relyingPartyId = await webauthn.config.getRelyingPartyId({ userContext: {} }); + const relyingPartyName = await webauthn.config.getRelyingPartyName({ userContext: {} }); + + assert(origin === "https://supertokens.io"); + assert(relyingPartyId === "supertokens.io"); + assert(relyingPartyName === "SuperTokens"); + + assert((await webauthn.config.validateEmailAddress("aaaaa")) === "Email is invalid"); + assert((await webauthn.config.validateEmailAddress("aaaaaa@aaaaaa")) === "Email is invalid"); + assert((await webauthn.config.validateEmailAddress("random User @randomMail.com")) === "Email is invalid"); + assert((await webauthn.config.validateEmailAddress("*@*")) === "Email is invalid"); + assert((await webauthn.config.validateEmailAddress("validmail@gmail.com")) === undefined); + assert( + (await webauthn.config.validateEmailAddress()) === + "Development bug: Please make sure the email field yields a string" + ); + }); + + // Failure condition: passing data of invalid type/ syntax to the module + it("test config for webauthn module", async function () { + const connectionURI = await startST(); + + STExpress.init({ + supertokens: { + connectionURI, + }, + appInfo: { + apiDomain: "api.supertokens.io", + appName: "SuperTokens", + websiteDomain: "supertokens.io", + }, + recipeList: [ + WebAuthn.init({ + getOrigin: () => { + return "testOrigin"; + }, + getRelyingPartyId: () => { + return "testId"; + }, + getRelyingPartyName: () => { + return "testName"; + }, + validateEmailAddress: (email) => { + return email === "test"; + }, + }), + ], + }); + + let webauthn = await WebAuthnRecipe.getInstanceOrThrowError(); + const origin = webauthn.config.getOrigin(); + const relyingPartyId = webauthn.config.getRelyingPartyId(); + const relyingPartyName = webauthn.config.getRelyingPartyName(); + + assert(origin === "testOrigin"); + assert(relyingPartyId === "testId"); + assert(relyingPartyName === "testName"); + assert(await webauthn.config.validateEmailAddress("test")); + assert(!(await webauthn.config.validateEmailAddress("test!"))); + }); +});