diff --git a/lib/ts/recipe/totp/componentOverrideContext.tsx b/lib/ts/recipe/totp/componentOverrideContext.tsx new file mode 100644 index 000000000..467bd5485 --- /dev/null +++ b/lib/ts/recipe/totp/componentOverrideContext.tsx @@ -0,0 +1,7 @@ +import { createGenericComponentsOverrideContext } from "../../components/componentOverride/genericComponentOverrideContext"; + +import type { ComponentOverrideMap } from "./types"; + +const [useContext, Provider] = createGenericComponentsOverrideContext(); + +export { useContext as useRecipeComponentOverrideContext, Provider as RecipeComponentsOverrideContextProvider }; diff --git a/lib/ts/recipe/totp/components/features/mfaTOTP/index.tsx b/lib/ts/recipe/totp/components/features/mfaTOTP/index.tsx new file mode 100644 index 000000000..0e92a6bb2 --- /dev/null +++ b/lib/ts/recipe/totp/components/features/mfaTOTP/index.tsx @@ -0,0 +1,396 @@ +/* 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. + */ +/* + * Imports. + */ +import * as React from "react"; +import { Fragment } from "react"; +import { useMemo } from "react"; +import { useRef } from "react"; +import { useEffect } from "react"; + +import { ComponentOverrideContext } from "../../../../../components/componentOverride/componentOverrideContext"; +import FeatureWrapper from "../../../../../components/featureWrapper"; +import { useUserContext } from "../../../../../usercontext"; +import { clearErrorQueryParam, getQueryParams, getRedirectToPathFromURL } from "../../../../../utils"; +import Session from "../../../../session"; +import SessionRecipe from "../../../../session/recipe"; +import MFATOTPThemeWrapper from "../../themes/mfaTOTP"; +import { defaultTranslationsTOTP } from "../../themes/translations"; + +import type { FeatureBaseProps } from "../../../../../types"; +import type Recipe from "../../../recipe"; +import type { ComponentOverrideMap } from "../../../types"; +import type { NormalisedConfig } from "../../../types"; +import type { RecipeInterface } from "supertokens-web-js/recipe/passwordless"; +import type { User } from "supertokens-web-js/types"; + +export const useSuccessInAnotherTabChecker = ( + state: SignInUpState, + dispatch: React.Dispatch, + userContext: any +) => { + const callingConsumeCodeRef = useRef(false); + + useEffect(() => { + // We only need to start checking this if we have an active login attempt + if (state.loginAttemptInfo && !state.successInAnotherTab) { + const checkSessionIntervalHandle = setInterval(async () => { + if (callingConsumeCodeRef.current === false) { + const hasSession = await Session.doesSessionExist({ + userContext, + }); + if (hasSession) { + dispatch({ type: "successInAnotherTab" }); + } + } + }, 2000); + + return () => { + clearInterval(checkSessionIntervalHandle); + }; + } + // Nothing to clean up + return; + }, [state.loginAttemptInfo, state.successInAnotherTab]); + + return callingConsumeCodeRef; +}; + +export const useFeatureReducer = ( + recipeImpl: RecipeInterface | undefined, + userContext: any +): [SignInUpState, React.Dispatch] => { + const [state, dispatch] = React.useReducer( + (oldState: SignInUpState, action: PasswordlessSignInUpAction) => { + switch (action.type) { + case "load": + return { + loaded: true, + error: action.error, + loginAttemptInfo: action.loginAttemptInfo, + successInAnotherTab: false, + }; + case "resendCode": + if (!oldState.loginAttemptInfo) { + return oldState; + } + return { + ...oldState, + error: undefined, + loginAttemptInfo: { + ...oldState.loginAttemptInfo, + lastResend: action.timestamp, + }, + }; + case "restartFlow": + return { + ...oldState, + error: action.error, + loginAttemptInfo: undefined, + }; + case "setError": + return { + ...oldState, + error: action.error, + }; + case "startLogin": + return { + ...oldState, + loginAttemptInfo: action.loginAttemptInfo, + error: undefined, + }; + case "successInAnotherTab": + return { + ...oldState, + successInAnotherTab: true, + }; + default: + return oldState; + } + }, + { + error: undefined, + loaded: false, + loginAttemptInfo: undefined, + successInAnotherTab: false, + }, + (initArg) => { + let error: string | undefined = undefined; + const errorQueryParam = getQueryParams("error"); + const messageQueryParam = getQueryParams("message"); + if (errorQueryParam !== null) { + if (errorQueryParam === "signin") { + error = "SOMETHING_WENT_WRONG_ERROR"; + } else if (errorQueryParam === "restart_link") { + error = "ERROR_SIGN_IN_UP_LINK"; + } else if (errorQueryParam === "custom" && messageQueryParam !== null) { + error = messageQueryParam; + } + } + return { + ...initArg, + error, + }; + } + ); + useEffect(() => { + if (recipeImpl === undefined) { + return; + } + async function load() { + let error: string | undefined = undefined; + const errorQueryParam = getQueryParams("error"); + const messageQueryParam = getQueryParams("message"); + if (errorQueryParam !== null) { + if (errorQueryParam === "signin") { + error = "SOMETHING_WENT_WRONG_ERROR"; + } else if (errorQueryParam === "restart_link") { + error = "ERROR_SIGN_IN_UP_LINK"; + } else if (errorQueryParam === "custom" && messageQueryParam !== null) { + error = messageQueryParam; + } + } + const loginAttemptInfo = await recipeImpl?.getLoginAttemptInfo({ + userContext, + }); + // No need to check if the component is unmounting, since this has no effect then. + dispatch({ type: "load", loginAttemptInfo, error }); + } + if (state.loaded === false) { + void load(); + } + }, [state.loaded, recipeImpl, userContext]); + return [state, dispatch]; +}; + +// We are overloading to explicitly state that if recipe is defined then the return value is defined as well. +export function useChildProps( + recipe: Recipe, + dispatch: React.Dispatch, + state: SignInUpState, + callingConsumeCodeRef: React.MutableRefObject, + userContext: any, + history: any +): SignInUpChildProps; +export function useChildProps( + recipe: Recipe | undefined, + dispatch: React.Dispatch, + state: SignInUpState, + callingConsumeCodeRef: React.MutableRefObject, + userContext: any, + history: any +): SignInUpChildProps | undefined; + +export function useChildProps( + recipe: Recipe | undefined, + dispatch: React.Dispatch, + state: SignInUpState, + callingConsumeCodeRef: React.MutableRefObject, + userContext: any, + history: any +): SignInUpChildProps | undefined { + const recipeImplementation = React.useMemo( + () => + recipe && + getModifiedRecipeImplementation(recipe.webJSRecipe, recipe.config, dispatch, callingConsumeCodeRef), + [recipe] + ); + + return useMemo(() => { + if (!recipe || !recipeImplementation) { + return undefined; + } + return { + onSuccess: (result: { createdNewRecipeUser: boolean; user: User }) => { + return SessionRecipe.getInstanceOrThrow().validateGlobalClaimsAndHandleSuccessRedirection( + { + rid: recipe.config.recipeId, + successRedirectContext: { + action: "SUCCESS", + isNewRecipeUser: result.createdNewRecipeUser, + user: result.user, + redirectToPath: getRedirectToPathFromURL(), + }, + }, + userContext, + history + ); + }, + recipeImplementation: recipeImplementation, + config: recipe.config, + }; + }, [state, recipeImplementation]); +} + +export const SignInUpFeature: React.FC< + FeatureBaseProps & { + recipe: Recipe; + useComponentOverrides: () => ComponentOverrideMap; + } +> = (props) => { + const recipeComponentOverrides = props.useComponentOverrides(); + const userContext = useUserContext(); + const [state, dispatch] = useFeatureReducer(props.recipe.webJSRecipe, userContext); + const callingConsumeCodeRef = useSuccessInAnotherTabChecker(state, dispatch, userContext); + const childProps = useChildProps(props.recipe, dispatch, state, callingConsumeCodeRef, userContext, props.history)!; + + return ( + + + + {/* No custom theme, use default. */} + {props.children === undefined && ( + + )} + + {/* Otherwise, custom theme is provided, propagate props. */} + {props.children && + React.Children.map(props.children, (child) => { + if (React.isValidElement(child)) { + return React.cloneElement(child, { + ...childProps, + featureState: state, + dispatch: dispatch, + }); + } + return child; + })} + + + + ); +}; + +export default SignInUpFeature; + +function getModifiedRecipeImplementation( + originalImpl: RecipeInterface, + config: NormalisedConfig, + dispatch: React.Dispatch, + callingConsumeCodeRef: React.MutableRefObject +): RecipeInterface { + return { + ...originalImpl, + createCode: async (input) => { + let contactInfo; + const phoneNumberUtils = await getPhoneNumberUtils(); + if ("email" in input) { + contactInfo = input.email; + } else { + contactInfo = phoneNumberUtils.formatNumber( + input.phoneNumber, + config.signInUpFeature.defaultCountry || "", + phoneNumberUtils.numberFormat.E164 + ); + } + + // This contactMethod refers to the one that was used to deliver the login info + // This can be an important distinction in case both email and phone are allowed + const contactMethod: "EMAIL" | "PHONE" = "email" in input ? "EMAIL" : "PHONE"; + const additionalAttemptInfo = { + lastResend: Date.now(), + contactMethod, + contactInfo, + redirectToPath: getRedirectToPathFromURL(), + }; + + const res = await originalImpl.createCode({ + ...input, + userContext: { ...input.userContext, additionalAttemptInfo }, + }); + if (res.status === "OK") { + const loginAttemptInfo = (await originalImpl.getLoginAttemptInfo({ + userContext: input.userContext, + }))!; + dispatch({ type: "startLogin", loginAttemptInfo }); + } + return res; + }, + resendCode: async (input) => { + /** + * In this case we want the code that is calling resendCode in the + * UI to handle STGeneralError so we let this throw + */ + const res = await originalImpl.resendCode(input); + + if (res.status === "OK") { + const loginAttemptInfo = await originalImpl.getLoginAttemptInfo({ + userContext: input.userContext, + }); + + if (loginAttemptInfo !== undefined) { + const timestamp = Date.now(); + + await originalImpl.setLoginAttemptInfo({ + userContext: input.userContext, + attemptInfo: { + ...loginAttemptInfo, + lastResend: timestamp, + }, + }); + dispatch({ type: "resendCode", timestamp }); + } + } else if (res.status === "RESTART_FLOW_ERROR") { + await originalImpl.clearLoginAttemptInfo({ + userContext: input.userContext, + }); + + dispatch({ type: "restartFlow", error: "ERROR_SIGN_IN_UP_RESEND_RESTART_FLOW" }); + } + return res; + }, + + consumeCode: async (input) => { + // We need to call consume code while callingConsume, so we don't detect + // the session creation too early and go to successInAnotherTab too early + callingConsumeCodeRef.current = true; + + const res = await originalImpl.consumeCode(input); + + if (res.status === "RESTART_FLOW_ERROR") { + await originalImpl.clearLoginAttemptInfo({ + userContext: input.userContext, + }); + + dispatch({ type: "restartFlow", error: "ERROR_SIGN_IN_UP_CODE_CONSUME_RESTART_FLOW" }); + } else if (res.status === "SIGN_IN_UP_NOT_ALLOWED") { + await originalImpl.clearLoginAttemptInfo({ + userContext: input.userContext, + }); + + dispatch({ type: "restartFlow", error: res.reason }); + } else if (res.status === "OK") { + await originalImpl.clearLoginAttemptInfo({ + userContext: input.userContext, + }); + } + + callingConsumeCodeRef.current = false; + + return res; + }, + + clearLoginAttemptInfo: async (input) => { + await originalImpl.clearLoginAttemptInfo({ + userContext: input.userContext, + }); + clearErrorQueryParam(); + dispatch({ type: "restartFlow", error: undefined }); + }, + }; +} diff --git a/lib/ts/recipe/totp/components/themes/mfaTOTP/factorChooserFooter.tsx b/lib/ts/recipe/totp/components/themes/mfaTOTP/factorChooserFooter.tsx new file mode 100644 index 000000000..10473fbe1 --- /dev/null +++ b/lib/ts/recipe/totp/components/themes/mfaTOTP/factorChooserFooter.tsx @@ -0,0 +1,33 @@ +/* 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 ArrowLeftIcon from "../../../../../components/assets/arrowLeftIcon"; +import { withOverride } from "../../../../../components/componentOverride/withOverride"; +import { useTranslation } from "../../../../../translation/translationContext"; + +export const FactorChooserFooter = withOverride( + "MultiFactorAuthFactorChooserFooter", + function MultiFactorAuthFactorChooserFooter({ logout }: { logout: (() => void) | undefined }): JSX.Element { + const t = useTranslation(); + + return ( +
+
+ + {t("MULTI_FACTOR_AUTH_LOGOUT")} +
+
+ ); + } +); diff --git a/lib/ts/recipe/totp/components/themes/mfaTOTP/factorChooserHeader.tsx b/lib/ts/recipe/totp/components/themes/mfaTOTP/factorChooserHeader.tsx new file mode 100644 index 000000000..544582d03 --- /dev/null +++ b/lib/ts/recipe/totp/components/themes/mfaTOTP/factorChooserHeader.tsx @@ -0,0 +1,29 @@ +/* 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 { withOverride } from "../../../../../components/componentOverride/withOverride"; +import { useTranslation } from "../../../../../translation/translationContext"; + +export const FactorChooserHeader = withOverride( + "MultiFactorAuthFactorChooserHeader", + function MultiFactorAuthFactorChooserHeader(): JSX.Element { + const t = useTranslation(); + + return ( +
+
{t("MULTI_FACTOR_CHOOSER_HEADER_TITLE")}
+
+ ); + } +); diff --git a/lib/ts/recipe/totp/components/themes/mfaTOTP/factorList.tsx b/lib/ts/recipe/totp/components/themes/mfaTOTP/factorList.tsx new file mode 100644 index 000000000..0a06de9f1 --- /dev/null +++ b/lib/ts/recipe/totp/components/themes/mfaTOTP/factorList.tsx @@ -0,0 +1,47 @@ +/* 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 { withOverride } from "../../../../../components/componentOverride/withOverride"; + +import { FactorOption } from "./factorOption"; + +import type { SecondaryFactorRedirectionInfo } from "../../../types"; +import type { MFAFactorInfo } from "supertokens-web-js/recipe/multifactorauth/types"; + +export const FactorList = withOverride( + "MultiFactorAuthFactorList", + function MultiFactorAuthFactorList({ + availableFactors, + navigateToFactor, + }: { + availableFactors: SecondaryFactorRedirectionInfo[]; + mfaInfo: MFAFactorInfo; + navigateToFactor: (factorId: string) => void; + }): JSX.Element { + return ( +
+ {availableFactors.map((factor) => ( + navigateToFactor(factor.id)} + /> + ))} +
+ ); + } +); diff --git a/lib/ts/recipe/totp/components/themes/mfaTOTP/factorOption.tsx b/lib/ts/recipe/totp/components/themes/mfaTOTP/factorOption.tsx new file mode 100644 index 000000000..365c2bb37 --- /dev/null +++ b/lib/ts/recipe/totp/components/themes/mfaTOTP/factorOption.tsx @@ -0,0 +1,49 @@ +/* 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 ArrowRightIcon from "../../../../../components/assets/arrowRightIcon"; +import { withOverride } from "../../../../../components/componentOverride/withOverride"; +import { useTranslation } from "../../../../../translation/translationContext"; + +import type { FC } from "react"; + +export const FactorOption = withOverride( + "MultiFactorAuthFactorOption", + function MultiFactorAuthFactorOption({ + onClick, + name, + description, + logo, + }: { + onClick: (() => void) | undefined; + name: string; + description: string; + logo: FC; + }): JSX.Element { + const t = useTranslation(); + return ( + +
{logo({})}
+
+
{t(name)}
+

{t(description)}

+
+ {/* + + */} +
+ ); + } +); diff --git a/lib/ts/recipe/totp/components/themes/mfaTOTP/index.tsx b/lib/ts/recipe/totp/components/themes/mfaTOTP/index.tsx new file mode 100644 index 000000000..7f099d615 --- /dev/null +++ b/lib/ts/recipe/totp/components/themes/mfaTOTP/index.tsx @@ -0,0 +1,64 @@ +/* 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 { SuperTokensBranding } from "../../../../../components/SuperTokensBranding"; +import { hasFontDefined } from "../../../../../styles/styles"; +import UserContextWrapper from "../../../../../usercontext/userContextWrapper"; +import { useSessionContext } from "../../../../session"; +import { ThemeBase } from "../themeBase"; + +import { FactorChooserFooter } from "./factorChooserFooter"; +import { FactorChooserHeader } from "./factorChooserHeader"; +import { FactorList } from "./factorList"; + +import type { FactorChooserThemeProps } from "../../../types"; + +export function FactorChooserTheme(props: FactorChooserThemeProps): JSX.Element { + const sessionContext = useSessionContext(); + + if (sessionContext.loading === false && sessionContext.doesSessionExist === true) { + return ( +
+ + + + +
+ ); + } + + // Otherwise, return an empty screen, waiting for the feature component redirection to complete. + return <>; +} + +function FactorChooserThemeWrapper(props: FactorChooserThemeProps): JSX.Element { + const hasFont = hasFontDefined(props.config.rootStyle); + + return ( + + + + + + ); +} + +export default FactorChooserThemeWrapper; diff --git a/lib/ts/recipe/totp/components/themes/styles.css b/lib/ts/recipe/totp/components/themes/styles.css new file mode 100644 index 000000000..3e3e2dcf4 --- /dev/null +++ b/lib/ts/recipe/totp/components/themes/styles.css @@ -0,0 +1,64 @@ +@import "../../../../styles/styles.css"; + +[data-supertokens~="container"] { + padding-top: 24px; +} + +[data-supertokens~="row"] { + padding-top: 16px; + padding-bottom: 8px; +} + +[data-supertokens~="factorChooserList"] { + padding-top: 4px; +} + +[data-supertokens~="factorChooserOption"] { + display: flex; + flex-direction: row; + border-radius: 6px; + border: 1px solid rgb(var(--palette-inputBorder)); + padding: 16px; + cursor: pointer; + margin-top: 12px; +} + +[data-supertokens~="factorChooserOption"]:hover { + border: 1px solid rgb(var(--palette-textLink)); +} + +[data-supertokens~="factorOptionText"] { + flex-grow: 1; + display: flex; + flex-direction: column; + align-items: start; + text-align: left; +} + +[data-supertokens~="factorLogo"] { + flex-grow: 0; + min-width: 30px; + text-align: left; + margin-top: 6px; +} + +[data-supertokens~="factorName"] { + color: rgb(var(--palette-textPrimary)); + font-size: var(--font-size-1); + margin: 4px; +} + +[data-supertokens~="factorChooserOption"]:hover [data-supertokens~="factorName"] { + color: rgb(var(--palette-textLink)); +} + +[data-supertokens~="factorDescription"] { + color: rgb(var(--palette-textSecondary)); + font-size: var(--font-size-0); + margin: 4px; +} + +[data-supertokens~="secondaryLinkWithLeftArrow"] { + margin-bottom: 32px; + text-align: right; +} diff --git a/lib/ts/recipe/totp/components/themes/themeBase.tsx b/lib/ts/recipe/totp/components/themes/themeBase.tsx new file mode 100644 index 000000000..cfdf405a4 --- /dev/null +++ b/lib/ts/recipe/totp/components/themes/themeBase.tsx @@ -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 React from "react"; +import { Fragment } from "react"; + +import styles from "./styles.css"; + +import type { PropsWithChildren } from "react"; + +export const ThemeBase: React.FC< + PropsWithChildren<{ loadDefaultFont: boolean; userStyles: Array }> +> = ({ children, userStyles, loadDefaultFont }) => { + return ( + + {children} + {loadDefaultFont && ( + + )} + + + ); +}; diff --git a/lib/ts/recipe/totp/components/themes/translations.ts b/lib/ts/recipe/totp/components/themes/translations.ts new file mode 100644 index 000000000..983ae3277 --- /dev/null +++ b/lib/ts/recipe/totp/components/themes/translations.ts @@ -0,0 +1,9 @@ +import { defaultTranslationsCommon } from "../../../../translation/translations"; + +export const defaultTranslationsTOTP = { + en: { + ...defaultTranslationsCommon.en, + MULTI_FACTOR_CHOOSER_HEADER_TITLE: "Please select a second factor", + MULTI_FACTOR_AUTH_LOGOUT: "Logout", + }, +}; diff --git a/lib/ts/recipe/totp/constants.ts b/lib/ts/recipe/totp/constants.ts new file mode 100644 index 000000000..2d72d43ca --- /dev/null +++ b/lib/ts/recipe/totp/constants.ts @@ -0,0 +1,15 @@ +/* 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_TOTP_PATH = "/mfa/totp"; diff --git a/lib/ts/recipe/totp/functionOverrides.ts b/lib/ts/recipe/totp/functionOverrides.ts new file mode 100644 index 000000000..6f526cbdc --- /dev/null +++ b/lib/ts/recipe/totp/functionOverrides.ts @@ -0,0 +1,12 @@ +import type { OnHandleEventContext } from "./types"; +import type { RecipeOnHandleEventFunction } from "../recipeModule/types"; +import type { RecipeInterface } from "supertokens-web-js/recipe/totp"; + +export const getFunctionOverrides = + ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _onHandleEvent: RecipeOnHandleEventFunction + ) => + (originalImp: RecipeInterface): RecipeInterface => ({ + ...originalImp, + }); diff --git a/lib/ts/recipe/totp/index.ts b/lib/ts/recipe/totp/index.ts new file mode 100644 index 000000000..36a895aad --- /dev/null +++ b/lib/ts/recipe/totp/index.ts @@ -0,0 +1,94 @@ +/* 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. + */ + +/* + * Imports. + */ +import { RecipeInterface } from "supertokens-web-js/recipe/totp"; + +import { getNormalisedUserContext } from "../../utils"; + +import { RecipeComponentsOverrideContextProvider } from "./componentOverrideContext"; +import TOTPRecipe from "./recipe"; +import { GetRedirectionURLContext, PreAPIHookContext, OnHandleEventContext } from "./types"; +import { UserInput } from "./types"; + +import type { RecipeFunctionOptions } from "supertokens-web-js/recipe/totp"; + +export default class Wrapper { + static init(config?: UserInput) { + return TOTPRecipe.init(config); + } + static createDevice(input: { deviceName?: string; options?: RecipeFunctionOptions; userContext?: any }) { + return TOTPRecipe.getInstanceOrThrow().webJSRecipe.createDevice({ + ...input, + userContext: getNormalisedUserContext(input?.userContext), + }); + } + static verifyCode(input: { totp: string; options?: RecipeFunctionOptions; userContext?: any }) { + return TOTPRecipe.getInstanceOrThrow().webJSRecipe.verifyCode({ + ...input, + userContext: getNormalisedUserContext(input?.userContext), + }); + } + static verifyDevice(input: { + deviceName: string; + totp: string; + options?: RecipeFunctionOptions | undefined; + userContext?: any; + }) { + return TOTPRecipe.getInstanceOrThrow().webJSRecipe.verifyDevice({ + ...input, + userContext: getNormalisedUserContext(input?.userContext), + }); + } + static removeDevice(input: { deviceName: string; options?: RecipeFunctionOptions; userContext?: any }) { + return TOTPRecipe.getInstanceOrThrow().webJSRecipe.removeDevice({ + ...input, + userContext: getNormalisedUserContext(input?.userContext), + }); + } + static listDevices(input: { options?: RecipeFunctionOptions; userContext?: any }) { + return TOTPRecipe.getInstanceOrThrow().webJSRecipe.listDevices({ + ...input, + userContext: getNormalisedUserContext(input?.userContext), + }); + } + + static ComponentsOverrideProvider = RecipeComponentsOverrideContextProvider; +} + +const init = Wrapper.init; +const createDevice = Wrapper.createDevice; +const verifyCode = Wrapper.verifyCode; +const verifyDevice = Wrapper.verifyDevice; +const removeDevice = Wrapper.removeDevice; +const listDevices = Wrapper.listDevices; +const TOTPComponentsOverrideProvider = Wrapper.ComponentsOverrideProvider; + +export { + init, + createDevice, + verifyCode, + verifyDevice, + removeDevice, + listDevices, + TOTPComponentsOverrideProvider, + GetRedirectionURLContext, + PreAPIHookContext as PreAPIHookContext, + OnHandleEventContext, + UserInput, + RecipeInterface, +}; diff --git a/lib/ts/recipe/totp/multiFactorAuthClaim.ts b/lib/ts/recipe/totp/multiFactorAuthClaim.ts new file mode 100644 index 000000000..347e2e9a4 --- /dev/null +++ b/lib/ts/recipe/totp/multiFactorAuthClaim.ts @@ -0,0 +1,92 @@ +import { MultiFactorAuthClaimClass as MultiFactorAuthClaimClassWebJS } from "supertokens-web-js/recipe/multifactorauth"; + +import type { SessionClaimValidator, ValidationFailureCallback } from "../../types"; +import type { RecipeInterface } from "supertokens-web-js/recipe/multifactorauth"; +import type { MFARequirementList } from "supertokens-web-js/recipe/multifactorauth/types"; + +export class MultiFactorAuthClaimClass { + private webJSClaim: MultiFactorAuthClaimClassWebJS; + public readonly id: string; + public readonly refresh: (userContext: any) => Promise; + public readonly getLastFetchedTime: (payload: any, _userContext?: any) => number | undefined; + public readonly getValueFromPayload: ( + payload: any, + _userContext?: any + ) => { c: Record; n: string[] } | undefined; + public validators: Omit< + MultiFactorAuthClaimClassWebJS["validators"], + "hasCompletedDefaultFactors" | "hasCompletedFactors" + > & { + hasCompletedDefaultFactors: ( + doRedirection?: boolean, + showAccessDeniedOnFailure?: boolean + ) => SessionClaimValidator; + hasCompletedFactors: ( + requirements: MFARequirementList, + doRedirection?: boolean, + showAccessDeniedOnFailure?: boolean + ) => SessionClaimValidator; + }; + + constructor( + getRecipeImpl: () => RecipeInterface, + getRedirectURL: ( + context: { action: "GO_TO_FACTOR"; factorId: string } | { action: "FACTOR_CHOOSER" }, + userContext: any + ) => Promise, + onFailureRedirection?: ValidationFailureCallback + ) { + this.webJSClaim = new MultiFactorAuthClaimClassWebJS(getRecipeImpl); + this.refresh = this.webJSClaim.refresh; + this.getLastFetchedTime = this.webJSClaim.getLastFetchedTime; + this.getValueFromPayload = this.webJSClaim.getValueFromPayload; + this.id = this.webJSClaim.id; + + const defaultOnFailureRedirection = ({ reason, userContext }: any) => { + if (reason.nextFactorOptions) { + if (reason.nextFactorOptions.length === 1) { + return getRedirectURL( + { action: "GO_TO_FACTOR", factorId: reason.nextFactorOptions[0] }, + userContext + ); + } else { + return getRedirectURL({ action: "FACTOR_CHOOSER" }, userContext); + } + } + return getRedirectURL({ action: "GO_TO_FACTOR", factorId: reason.factorId }, userContext); + }; + + this.validators = { + ...this.webJSClaim.validators, + hasCompletedDefaultFactors: (doRedirection = true, showAccessDeniedOnFailure = true) => { + const orig = this.webJSClaim.validators.hasCompletedDefaultFactors(); + return { + ...orig, + showAccessDeniedOnFailure, + onFailureRedirection: + onFailureRedirection ?? + (( + { reason, userContext } // TODO: feels brittle to rely on reason + ) => (doRedirection ? defaultOnFailureRedirection({ reason, userContext }) : undefined)), + }; + }, + + hasCompletedFactors: ( + requirements: MFARequirementList, + doRedirection = true, + showAccessDeniedOnFailure = true + ) => { + const orig = this.webJSClaim.validators.hasCompletedFactors(requirements); + return { + ...orig, + showAccessDeniedOnFailure, + onFailureRedirection: + onFailureRedirection ?? + (( + { reason, userContext } // TODO: feels brittle to rely on reason + ) => (doRedirection ? defaultOnFailureRedirection({ reason, userContext }) : undefined)), + }; + }, + }; + } +} diff --git a/lib/ts/recipe/totp/prebuiltui.tsx b/lib/ts/recipe/totp/prebuiltui.tsx new file mode 100644 index 000000000..059cf3385 --- /dev/null +++ b/lib/ts/recipe/totp/prebuiltui.tsx @@ -0,0 +1,102 @@ +import NormalisedURLPath from "supertokens-web-js/utils/normalisedURLPath"; + +import UserContextWrapper from "../../usercontext/userContextWrapper"; +import { isTest, matchRecipeIdUsingQueryParams } from "../../utils"; +import { RecipeRouter } from "../recipeRouter"; +import { SessionAuth } from "../session"; + +import { useRecipeComponentOverrideContext } from "./componentOverrideContext"; +import { default as MFATOTPFeature } from "./components/features/mfaTOTP"; +import MFATOTPTheme from "./components/themes/mfaTOTP"; +import { DEFAULT_TOTP_PATH } from "./constants"; +import MultiFactorAuthRecipe from "./recipe"; + +import type { GenericComponentOverrideMap } from "../../components/componentOverride/componentOverrideContext"; +import type { RecipeFeatureComponentMap } from "../../types"; + +export class MultiFactorAuthPreBuiltUI extends RecipeRouter { + static instance?: MultiFactorAuthPreBuiltUI; + constructor(public readonly recipeInstance: MultiFactorAuthRecipe) { + super(); + } + + // Static methods + static getInstanceOrInitAndGetInstance(): MultiFactorAuthPreBuiltUI { + if (MultiFactorAuthPreBuiltUI.instance === undefined) { + const recipeInstance = MultiFactorAuthRecipe.getInstanceOrThrow(); + MultiFactorAuthPreBuiltUI.instance = new MultiFactorAuthPreBuiltUI(recipeInstance); + } + + return MultiFactorAuthPreBuiltUI.instance; + } + static getFeatures( + useComponentOverrides: () => GenericComponentOverrideMap = useRecipeComponentOverrideContext + ): RecipeFeatureComponentMap { + return MultiFactorAuthPreBuiltUI.getInstanceOrInitAndGetInstance().getFeatures(useComponentOverrides); + } + static getFeatureComponent( + componentName: "mfaTOTP", + props: any, + useComponentOverrides: () => GenericComponentOverrideMap = useRecipeComponentOverrideContext + ): JSX.Element { + return MultiFactorAuthPreBuiltUI.getInstanceOrInitAndGetInstance().getFeatureComponent( + componentName, + props, + useComponentOverrides + ); + } + + // Instance methods + getFeatures = ( + useComponentOverrides: () => GenericComponentOverrideMap = useRecipeComponentOverrideContext + ): RecipeFeatureComponentMap => { + const features: RecipeFeatureComponentMap = {}; + if (this.recipeInstance.config.mfaTOTPScreen.disableDefaultUI !== true) { + const normalisedFullPath = this.recipeInstance.config.appInfo.websiteBasePath.appendPath( + new NormalisedURLPath(DEFAULT_TOTP_PATH) + ); + features[normalisedFullPath.getAsStringDangerous()] = { + matches: matchRecipeIdUsingQueryParams(this.recipeInstance.config.recipeId), + component: (props: any) => this.getFeatureComponent("mfaTOTP", props, useComponentOverrides), + recipeID: MultiFactorAuthRecipe.RECIPE_ID, + }; + } + return features; + }; + getFeatureComponent = ( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _: "mfaTOTP", + props: any, + useComponentOverrides: () => GenericComponentOverrideMap = useRecipeComponentOverrideContext + ): JSX.Element => { + return ( + + []}> + + + + ); + }; + + // For tests + static reset(): void { + if (!isTest()) { + return; + } + + MultiFactorAuthPreBuiltUI.instance = undefined; + return; + } + + static MFATOTP = (props?: any): JSX.Element => + MultiFactorAuthPreBuiltUI.getInstanceOrInitAndGetInstance().getFeatureComponent("mfaTOTP", props); + static MFATOTPTheme = MFATOTPTheme; +} + +const MFATOTP = MultiFactorAuthPreBuiltUI.MFATOTP; + +export { MFATOTP, MFATOTPTheme }; diff --git a/lib/ts/recipe/totp/recipe.tsx b/lib/ts/recipe/totp/recipe.tsx new file mode 100644 index 000000000..52041b844 --- /dev/null +++ b/lib/ts/recipe/totp/recipe.tsx @@ -0,0 +1,121 @@ +/* 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. + */ + +/* + * Imports. + */ + +import TOTPWebJS from "supertokens-web-js/recipe/totp"; +import NormalisedURLPath from "supertokens-web-js/utils/normalisedURLPath"; + +import { SSR_ERROR } from "../../constants"; +import RecipeModule from "../recipeModule"; + +import { DEFAULT_TOTP_PATH } from "./constants"; +import { getFunctionOverrides } from "./functionOverrides"; +import { normaliseMultiFactorAuthFeature } from "./utils"; + +import type { + UserInput, + NormalisedConfig, + GetRedirectionURLContext, + OnHandleEventContext, + PreAndPostAPIHookAction, +} from "./types"; +import type { NormalisedConfigWithAppInfoAndRecipeID, RecipeInitResult, WebJSRecipeInterface } from "../../types"; +import type { NormalisedAppInfo } from "../../types"; + +export default class TOTP extends RecipeModule< + GetRedirectionURLContext, + PreAndPostAPIHookAction, + OnHandleEventContext, + NormalisedConfig +> { + static instance?: TOTP; + static RECIPE_ID = "totp"; + + public recipeID = TOTP.RECIPE_ID; + + constructor( + config: NormalisedConfigWithAppInfoAndRecipeID, + public readonly webJSRecipe: WebJSRecipeInterface = TOTPWebJS + ) { + super(config); + } + + static init( + config?: UserInput + ): RecipeInitResult { + const normalisedConfig = normaliseMultiFactorAuthFeature(config); + + return { + recipeID: TOTP.RECIPE_ID, + authReact: ( + appInfo: NormalisedAppInfo + ): RecipeModule< + GetRedirectionURLContext, + PreAndPostAPIHookAction, + OnHandleEventContext, + NormalisedConfig + > => { + TOTP.instance = new TOTP({ + ...normalisedConfig, + appInfo, + recipeId: TOTP.RECIPE_ID, + }); + return TOTP.instance; + }, + webJS: TOTPWebJS.init({ + ...normalisedConfig, + override: { + functions: (originalImpl, builder) => { + const functions = getFunctionOverrides(normalisedConfig.onHandleEvent); + builder.override(functions); + builder.override(normalisedConfig.override.functions); + return originalImpl; + }, + }, + }), + }; + } + + static getInstance(): TOTP | undefined { + return TOTP.instance; + } + + static getInstanceOrThrow(): TOTP { + if (TOTP.instance === undefined) { + let error = "No instance of EmailVerification found. Make sure to call the EmailVerification.init method."; + + // eslint-disable-next-line supertokens-auth-react/no-direct-window-object + if (typeof window === "undefined") { + error = error + SSR_ERROR; + } + throw Error(error); + } + + return TOTP.instance; + } + + getDefaultRedirectionURL = async (context: GetRedirectionURLContext): Promise => { + if (context.action === "MFA_TOTP") { + const chooserPath = new NormalisedURLPath(DEFAULT_TOTP_PATH); + return `${this.config.appInfo.websiteBasePath.appendPath(chooserPath).getAsStringDangerous()}`; + } + { + return "/"; + } + }; +} diff --git a/lib/ts/recipe/totp/types.ts b/lib/ts/recipe/totp/types.ts new file mode 100644 index 000000000..1a08952cf --- /dev/null +++ b/lib/ts/recipe/totp/types.ts @@ -0,0 +1,84 @@ +/* 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 { ComponentOverride } from "../../components/componentOverride/componentOverride"; +import type { + Config as RecipeModuleConfig, + NormalisedConfig as NormalisedRecipeModuleConfig, + UserInput as RecipeModuleUserInput, +} from "../recipeModule/types"; +import type { OverrideableBuilder } from "supertokens-js-override"; +import type { RecipeInterface } from "supertokens-web-js/recipe/totp"; + +export type ComponentOverrideMap = { + FactorChooser_Override?: ComponentOverride; // TODO +}; + +export type TOTPScreenConfig = { + disableDefaultUI: boolean; + setupScreenStyle: string; + verificationScreenStyle: string; + blockedScreenStyle: string; // TODO: ?? +}; + +// Config is what does in the constructor of the recipe. +export type UserInput = { + mfaTOTPScreen?: Partial; + + override?: { + functions?: ( + originalImplementation: RecipeInterface, + builder?: OverrideableBuilder + ) => RecipeInterface; + }; +} & RecipeModuleUserInput; + +// Config is what does in the constructor of the recipe. +export type Config = UserInput & + RecipeModuleConfig; + +export type NormalisedConfig = { + mfaTOTPScreen: TOTPScreenConfig; + + override: { + functions: ( + originalImplementation: RecipeInterface, + builder?: OverrideableBuilder + ) => RecipeInterface; + }; +} & NormalisedRecipeModuleConfig; + +export type GetRedirectionURLContext = { + action: "MFA_TOTP"; +}; + +export type PreAndPostAPIHookAction = + | "CREATE_DEVICE" + | "VERIFY_CODE" + | "VERIFY_DEVICE" + | "REMOVE_DEVICE" + | "LIST_DEVICES"; + +export type PreAPIHookContext = { + action: PreAndPostAPIHookAction; + requestInit: RequestInit; + url: string; + userContext: any; +}; + +export type OnHandleEventContext = { + action: "FACTOR_CHOSEN"; + userContext: any; +}; diff --git a/lib/ts/recipe/totp/utils.ts b/lib/ts/recipe/totp/utils.ts new file mode 100644 index 000000000..9f9d12af5 --- /dev/null +++ b/lib/ts/recipe/totp/utils.ts @@ -0,0 +1,42 @@ +/* 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 { normaliseRecipeModuleConfig } from "../recipeModule/utils"; + +import type { Config, NormalisedConfig } from "./types"; +import type { RecipeInterface } from "supertokens-web-js/recipe/totp"; + +export function normaliseMultiFactorAuthFeature(config?: Config): NormalisedConfig { + if (config === undefined) { + config = {}; + } + + const override = { + functions: (originalImplementation: RecipeInterface) => originalImplementation, + ...config.override, + }; + + return { + ...normaliseRecipeModuleConfig(config), + mfaTOTPScreen: { + disableDefaultUI: false, + blockedScreenStyle: "", + setupScreenStyle: "", + verificationScreenStyle: "", + ...config.mfaTOTPScreen, + }, + override, + }; +}