/** * Copyright Amazon.com, Inc. and its affiliates. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"). You * may not use this file except in compliance with the License. A copy of * the License is located at * * http://aws.amazon.com/apache2.0/ * * or in the "license" file accompanying this file. This file 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 { signOut } from "../common.js"; import { parseJwtPayload, setTimeoutWallClock } from "../util.js"; import { signInWithLink, requestSignInLink } from "../magic-link.js"; import { fido2CreateCredential, fido2DeleteCredential, fido2ListCredentials, fido2UpdateCredential, StoredCredential, authenticateWithFido2, } from "../fido2.js"; import { authenticateWithSRP } from "../srp.js"; import { authenticateWithPlaintextPassword } from "../plaintext.js"; import { stepUpAuthenticationWithSmsOtp } from "../sms-otp-stepup.js"; import { configure } from "../config.js"; import { retrieveTokens, storeTokens, TokensFromStorage } from "../storage.js"; import { BusyState, IdleState, busyState } from "../model.js"; import { scheduleRefresh, refreshTokens } from "../refresh.js"; import { CognitoAccessTokenPayload, CognitoIdTokenPayload, } from "../jwt-model.js"; import React, { useState, useEffect, useContext, useCallback, useMemo, useRef, } from "react"; const PasswordlessContext = React.createContext( undefined ); /** React hook that provides convenient access to the Passwordless lib's features */ export function usePasswordless() { const context = useContext(PasswordlessContext); if (!context) { throw new Error( "The PasswordlessContextProvider must be added above this consumer in the React component tree" ); } return context; } const LocalUserCacheContext = React.createContext< UseLocalUserCache | undefined >(undefined); /** React hook that stores and gives access to the last 10 signed in users (from your configured storage) */ export function useLocalUserCache() { const context = useContext(LocalUserCacheContext); if (!context) { throw new Error( "The localUserCache must be enabled in the PasswordlessContextProvider: " ); } return context; } export const PasswordlessContextProvider = (props: { children: React.ReactNode; enableLocalUserCache?: boolean; }) => { return ( {props.enableLocalUserCache ? ( {props.children} ) : ( props.children )} ); }; const LocalUserCacheContextProvider = (props: { children: React.ReactNode; }) => { return ( {props.children} ); }; /** A FIDO2 credential (e.g. Face ID or Touch), with convenient methods for updating and deleting */ type Fido2Credential = StoredCredential & { /** Update the friendly name of the credential */ update: (update: { friendlyName: string }) => Promise; /** Delete the credential */ delete: () => Promise; /** The credential is currently being updated or deleted */ busy: boolean; }; type UsePasswordless = ReturnType; function _usePasswordless() { const [signingInStatus, setSigninInStatus] = useState( "CHECKING_FOR_SIGNIN_LINK" ); const [ initiallyRetrievingTokensFromStorage, setInitiallyRetrievingTokensFromStorage, ] = useState(true); const [tokens, _setTokens] = useState(); const [tokensParsed, setTokensParsed] = useState<{ idToken: CognitoIdTokenPayload; accessToken: CognitoAccessTokenPayload; expireAt: Date; }>(); const setTokens: typeof _setTokens = useCallback((reactSetStateAction) => { _setTokens((prevState) => { const newTokens = typeof reactSetStateAction === "function" ? reactSetStateAction(prevState) : reactSetStateAction; const { idToken, accessToken, expireAt } = newTokens ?? {}; if (idToken && accessToken && expireAt) { setTokensParsed({ idToken: parseJwtPayload(idToken), accessToken: parseJwtPayload(accessToken), expireAt, }); } else { setTokensParsed(undefined); } return newTokens; }); }, []); const [lastError, setLastError] = useState(); const [ userVerifyingPlatformAuthenticatorAvailable, setUserVerifyingPlatformAuthenticatorAvailable, ] = useState(); const [creatingCredential, setCreatingCredential] = useState(false); const [fido2Credentials, setFido2Credentials] = useState(); const updateFido2Credential = useCallback( (update: { credentialId: string } & Partial) => setFido2Credentials((state) => { if (!state) return state; const index = state.findIndex( (i) => i.credentialId === update.credentialId ); if (index === -1) return state; // eslint-disable-next-line security/detect-object-injection state[index] = { ...state[index], ...update }; return [...state]; }), [] ); const deleteFido2Credential = useCallback( (credentialId: string) => setFido2Credentials((state) => state?.filter( (remainingAuthenticator) => credentialId !== remainingAuthenticator.credentialId ) ), [] ); const [isSchedulingRefresh, setIsSchedulingRefresh] = useState(); const [isRefreshingTokens, setIsRefreshingTokens] = useState(); const [showAuthenticatorManager, setShowAuthenticatorManager] = useState(false); const [recheckSignInStatus, setRecheckSignInStatus] = useState(0); // At component mount, attempt sign-in with link // This is a no-op, if there's no secret hash in the location bar useEffect(() => { setLastError(undefined); const signingIn = signInWithLink({ statusCb: setSigninInStatus, tokensCb: (tokens) => storeTokens(tokens).then(() => setTokens(tokens)), }); signingIn.signedIn.catch(setLastError); return signingIn.abort; }, [setTokens]); const busy = busyState.includes(signingInStatus as BusyState); // Schedule token refresh const refreshToken = tokens?.refreshToken; const expireAtTime = tokens?.expireAt?.getTime(); useEffect(() => { if (refreshToken) { const abort = new AbortController(); scheduleRefresh({ abort: abort.signal, tokensCb: (newTokens) => newTokens && storeTokens(newTokens).then(() => setTokens((tokens) => ({ ...tokens, ...newTokens })) ), isRefreshingCb: setIsRefreshingTokens, }).finally(() => setIsSchedulingRefresh(false)); return () => abort.abort(); } }, [setTokens, refreshToken, expireAtTime]); // If we have some tokens, but not all, attempt a refresh // (looks like e.g. a developer deleted some keys from storage) if ( tokens && (!tokens.idToken || !tokens.accessToken || !tokens.expireAt) && !isRefreshingTokens && !isSchedulingRefresh ) { refreshTokens({ tokensCb: (newTokens) => newTokens && storeTokens(newTokens).then(() => setTokens((tokens) => ({ ...tokens, ...newTokens })) ), isRefreshingCb: setIsRefreshingTokens, }).catch(() => { setTokens(undefined); }); } // At component mount, load tokens from storage useEffect(() => { retrieveTokens() .then(setTokens) .finally(() => setInitiallyRetrievingTokensFromStorage(false)); }, [setTokens]); // Give easy access to isUserVerifyingPlatformAuthenticatorAvailable useEffect(() => { if (window.PublicKeyCredential) { const cancel = new AbortController(); window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable() .then((res) => { if (!cancel.signal.aborted) { setUserVerifyingPlatformAuthenticatorAvailable(res); } return () => cancel.abort(); }) .catch((err) => { const { debug } = configure(); debug?.( "Failed to determine if a user verifying platform authenticator is available:", err ); }); } else { setUserVerifyingPlatformAuthenticatorAvailable(false); } }, []); const toFido2Credential = useCallback( (credential: StoredCredential) => { return { ...credential, busy: false, update: async (update: { friendlyName: string }) => { updateFido2Credential({ credentialId: credential.credentialId, busy: true, }); return fido2UpdateCredential({ ...update, credentialId: credential.credentialId, }) .catch((err) => { updateFido2Credential({ credentialId: credential.credentialId, busy: false, }); throw err; }) .then(() => updateFido2Credential({ ...update, credentialId: credential.credentialId, busy: false, }) ); }, delete: async () => { updateFido2Credential({ credentialId: credential.credentialId, busy: true, }); return fido2DeleteCredential({ credentialId: credential.credentialId, }) .catch((err) => { updateFido2Credential({ credentialId: credential.credentialId, busy: false, }); throw err; }) .then(() => deleteFido2Credential(credential.credentialId)); }, }; }, [deleteFido2Credential, updateFido2Credential] ); // Determine sign-in status const signInStatus = useMemo(() => { recheckSignInStatus; // dummy usage otherwise eslint complains we should remove it from the dep array return tokensParsed && tokensParsed.expireAt.valueOf() >= Date.now() ? ("SIGNED_IN" as const) : tokensParsed && (isSchedulingRefresh || isRefreshingTokens) ? ("REFRESHING_SIGN_IN" as const) : busyState .filter( (state) => !["SIGNING_OUT", "CHECKING_FOR_SIGNIN_LINK"].includes(state) ) .includes(signingInStatus as BusyState) ? ("SIGNING_IN" as const) : initiallyRetrievingTokensFromStorage || signingInStatus === "CHECKING_FOR_SIGNIN_LINK" ? ("CHECKING" as const) : signingInStatus === "SIGNING_OUT" ? ("SIGNING_OUT" as const) : ("NOT_SIGNED_IN" as const); }, [ tokensParsed, isSchedulingRefresh, isRefreshingTokens, signingInStatus, initiallyRetrievingTokensFromStorage, recheckSignInStatus, // if this increments we should redetermine the signInStatus ]); // Check signInStatus upon token expiry useEffect(() => { if (!tokens?.expireAt) return; const checkIn = tokens.expireAt.valueOf() - Date.now(); if (checkIn < 0) return; return setTimeoutWallClock(() => { const { debug } = configure(); debug?.( "Checking signInStatus as tokens have expired at:", tokens.expireAt?.toISOString() ); setRecheckSignInStatus((s) => s + 1); }, checkIn); }, [tokens?.expireAt]); // Track FIDO2 authenticators for the user const isSignedIn = signInStatus === "SIGNED_IN"; const revalidateFido2Credentials = () => { const cancel = new AbortController(); if (isSignedIn) { fido2ListCredentials() .then((res) => { if (!cancel.signal.aborted) { setFido2Credentials(res.authenticators.map(toFido2Credential)); } }) .catch((err) => { const { debug } = configure(); debug?.("Failed to list credentials:", err); }); return () => cancel.abort(); } }; useEffect(revalidateFido2Credentials, [isSignedIn, toFido2Credential]); return { /** The (raw) tokens: ID token, Access token and Refresh Token */ tokens, /** The JSON parsed ID and Access token */ tokensParsed, /** Is the UI currently refreshing tokens? */ isRefreshingTokens, /** Execute (and reschedule) token refresh */ refreshTokens: (abort?: AbortSignal) => refreshTokens({ abort, tokensCb: (newTokens) => newTokens && storeTokens(newTokens).then(() => setTokens((tokens) => ({ ...tokens, ...newTokens })) ), isRefreshingCb: setIsRefreshingTokens, }), /** Last error that occured */ lastError, /** The status of the most recent sign-in attempt */ signingInStatus, /** Are we currently busy signing in or out? */ busy, /** * The overall auth status, e.g. is the user signed in or not? * Use this field to show the relevant UI, e.g. render a sign-in page, * if the status equals "NOT_SIGNED_IN" */ signInStatus, /** Is a user verifying platform authenticator available? E.g. Face ID or Touch */ userVerifyingPlatformAuthenticatorAvailable, /** The user's registered FIDO2 credentials. Each credential provides `update` and `delete` methods */ fido2Credentials, /** Are we currently creating a FIDO2 credential? */ creatingCredential, /** Register a FIDO2 credential with the Relying Party */ fido2CreateCredential: ( ...args: Parameters ) => { setCreatingCredential(true); return fido2CreateCredential(...args) .then((storedCredential) => { setFido2Credentials((state) => { const credential = toFido2Credential(storedCredential); return state ? state.concat([credential]) : [credential]; }); return storedCredential; }) .finally(() => setCreatingCredential(false)); }, /** Sign out */ signOut: () => { setLastError(undefined); const signingOut = signOut({ statusCb: setSigninInStatus, tokensRemovedLocallyCb: () => { setTokens(undefined); setTokensParsed(undefined); setFido2Credentials(undefined); }, currentStatus: signingInStatus, }); signingOut.signedOut.catch(setLastError); return signingOut; }, /** Request a sign-in link ("magic link") to be sent to the user's e-mail address */ requestSignInLink: (username: string) => { setLastError(undefined); const requesting = requestSignInLink({ usernameOrAlias: username, statusCb: setSigninInStatus, currentStatus: signingInStatus, }); requesting.signInLinkRequested.catch(setLastError); return requesting; }, /** Sign in with FIDO2 (e.g. Face ID or Touch) */ authenticateWithFido2: ({ username, credentials, clientMetadata, }: { username: string; credentials?: { id: string; transports?: AuthenticatorTransport[] }[]; clientMetadata?: Record; }) => { setLastError(undefined); const signinIn = authenticateWithFido2({ username, credentials, clientMetadata, statusCb: setSigninInStatus, tokensCb: (tokens) => storeTokens(tokens).then(() => setTokens(tokens)), }); signinIn.signedIn.catch(setLastError); return signinIn.signedIn; }, /** Sign in with username and password (using SRP: Secure Remote Password, where the password isn't sent over the wire) */ authenticateWithSRP: ({ username, password, smsMfaCode, clientMetadata, }: { username: string; password: string; smsMfaCode?: () => Promise; clientMetadata?: Record; }) => { setLastError(undefined); const signinIn = authenticateWithSRP({ username, password, smsMfaCode, clientMetadata, statusCb: setSigninInStatus, tokensCb: (tokens) => storeTokens(tokens).then(() => setTokens(tokens)), }); signinIn.signedIn.catch(setLastError); return signinIn.signedIn; }, /** Sign in with username and password (the password is sent in plaintext over the wire) */ authenticateWithPlaintextPassword: ({ username, password, smsMfaCode, clientMetadata, }: { username: string; password: string; smsMfaCode?: () => Promise; clientMetadata?: Record; }) => { setLastError(undefined); const signinIn = authenticateWithPlaintextPassword({ username, password, smsMfaCode, clientMetadata, statusCb: setSigninInStatus, tokensCb: (tokens) => storeTokens(tokens).then(() => setTokens(tokens)), }); signinIn.signedIn.catch(setLastError); return signinIn.signedIn; }, /** Sign-in again, using the user's current tokens (JWTs) and an OTP (One Time Password) that is sent to the user via SMS */ stepUpAuthenticationWithSmsOtp: ({ username, smsMfaCode, clientMetadata, }: { username: string; smsMfaCode: (phoneNumber: string, attempt: number) => Promise; clientMetadata?: Record; }) => { setLastError(undefined); const signinIn = stepUpAuthenticationWithSmsOtp({ username, smsMfaCode, clientMetadata, statusCb: setSigninInStatus, tokensCb: (tokens) => storeTokens(tokens).then(() => setTokens(tokens)), }); signinIn.signedIn.catch(setLastError); return signinIn.signedIn; }, /** Should the FIDO2 credential manager UI component be shown? */ showAuthenticatorManager, /** Toggle showing the FIDO2 credential manager UI component */ toggleShowAuthenticatorManager: useCallback( () => setShowAuthenticatorManager((state) => !state), [] ), }; } /** User Details stored in your configured storage (e.g. localStorage) */ type StoredUser = { username: string; email?: string; useFido?: "YES" | "NO" | "ASK"; credentials?: { id: string; transports?: AuthenticatorTransport[] }[]; }; /** Retrieve the last signed in users from your configured storage (e.g. localStorage) */ async function getLastSignedInUsers() { const { clientId, storage } = configure(); const lastUsers = await storage.getItem(`Passwordless.${clientId}.lastUsers`); if (!lastUsers) return []; const users = JSON.parse(lastUsers) as StoredUser[]; return users; } /** Clear the last signed in users from your configured storage (e.g. localStorage) */ async function clearLastSignedInUsers() { const { clientId, storage } = configure(); await storage.removeItem(`Passwordless.${clientId}.lastUsers`); } /** Register a signed in user in your configured storage (e.g. localStorage) */ async function registerSignedInUser(user: StoredUser) { const { clientId, debug, storage } = configure(); debug?.(`Registering user in storage: ${JSON.stringify(user)}`); const lastUsers = await getLastSignedInUsers(); const index = lastUsers.findIndex( (lastUser) => lastUser.username === user.username ); if (index !== -1) { lastUsers.splice(index, 1); } lastUsers.unshift(user); await storage.setItem( `Passwordless.${clientId}.lastUsers`, JSON.stringify(lastUsers.slice(0, 10)) ); } type UseLocalUserCache = ReturnType; function _useLocalUserCache() { const { tokensParsed, creatingCredential, fido2Credentials, signingInStatus, } = usePasswordless(); const idToken = tokensParsed?.idToken; const hasFido2Credentials = fido2Credentials && !!fido2Credentials.length; const justSignedInWithMagicLink = signingInStatus === "SIGNED_IN_WITH_LINK"; const [lastSignedInUsers, setLastSignedInUsers] = useState(); const [currentUser, setCurrentUser] = useState(); const [fidoPreferenceOverride, setFidoPreferenceOverride] = useState< "YES" | "NO" >(); // 1 populate lastSignedInUsers from local storage useEffect(() => { getLastSignedInUsers() .then(setLastSignedInUsers) .catch((err) => { const { debug } = configure(); debug?.("Failed to determine last signed-in users:", err); }); }, []); // 2 populate currentUser from lastSignedInUsers OR init currentUser useEffect(() => { if (!idToken) { setCurrentUser(undefined); return; } const user: StoredUser = { username: idToken["cognito:username"], email: idToken.email && idToken.email_verified ? idToken.email : undefined, }; if (lastSignedInUsers) { const found = lastSignedInUsers.find( (lastUser) => lastUser.username && lastUser.username === idToken["cognito:username"] ); if (found) { user.useFido = found.useFido; user.credentials = found.credentials; if (!idToken.email_verified) { user.email = found.email; } } } setCurrentUser((state) => JSON.stringify(state) === JSON.stringify(user) ? state : user ); }, [lastSignedInUsers, idToken]); // 3 If user is updated, store in lastSignedInUsers useEffect(() => { if (currentUser) { registerSignedInUser(currentUser).catch((err) => { const { debug } = configure(); debug?.("Failed to register last signed-in user:", err); }); setLastSignedInUsers((state) => { const update = [currentUser]; for (const user of state ?? []) { if (user.username !== currentUser.username) { update.push(user); } } return JSON.stringify(state) === JSON.stringify(update) ? state : update; }); } }, [currentUser]); const determineFido = useCallback( (user: StoredUser): "YES" | "NO" | "ASK" | "INDETERMINATE" => { const { fido2 } = configure(); if (!fido2) { throw new Error("Missing Fido2 config"); } if (hasFido2Credentials === undefined) { return "INDETERMINATE"; } if (fidoPreferenceOverride) { return fidoPreferenceOverride; } if (user.useFido === "NO") { if (justSignedInWithMagicLink) { return "ASK"; } return "NO"; } if (hasFido2Credentials) { return "YES"; } if (creatingCredential) { return user.useFido ?? "INDETERMINATE"; } return "ASK"; }, [ creatingCredential, hasFido2Credentials, fidoPreferenceOverride, justSignedInWithMagicLink, ] ); // 4 Update user FIDO preference useEffect(() => { if (currentUser) { const useFido = determineFido(currentUser); if (useFido === "INDETERMINATE") return; setCurrentUser((state) => { const update: StoredUser = { ...currentUser, useFido, credentials: fido2Credentials?.map((c) => ({ id: c.credentialId, transports: c.transports, })), }; return JSON.stringify(state) === JSON.stringify(update) ? state : update; }); } }, [currentUser, determineFido, fido2Credentials]); // 5 reset state on signOut useEffect(() => { if (!currentUser) setFidoPreferenceOverride(undefined); }, [currentUser]); return { /** The current signed-in user */ currentUser, /** Update the current user's FIDO2 preference */ updateFidoPreference: ({ useFido }: { useFido: "YES" | "NO" }) => { setFidoPreferenceOverride(useFido); }, /** The list of the 10 last signed-in users in your configured storage (e.g. localStorage) */ lastSignedInUsers, /** Clear the last signed in users from your configured storage (e.g. localStorage) */ clearLastSignedInUsers: () => { clearLastSignedInUsers().catch((err) => { const { debug } = configure(); debug?.("Failed to clear last signed-in users:", err); }); setLastSignedInUsers(undefined); }, }; } /** React hook to turn state (or any variable) into a promise that can be awaited */ export function useAwaitableState(state: T) { const resolve = useRef<(value: T) => void>(); const reject = useRef<(reason: Error) => void>(); const awaitable = useRef>(); const [awaited, setAwaited] = useState<{ value: T }>(); const renewPromise = useCallback(() => { awaitable.current = new Promise((_resolve, _reject) => { resolve.current = _resolve; reject.current = _reject; }) .then((value) => { setAwaited({ value }); return value; }) .finally(renewPromise); }, []); useEffect(renewPromise, [renewPromise]); useEffect(() => setAwaited(undefined), [state]); return { /** Call to get the current awaitable (promise) */ awaitable: () => awaitable.current!, /** Resolve the current awaitable (promise) with the current value of state */ resolve: () => resolve.current!(state), /** Reject the current awaitable (promise) */ reject: (reason: Error) => reject.current!(reason), /** That value of awaitable (promise) once it resolves. This is undefined if (1) awaitable is not yet resolved or (2) the state has changed since awaitable was resolved */ awaited, }; }