Skip to content

Commit b83a40f

Browse files
committed
feat: Add support for webauthn in MFA
Update build Add paths Add component Add sign in Add forms
1 parent 92c7c1e commit b83a40f

File tree

13 files changed

+4258
-3137
lines changed

13 files changed

+4258
-3137
lines changed

lib/ts/components/assets/passkeyIcon.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,10 @@ export default function PasskeyIcon(): JSX.Element {
1717
return (
1818
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
1919
<path
20-
fill-rule="evenodd"
21-
clip-rule="evenodd"
20+
fillRule="evenodd"
21+
clipRule="evenodd"
2222
d="M5.23974 0.00426122C5.20971 0.00917398 5.11635 0.023531 5.03227 0.0361517C4.26872 0.150797 3.53667 0.527026 2.99234 1.08462C1.7778 2.32876 1.67594 4.23877 2.75137 5.60384C3.47855 6.52688 4.6728 7.04702 5.85871 6.9572C6.17441 6.93329 6.23003 6.92444 6.5173 6.85235C7.49851 6.60612 8.30571 5.98307 8.79333 5.09562C9.19012 4.37346 9.30287 3.48404 9.1018 2.66238C8.85299 1.64555 8.10397 0.756337 7.12878 0.320054C6.63453 0.0989376 6.24276 0.0133032 5.67651 0.00256712C5.46631 -0.00143509 5.26976 -0.000672722 5.23974 0.00426122ZM10.6557 5.27343C10.0527 5.37376 9.55624 5.62311 9.14615 6.03156C8.75205 6.42412 8.50113 6.87791 8.36981 7.43553C8.33814 7.56998 8.33022 7.69015 8.33135 8.01787C8.33214 8.24497 8.34291 8.46891 8.35529 8.51549L8.37778 8.6002L7.45649 9.50016C6.69978 10.2393 6.51909 10.4271 6.44506 10.5509C6.14071 11.0599 6.15283 11.626 6.47843 12.1093C6.65032 12.3645 6.88539 12.5333 7.22706 12.6471C7.41015 12.7081 7.882 12.7037 8.08645 12.6391C8.4564 12.5223 8.46699 12.5139 9.49199 11.5255L10.4357 10.6155L10.6931 10.6508C11.2021 10.7205 11.6431 10.6746 12.1298 10.5012C12.6661 10.3101 13.0696 10.0101 13.4499 9.51964C13.7248 9.16523 13.8805 8.83599 13.9642 8.43238C14.0168 8.17874 14.0105 7.6749 13.9514 7.41044C13.7089 6.3251 12.8932 5.543 11.7477 5.29734C11.5289 5.25043 10.8794 5.2362 10.6557 5.27343ZM11.3837 7.50376C11.6039 7.58029 11.7631 7.85197 11.707 8.05537C11.6436 8.28527 11.4241 8.46336 11.2057 8.46209C11.1131 8.46156 10.9524 8.39644 10.877 8.32889C10.6068 8.08705 10.6618 7.67956 10.9856 7.5231C11.1211 7.45762 11.235 7.45209 11.3837 7.50376ZM4.91216 7.91218C4.22601 8.00179 3.70343 8.15612 3.11079 8.44415C1.63725 9.16034 0.531334 10.5122 0.15953 12.0518C0.0539832 12.4889 -0.0159007 13.1132 0.0031208 13.4491C0.0172068 13.6976 0.126772 13.8739 0.322949 13.9636C0.397419 13.9977 0.734237 14 5.63283 14H10.8632L10.952 13.9539C11.078 13.8885 11.1898 13.7597 11.2349 13.6282C11.2674 13.5334 11.2714 13.4709 11.2602 13.2388C11.239 12.8009 11.1878 12.4787 11.0683 12.0307C11.0094 11.8095 10.9775 11.7616 10.895 11.77C10.8442 11.7752 10.6791 11.9233 10.0829 12.4983C9.35956 13.1959 9.19625 13.338 8.96323 13.4727C8.39003 13.8042 7.595 13.8892 6.92132 13.6911C6.71145 13.6294 6.36331 13.458 6.19271 13.3324C5.60763 12.9016 5.24956 12.2772 5.19726 11.5966C5.15424 11.0364 5.27535 10.5421 5.56898 10.0796C5.67022 9.92014 5.80553 9.77737 6.50092 9.0963C7.2706 8.34249 7.31441 8.29544 7.31441 8.22274C7.31441 8.12885 7.27373 8.1002 7.07419 8.05359C6.30527 7.874 5.57177 7.82603 4.91216 7.91218Z"
23-
fill="white"
23+
fill="currentColor"
2424
/>
2525
</svg>
2626
);

lib/ts/recipe/multifactorauth/components/themes/translations.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ export const defaultTranslationsMultiFactorAuth = {
1616
TOTP_MFA_NAME: "TOTP",
1717
TOTP_MFA_DESCRIPTION: "Use an authenticator app to complete the authentication request",
1818

19+
WEBAUTHN_MFA_NAME: "Passkeys",
20+
WEBAUTHN_MFA_DESCRIPTION: "Use a passkey to complete the authentication request",
21+
1922
MFA_NO_AVAILABLE_OPTIONS: "You have no available secondary factors.",
2023
MFA_NO_AVAILABLE_OPTIONS_LOGIN:
2124
"You have no available secondary factors and cannot complete the sign-in process. Please contact support.",
Lines changed: 375 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,375 @@
1+
/* Copyright (c) 2024, VRAI Labs and/or its affiliates. All rights reserved.
2+
*
3+
* This software is licensed under the Apache License, Version 2.0 (the
4+
* "License") as published by the Apache Software Foundation.
5+
*
6+
* You may not use this file except in compliance with the License. You may
7+
* obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
8+
*
9+
* Unless required by applicable law or agreed to in writing, software
10+
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11+
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12+
* License for the specific language governing permissions and limitations
13+
* under the License.
14+
*/
15+
/*
16+
* Imports.
17+
*/
18+
import * as React from "react";
19+
import { Fragment } from "react";
20+
import { WindowHandlerReference } from "supertokens-web-js/utils/windowHandler";
21+
22+
import { ComponentOverrideContext } from "../../../../../components/componentOverride/componentOverrideContext";
23+
import FeatureWrapper from "../../../../../components/featureWrapper";
24+
import SuperTokens from "../../../../../superTokens";
25+
import { redirectToAuth } from "../../../../..";
26+
import SessionRecipe from "../../../../session/recipe";
27+
import { getAvailableFactors } from "../../../../multifactorauth/utils";
28+
import { useUserContext } from "../../../../../usercontext";
29+
import { defaultTranslationsWebauthn } from "../../themes/translations";
30+
import { FactorIds } from "../../../../multifactorauth/types";
31+
import MFAThemeWrapper from "../../themes/mfa";
32+
import MultiFactorAuth from "../../../../multifactorauth/recipe";
33+
import type { FieldState } from "../../../../emailpassword/components/library/formBase";
34+
import type { APIFormField } from "../../../../../types";
35+
import {
36+
getQueryParams,
37+
getRedirectToPathFromURL,
38+
useOnMountAPICall,
39+
handleCallAPI,
40+
useRethrowInRender,
41+
} from "../../../../../utils";
42+
43+
import type { FeatureBaseProps, UserContext, Navigate } from "../../../../../types";
44+
import type Recipe from "../../../recipe";
45+
import type { ComponentOverrideMap, WebAuthnMFAAction, WebAuthnMFAProps, WebAuthnMFAState } from "../../../types";
46+
import type { RecipeInterface } from "supertokens-web-js/recipe/webauthn";
47+
48+
export const useFeatureReducer = (): [WebAuthnMFAState, React.Dispatch<WebAuthnMFAAction>] => {
49+
return React.useReducer(
50+
(oldState: WebAuthnMFAState, action: WebAuthnMFAAction): WebAuthnMFAState => {
51+
switch (action.type) {
52+
case "setError":
53+
return {
54+
...oldState,
55+
error: action.error,
56+
accessDenied: action.accessDenied || false,
57+
};
58+
case "load":
59+
return {
60+
...oldState,
61+
loaded: true,
62+
deviceSupported: action.deviceSupported,
63+
email: action.email,
64+
showBackButton: action.showBackButton,
65+
};
66+
default:
67+
return oldState;
68+
}
69+
},
70+
{
71+
error: undefined,
72+
deviceSupported: false,
73+
loaded: false,
74+
showBackButton: true,
75+
email: "",
76+
accessDenied: false,
77+
}
78+
);
79+
};
80+
81+
export function useChildProps(
82+
recipe: Recipe,
83+
recipeImplementation: RecipeInterface,
84+
state: WebAuthnMFAState,
85+
dispatch: React.Dispatch<WebAuthnMFAAction>,
86+
userContext: UserContext,
87+
navigate?: Navigate
88+
): Omit<WebAuthnMFAProps, "featureState" | "dispatch"> {
89+
const rethrowInRender = useRethrowInRender();
90+
const callSignInAPI = React.useCallback(
91+
async (_: APIFormField[], __: (id: string, value: string) => any) => {
92+
const response = await recipeImplementation.authenticateCredentialWithSignIn({
93+
shouldTryLinkingWithSessionUser: true,
94+
userContext,
95+
});
96+
97+
switch (response.status) {
98+
case "INVALID_CREDENTIALS_ERROR":
99+
dispatch({ type: "setError", error: "WEBAUTHN_PASSKEY_INVALID_CREDENTIALS_ERROR" });
100+
break;
101+
case "FAILED_TO_AUTHENTICATE_USER":
102+
case "INVALID_OPTIONS_ERROR":
103+
dispatch({ type: "setError", error: "WEBAUTHN_PASSKEY_RECOVERABLE_ERROR" });
104+
break;
105+
case "WEBAUTHN_NOT_SUPPORTED":
106+
dispatch({ type: "setError", error: "WEBAUTHN_NOT_SUPPORTED_ERROR" });
107+
break;
108+
}
109+
110+
return response;
111+
},
112+
[recipeImplementation, userContext]
113+
);
114+
115+
const callSignUpAPI = React.useCallback(
116+
async (email: string, _: APIFormField[], __: (id: string, value: string) => any) => {
117+
const response = await recipeImplementation.registerCredentialWithSignUp({
118+
email,
119+
shouldTryLinkingWithSessionUser: true,
120+
userContext,
121+
});
122+
123+
if (response.status !== "OK") {
124+
dispatch({ type: "setError", error: "WEBAUTHN_PASSKEY_RECOVERABLE_ERROR" });
125+
}
126+
127+
if (response.status === "EMAIL_ALREADY_EXISTS_ERROR") {
128+
dispatch({ type: "setError", error: "WEBAUTHN_EMAIL_ALREADY_EXISTS_ERROR" });
129+
}
130+
131+
if (response.status === "WEBAUTHN_NOT_SUPPORTED") {
132+
dispatch({ type: "setError", error: "WEBAUTHN_NOT_SUPPORTED_ERROR" });
133+
}
134+
135+
return response;
136+
},
137+
[state]
138+
);
139+
140+
const onSuccess = React.useCallback(() => {
141+
const redirectToPath = getRedirectToPathFromURL();
142+
143+
return SessionRecipe.getInstanceOrThrow()
144+
.validateGlobalClaimsAndHandleSuccessRedirection(
145+
undefined,
146+
recipe.recipeID,
147+
redirectToPath,
148+
userContext,
149+
navigate
150+
)
151+
.catch(rethrowInRender);
152+
}, [recipe, userContext, navigate]);
153+
154+
return React.useMemo(() => {
155+
return {
156+
onSignIn: async () => {
157+
const fieldUpdates: FieldState[] = [];
158+
try {
159+
const { result, generalError, fetchError } = await handleCallAPI<any>({
160+
apiFields: [],
161+
fieldUpdates,
162+
callAPI: callSignInAPI,
163+
});
164+
165+
if (generalError !== undefined) {
166+
dispatch({ type: "setError", error: generalError.message });
167+
} else if (fetchError !== undefined) {
168+
dispatch({ type: "setError", error: "Failed to fetch from upstream" });
169+
} else if (result.status === "OK") {
170+
dispatch({ type: "setError", error: undefined });
171+
onSuccess();
172+
}
173+
} catch (e) {
174+
dispatch({ type: "setError", error: "SOMETHING_WENT_WRONG_ERROR" });
175+
}
176+
},
177+
onSignUp: async (email: string) => {
178+
const fieldUpdates: FieldState[] = [];
179+
180+
try {
181+
const { result, generalError, fetchError } = await handleCallAPI<any>({
182+
apiFields: [],
183+
fieldUpdates,
184+
callAPI: (...params) => callSignUpAPI(email, ...params),
185+
});
186+
187+
if (generalError !== undefined) {
188+
dispatch({ type: "setError", error: generalError.message });
189+
} else if (fetchError !== undefined) {
190+
dispatch({ type: "setError", error: "WEBAUTHN_PASSKEY_RECOVERABLE_ERROR" });
191+
} else if (result?.status === "OK") {
192+
dispatch({ type: "setError", error: undefined });
193+
onSuccess();
194+
}
195+
} catch (e) {
196+
dispatch({ type: "setError", error: "SOMETHING_WENT_WRONG_ERROR" });
197+
console.error("error", e);
198+
}
199+
},
200+
onSignOutClicked: async () => {
201+
await SessionRecipe.getInstanceOrThrow().signOut({ userContext });
202+
await redirectToAuth({ redirectBack: false, navigate: navigate });
203+
},
204+
onBackButtonClicked: async () => {
205+
// If we don't have navigate available this would mean we are using react-router-dom, so we use window's history
206+
if (navigate === undefined) {
207+
return WindowHandlerReference.getReferenceOrThrow().windowHandler.getWindowUnsafe().history.back();
208+
}
209+
// If we do have navigate and goBack function on it this means we are using react-router-dom v5 or lower
210+
if ("goBack" in navigate) {
211+
return navigate.goBack();
212+
}
213+
// If we reach this code this means we are using react-router-dom v6
214+
return navigate(-1);
215+
},
216+
onRecoverAccountClick: () => {
217+
recipe.redirect(
218+
{ action: "SEND_RECOVERY_EMAIL", tenantIdFromQueryParams: "" },
219+
navigate,
220+
{},
221+
userContext
222+
);
223+
},
224+
recipeImplementation: recipeImplementation,
225+
config: recipe.config,
226+
};
227+
}, [recipeImplementation, state, recipe, userContext, navigate]);
228+
}
229+
230+
export const MFAFeature: React.FC<
231+
FeatureBaseProps<{
232+
recipe: Recipe;
233+
useComponentOverrides: () => ComponentOverrideMap;
234+
}>
235+
> = (props) => {
236+
const recipeComponentOverrides = props.useComponentOverrides();
237+
238+
return (
239+
<ComponentOverrideContext.Provider value={recipeComponentOverrides}>
240+
<FeatureWrapper
241+
useShadowDom={SuperTokens.getInstanceOrThrow().useShadowDom}
242+
defaultStore={defaultTranslationsWebauthn}>
243+
<MFAFeatureInner {...props} />
244+
</FeatureWrapper>
245+
</ComponentOverrideContext.Provider>
246+
);
247+
};
248+
249+
export default MFAFeature;
250+
251+
const MFAFeatureInner: React.FC<
252+
FeatureBaseProps<{
253+
recipe: Recipe;
254+
useComponentOverrides: () => ComponentOverrideMap;
255+
}>
256+
> = (props) => {
257+
const userContext = useUserContext();
258+
const [state, dispatch] = useFeatureReducer();
259+
260+
const childProps = useChildProps(
261+
props.recipe,
262+
props.recipe.webJSRecipe as RecipeInterface,
263+
state,
264+
dispatch,
265+
userContext,
266+
props.navigate
267+
)!;
268+
269+
useOnLoad(props, props.recipe.webJSRecipe as RecipeInterface, dispatch, userContext);
270+
271+
return (
272+
<Fragment>
273+
{/* No custom theme, use default. */}
274+
{props.children === undefined && (
275+
<MFAThemeWrapper {...childProps} featureState={state} dispatch={dispatch} />
276+
)}
277+
278+
{/* Otherwise, custom theme is provided, propagate props. */}
279+
{props.children &&
280+
React.Children.map(props.children, (child) => {
281+
if (React.isValidElement(child)) {
282+
return React.cloneElement(child, {
283+
...childProps,
284+
featureState: state,
285+
dispatch: dispatch,
286+
});
287+
}
288+
return child;
289+
})}
290+
</Fragment>
291+
);
292+
};
293+
294+
function useOnLoad(
295+
props: React.PropsWithChildren<
296+
{ navigate?: Navigate } & { children?: React.ReactNode } & {
297+
recipe: Recipe;
298+
useComponentOverrides: () => ComponentOverrideMap;
299+
}
300+
>,
301+
recipeImplementation: RecipeInterface,
302+
dispatch: React.Dispatch<WebAuthnMFAAction>,
303+
userContext: UserContext
304+
) {
305+
const fetchMFAInfo = React.useCallback(
306+
async () => MultiFactorAuth.getInstanceOrThrow().webJSRecipe.resyncSessionAndFetchMFAInfo({ userContext }),
307+
[userContext]
308+
);
309+
310+
const handleLoadError = React.useCallback(
311+
() => dispatch({ type: "setError", accessDenied: true, error: "SOMETHING_WENT_WRONG_ERROR_RELOAD" }),
312+
[dispatch]
313+
);
314+
315+
const onLoad = React.useCallback(
316+
async (mfaInfo: Awaited<ReturnType<typeof fetchMFAInfo>>) => {
317+
console.log("onLoad", mfaInfo);
318+
let error: string | undefined = undefined;
319+
const errorQueryParam = getQueryParams("error");
320+
const doSetup = getQueryParams("setup");
321+
const stepUp = getQueryParams("stepUp");
322+
323+
if (errorQueryParam !== null) {
324+
error = "SOMETHING_WENT_WRONG_ERROR";
325+
}
326+
327+
if (mfaInfo.factors.next.length === 0 && stepUp !== "true" && doSetup !== "true") {
328+
const redirectToPath = getRedirectToPathFromURL();
329+
try {
330+
await SessionRecipe.getInstanceOrThrow().validateGlobalClaimsAndHandleSuccessRedirection(
331+
undefined,
332+
props.recipe.recipeID,
333+
redirectToPath,
334+
userContext,
335+
props.navigate
336+
);
337+
} catch {
338+
// If we couldn't redirect to EV (or an unknown claim validation failed or somehow the redirection threw an error)
339+
// we fall back to showing the something went wrong error
340+
dispatch({
341+
type: "setError",
342+
accessDenied: true,
343+
error: "SOMETHING_WENT_WRONG_ERROR_RELOAD",
344+
});
345+
}
346+
}
347+
348+
// If the next array only has a single option, it means the we were redirected here
349+
// automatically during the sign in process. In that case, anywhere the back button
350+
// could go would redirect back here, making it useless.
351+
const showBackButton =
352+
mfaInfo.factors.next.length === 0 ||
353+
getAvailableFactors(mfaInfo.factors, undefined, MultiFactorAuth.getInstanceOrThrow(), userContext)
354+
.length !== 1;
355+
356+
const mfaInfoEmails = mfaInfo.emails[FactorIds.WEBAUTHN];
357+
const email = mfaInfoEmails ? mfaInfoEmails[0] : undefined;
358+
359+
const browserSupportsWebauthn = await props.recipe.webJSRecipe.doesBrowserSupportWebAuthn({
360+
userContext: userContext,
361+
});
362+
363+
dispatch({
364+
type: "load",
365+
error,
366+
showBackButton,
367+
email,
368+
deviceSupported: browserSupportsWebauthn.status === "OK",
369+
});
370+
},
371+
[dispatch, recipeImplementation, props.recipe, userContext]
372+
);
373+
374+
useOnMountAPICall(fetchMFAInfo, onLoad, handleLoadError);
375+
}

0 commit comments

Comments
 (0)