UNPKG

@joinmeow/cognito-passwordless-auth

Version:

Passwordless authentication with Amazon Cognito: FIDO2 (WebAuthn, support for Passkeys)

1,090 lines (1,089 loc) 76.6 kB
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime"; /** * 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, bufferToBase64 } from "../util.js"; import { fido2CreateCredential, fido2DeleteCredential, fido2ListCredentials, fido2UpdateCredential, authenticateWithFido2, } from "../fido2.js"; import { authenticateWithSRP } from "../srp.js"; import { authenticateWithPlaintextPassword } from "../plaintext.js"; import { configure } from "../config.js"; import { retrieveTokens, storeDeviceKey, getRememberedDevice, setRememberedDevice, } from "../storage.js"; import { busyState, } from "../model.js"; import { scheduleRefresh, refreshTokens, forceRefreshTokens, } from "../refresh.js"; import { verifySoftwareTokenForCurrentUser as verifySoftwareTokenForCurrentUserApi, associateSoftwareTokenForCurrentUser as associateSoftwareTokenForCurrentUserApi, confirmDevice as confirmDeviceApi, updateDeviceStatus, forgetDevice as forgetDeviceApi, getUser, } from "../cognito-api.js"; import React, { useState, useEffect, useContext, useCallback, useMemo, useRef, useReducer, } from "react"; import { signInWithRedirect as hostedSignInWithRedirect, handleCognitoOAuthCallback, } from "../hosted-oauth.js"; // Constants const MFA_FETCH_COOLDOWN = 5000; // 5 second cooldown between fetches const REFRESH_GRACE_PERIOD_MS = 30000; // 30 seconds grace period 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(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: <PasswordlessContextProvider enableLocalUserCache={true}>"); } return context; } /** Simple error boundary to catch hook failures */ class PasswordlessErrorBoundary extends React.Component { constructor(props) { super(props); this.state = { hasError: false }; } static getDerivedStateFromError(error) { return { hasError: true, error }; } componentDidCatch(error, errorInfo) { const { debug } = configure(); debug?.("PasswordlessErrorBoundary caught error:", error, errorInfo); } render() { if (this.state.hasError) { return (this.props.fallback || (_jsxs("div", { children: [_jsx("h2", { children: "Authentication Error" }), _jsx("p", { children: "Something went wrong with the authentication system." }), _jsxs("details", { children: [_jsx("summary", { children: "Error Details" }), _jsx("pre", { children: this.state.error?.message })] })] }))); } return this.props.children; } } export const PasswordlessContextProvider = (props) => { const passwordlessValue = _usePasswordless(); // Memoize the context value to prevent unnecessary re-renders const memoizedValue = useMemo(() => passwordlessValue, // eslint-disable-next-line react-hooks/exhaustive-deps [ // State values that might change passwordlessValue.tokens, passwordlessValue.tokensParsed, passwordlessValue.isRefreshingTokens, passwordlessValue.lastError, passwordlessValue.signingInStatus, passwordlessValue.busy, passwordlessValue.signInStatus, passwordlessValue.userVerifyingPlatformAuthenticatorAvailable, passwordlessValue.fido2Credentials, passwordlessValue.creatingCredential, passwordlessValue.deviceKey, passwordlessValue.totpMfaStatus, passwordlessValue.timeSinceLastActivityMs, passwordlessValue.timeSinceLastActivitySeconds, passwordlessValue.authMethod, // Functions are already memoized with useCallback, so they're stable // We don't need to include them in dependencies ]); return (_jsx(PasswordlessErrorBoundary, { fallback: props.errorFallback, children: _jsx(PasswordlessContext.Provider, { value: memoizedValue, children: props.enableLocalUserCache ? (_jsx(LocalUserCacheContextProvider, { errorFallback: props.errorFallback, children: props.children })) : (props.children) }) })); }; const LocalUserCacheContextProvider = (props) => { const localUserCacheValue = _useLocalUserCache(); // Memoize the context value to prevent unnecessary re-renders const memoizedValue = useMemo(() => localUserCacheValue, // eslint-disable-next-line react-hooks/exhaustive-deps [ localUserCacheValue.currentUser, localUserCacheValue.lastSignedInUsers, localUserCacheValue.signingInStatus, localUserCacheValue.authMethod, // Functions are already memoized with useCallback, so they're stable ]); return (_jsx(LocalUserCacheContext.Provider, { value: memoizedValue, children: _jsx(PasswordlessErrorBoundary, { fallback: props.errorFallback, children: props.children }) })); }; // Initial state const initialPasswordlessState = { signingInStatus: "SIGNED_OUT", initiallyRetrievingTokensFromStorage: true, creatingCredential: false, deviceKey: null, recheckSignInStatus: 0, totpMfaStatus: { enabled: false, preferred: false, availableMfaTypes: [], }, lastActivityAt: Date.now(), nowTick: Date.now(), }; // Reducer function function passwordlessReducer(state, action) { switch (action.type) { case "SET_SIGNING_STATUS": return { ...state, signingInStatus: action.payload }; case "SET_INITIAL_LOADING": return { ...state, initiallyRetrievingTokensFromStorage: action.payload }; case "SET_TOKENS": return { ...state, tokens: action.payload }; case "SET_TOKENS_PARSED": return { ...state, tokensParsed: action.payload }; case "SET_ERROR": return { ...state, lastError: action.payload }; case "SET_PLATFORM_AUTHENTICATOR": return { ...state, userVerifyingPlatformAuthenticatorAvailable: action.payload, }; case "SET_CREATING_CREDENTIAL": return { ...state, creatingCredential: action.payload }; case "SET_FIDO2_CREDENTIALS": return { ...state, fido2Credentials: action.payload }; case "UPDATE_FIDO2_CREDENTIAL": { if (!state.fido2Credentials) return state; const index = state.fido2Credentials.findIndex((c) => c.credentialId === action.payload.credentialId); if (index === -1) return state; const updated = [...state.fido2Credentials]; // eslint-disable-next-line security/detect-object-injection updated[index] = { ...updated[index], ...action.payload }; return { ...state, fido2Credentials: updated }; } case "DELETE_FIDO2_CREDENTIAL": return { ...state, fido2Credentials: state.fido2Credentials?.filter((c) => c.credentialId !== action.payload), }; case "SET_DEVICE_KEY": return { ...state, deviceKey: action.payload }; case "SET_REFRESH_STATUS": return { ...state, isRefreshingTokens: action.isRefreshing, }; case "INCREMENT_RECHECK_STATUS": return { ...state, recheckSignInStatus: state.recheckSignInStatus + 1 }; case "SET_AUTH_METHOD": return { ...state, authMethod: action.payload }; case "SET_TOTP_MFA_STATUS": return { ...state, totpMfaStatus: action.payload }; case "SET_LAST_ACTIVITY": return { ...state, lastActivityAt: action.payload }; case "SET_NOW_TICK": return { ...state, nowTick: action.payload }; case "SIGN_OUT": return { ...initialPasswordlessState, signingInStatus: "SIGNED_OUT", initiallyRetrievingTokensFromStorage: false, userVerifyingPlatformAuthenticatorAvailable: state.userVerifyingPlatformAuthenticatorAvailable, }; default: return state; } } function _usePasswordless() { // Use reducer instead of multiple useState calls const [state, dispatch] = useReducer(passwordlessReducer, initialPasswordlessState); // Destructure commonly used values from state for convenience const { signingInStatus, initiallyRetrievingTokensFromStorage, tokens, tokensParsed, lastError, userVerifyingPlatformAuthenticatorAvailable, creatingCredential, fido2Credentials, deviceKey, isRefreshingTokens, authMethod, totpMfaStatus, lastActivityAt, nowTick, } = state; // Helper functions for common dispatch actions const setSigninInStatus = useCallback((status) => { dispatch({ type: "SET_SIGNING_STATUS", payload: status }); }, []); const setLastError = useCallback((error) => { dispatch({ type: "SET_ERROR", payload: error }); }, []); const _setTokens = useCallback((tokens) => { dispatch({ type: "SET_TOKENS", payload: tokens }); }, []); const setTokensParsed = useCallback((parsed) => { dispatch({ type: "SET_TOKENS_PARSED", payload: parsed }); }, []); const setIsRefreshingTokens = useCallback((isRefreshing) => { dispatch({ type: "SET_REFRESH_STATUS", isRefreshing }); }, []); /** Translate authMethod → the corresponding *SIGNED_IN_WITH_* status */ const signedInStatusForAuth = useCallback((method) => { switch (method) { case "REDIRECT": return "SIGNED_IN_WITH_REDIRECT"; case "SRP": return "SIGNED_IN_WITH_SRP_PASSWORD"; case "PLAINTEXT": return "SIGNED_IN_WITH_PLAINTEXT_PASSWORD"; case "FIDO2": return "SIGNED_IN_WITH_FIDO2"; default: return undefined; } }, []); const updateFido2Credential = useCallback((update) => dispatch({ type: "UPDATE_FIDO2_CREDENTIAL", payload: update }), []); const deleteFido2Credential = useCallback((credentialId) => dispatch({ type: "DELETE_FIDO2_CREDENTIAL", payload: credentialId }), []); // Get activity tracking configuration // Note: We call configure() on each render to ensure we get the latest config // This is acceptable since configure() is lightweight and config changes are rare const { tokenRefresh } = configure(); const useActivityTracking = tokenRefresh?.useActivityTracking ?? false; // 1️⃣ Attach lightweight listeners to detect user activity (only if activity tracking is enabled) useEffect(() => { if (!useActivityTracking || typeof globalThis.addEventListener === "undefined") return; const activityHandler = () => dispatch({ type: "SET_LAST_ACTIVITY", payload: Date.now() }); const events = [ "mousemove", "mousedown", "keydown", "scroll", "touchstart", ]; events.forEach((evt) => globalThis.addEventListener(evt, activityHandler, { passive: true })); return () => events.forEach((evt) => globalThis.removeEventListener(evt, activityHandler)); }, [useActivityTracking]); // 2️⃣ Keep an internal clock running so React renders every second and derived // inactivity duration stays fresh. Only run if activity tracking is enabled. useEffect(() => { if (!useActivityTracking) return; const id = setInterval(() => dispatch({ type: "SET_NOW_TICK", payload: Date.now() }), 1000); return () => clearInterval(id); }, [useActivityTracking]); /** Helper function for consumers – milliseconds since last activity */ const timeSinceLastActivityMs = useActivityTracking ? nowTick - lastActivityAt : 0; // At component mount, check sign-in status useEffect(() => { setLastError(undefined); }, [setLastError]); const busy = busyState.includes(signingInStatus); /** * Parse a fresh or cached token bundle and update the derived `tokensParsed` state. * Handles the special case where Hosted-UI/OAuth ("REDIRECT") flows may not return * an ID-token by synthesising a minimal one from the access-token claims so the * rest of the library can continue to treat the user as signed-in. */ const parseAndSetTokens = useCallback((tokens) => { if (!tokens) { setTokensParsed(undefined); return; } const { accessToken, expireAt, idToken } = tokens; // OAuth/Hosted-UI flow – ID-token can be missing if (accessToken && expireAt && tokens.authMethod === "REDIRECT") { try { const accessTokenParsed = parseJwtPayload(accessToken); setTokensParsed({ accessToken: accessTokenParsed, idToken: { sub: accessTokenParsed.sub, "cognito:username": accessTokenParsed.username, exp: accessTokenParsed.exp, iat: accessTokenParsed.iat, ...(idToken ? parseJwtPayload(idToken) : {}), }, expireAt, }); } catch (err) { const { debug } = configure(); debug?.("Failed to parse tokens for OAuth flow:", err); setTokensParsed(undefined); } return; } // Standard flows – expect both access & ID token if (accessToken && expireAt) { try { if (idToken) { setTokensParsed({ idToken: parseJwtPayload(idToken), accessToken: parseJwtPayload(accessToken), expireAt, }); } else { // Non-OAuth flows must provide an ID-token setTokensParsed(undefined); } } catch (err) { const { debug } = configure(); debug?.("Failed to parse tokens:", err); setTokensParsed(undefined); } } else { setTokensParsed(undefined); } }, [setTokensParsed]); // --------------------------------------------------------------------------- // ♻️ Keep auth status stable when tokens change // --------------------------------------------------------------------------- // NOTE: Token refresh scheduling is handled by processTokens, not here. // This prevents duplicate scheduling and infinite loops. // Update auth status when tokens change useEffect(() => { if (tokens?.refreshToken && tokens?.authMethod) { const status = signedInStatusForAuth(tokens.authMethod); status && setSigninInStatus(status); } }, [tokens, signedInStatusForAuth, setSigninInStatus]); // Handle incomplete token bundle (edge-case: storage was tampered with) // Use ref to prevent circular dependencies const isHandlingIncompleteTokens = useRef(false); // Track which accessToken we have already used for GetUser, so we only // fetch MFA status once per token rotation (per page load). const lastFetchedMfaTokenRef = useRef(); /** * Helper function to check if access token is present * This is useful for API calls that only require access token * @returns true if access token is present */ const hasAccessToken = useCallback(() => { return !!tokens?.accessToken; }, [tokens?.accessToken]); /** * Helper function to check if refresh token is present * @returns true if refresh token is present */ const hasRefreshToken = useCallback(() => { return !!tokens?.refreshToken; }, [tokens?.refreshToken]); /** * Helper function to check if essential tokens are present * @returns true if both access token (from storage or parsed) and refresh token are present */ const hasEssentialTokens = useCallback(() => { return hasAccessToken() && hasRefreshToken(); }, [hasAccessToken, hasRefreshToken]); /** * Helper function to check if tokens are incomplete (missing essential parts) * This is useful for detecting when token refresh is needed * @returns true if tokens exist but are missing access token or expiration */ const hasIncompleteTokens = useCallback(() => { return tokens && (!hasAccessToken() || !tokens.expireAt); }, [tokens, hasAccessToken]); /** * Helper function to get access token or throw an error * Consolidates the common pattern of checking for access token before API calls * @param operation - The operation being performed (for error messages) * @returns The access token * @throws Error if no access token is available */ const getAccessTokenOrThrow = useCallback((operation) => { // We need the actual token string, not just the parsed payload const accessToken = tokens?.accessToken; if (!accessToken) { throw new Error(`Cannot ${operation}: user must be signed in`); } return accessToken; }, [tokens?.accessToken]); // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { // Don't run if we're currently handling incomplete tokens to avoid loops if (isHandlingIncompleteTokens.current) return; if (hasIncompleteTokens() && !isRefreshingTokens && authMethod !== "SRP" && signingInStatus !== "SIGNING_IN_WITH_PASSWORD" && signingInStatus !== "SIGNED_IN_WITH_PASSWORD" && signingInStatus !== "SIGNED_IN_WITH_REDIRECT" && signingInStatus !== "STARTING_SIGN_IN_WITH_REDIRECT") { const { debug } = configure(); debug?.("Detected incomplete tokens, attempting refresh"); isHandlingIncompleteTokens.current = true; refreshTokens({ tokensCb: (newTokens) => { if (newTokens) { const merged = { ...tokens, ...newTokens }; parseAndSetTokens(merged); _setTokens(merged); } else { _setTokens(undefined); parseAndSetTokens(undefined); dispatch({ type: "INCREMENT_RECHECK_STATUS" }); } isHandlingIncompleteTokens.current = false; }, isRefreshingCb: setIsRefreshingTokens, }).catch(() => { _setTokens(undefined); parseAndSetTokens(undefined); dispatch({ type: "INCREMENT_RECHECK_STATUS" }); isHandlingIncompleteTokens.current = false; }); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [ hasIncompleteTokens, isRefreshingTokens, authMethod, signingInStatus, parseAndSetTokens, _setTokens, setIsRefreshingTokens, ]); // At component mount, load tokens from storage // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { const abortController = new AbortController(); const { debug, clientId } = configure(); // Function to load tokens from storage const loadTokens = async () => { try { const tokens = await retrieveTokens(); // Check if the operation was aborted if (abortController.signal.aborted) { debug?.("Token retrieval aborted - component unmounted"); return; } // Process tokens only if not aborted _setTokens(tokens); parseAndSetTokens(tokens); // Update signing status for OAuth/REDIRECT tokens if (tokens?.authMethod === "REDIRECT") { debug?.("Setting signingInStatus to SIGNED_IN_WITH_REDIRECT based on retrieved tokens"); setSigninInStatus("SIGNED_IN_WITH_REDIRECT"); } } catch (err) { // Check if the operation was aborted before handling error if (abortController.signal.aborted) { debug?.("Token retrieval error handling aborted - component unmounted"); return; } debug?.("Failed to retrieve tokens from storage:", err); // Make sure signInStatus gets recalculated on error dispatch({ type: "INCREMENT_RECHECK_STATUS" }); } finally { // Check if the operation was aborted before final state update if (!abortController.signal.aborted) { dispatch({ type: "SET_INITIAL_LOADING", payload: false }); } } }; // Initial load void loadTokens(); // Check for OAuth callback const checkOAuthCallback = async () => { const urlParams = new URLSearchParams(globalThis.location.search); const hashParams = new URLSearchParams(globalThis.location.hash.substring(1)); if (urlParams.has("code") || hashParams.has("access_token")) { // Prevent multiple simultaneous OAuth callback processing if (oauthProcessingRef.current) { debug?.("OAuth callback already being processed, skipping..."); return; } oauthProcessingRef.current = true; debug?.("OAuth callback detected, processing..."); try { setSigninInStatus("STARTING_SIGN_IN_WITH_REDIRECT"); const oauthTokens = await handleCognitoOAuthCallback(); if (oauthTokens !== null) { // Update React state with the processed tokens updateTokens(oauthTokens); setSigninInStatus("SIGNED_IN_WITH_REDIRECT"); debug?.("OAuth callback processed successfully"); } } catch (error) { debug?.("OAuth callback processing failed:", error); setLastError(error instanceof Error ? error : new Error(String(error))); setSigninInStatus("SIGNIN_WITH_REDIRECT_FAILED"); } } }; // Check for OAuth callback after initial load void checkOAuthCallback(); // Listen for storage events to detect token updates from other tabs const handleStorageChange = (e) => { // Check if this is a Cognito token-related key if (e.key && clientId && e.key.includes(`CognitoIdentityServiceProvider.${clientId}`)) { // Check if it's a token key (accessToken, idToken, or refreshToken) if (e.key.includes(".accessToken") || e.key.includes(".idToken") || e.key.includes(".refreshToken")) { debug?.(`Detected token change in storage for key: ${e.key}`); // Reload tokens from storage void loadTokens(); } } }; if (typeof globalThis !== "undefined" && globalThis.addEventListener) { globalThis.addEventListener("storage", handleStorageChange); } // Cleanup function return () => { abortController.abort(); if (typeof globalThis !== "undefined" && globalThis.removeEventListener) { globalThis.removeEventListener("storage", handleStorageChange); } }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [parseAndSetTokens, _setTokens, setSigninInStatus, setLastError]); // Give easy access to isUserVerifyingPlatformAuthenticatorAvailable useEffect(() => { if (typeof PublicKeyCredential !== "undefined") { const cancel = new AbortController(); PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable() .then((res) => { if (!cancel.signal.aborted) { dispatch({ type: "SET_PLATFORM_AUTHENTICATOR", payload: res }); } }) .catch((err) => { const { debug } = configure(); debug?.("Failed to determine if a user verifying platform authenticator is available:", err); }); // Return cleanup function from useEffect, not from promise return () => cancel.abort(); } else { dispatch({ type: "SET_PLATFORM_AUTHENTICATOR", payload: false }); } }, []); const toFido2Credential = useCallback((credential) => { // Ensure lastSignIn is a Date object const normalizedCredential = { ...credential, lastSignIn: credential.lastSignIn ? typeof credential.lastSignIn === "string" ? new Date(credential.lastSignIn) : credential.lastSignIn : undefined, }; return { ...normalizedCredential, busy: false, update: async (update) => { 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 (simplified to essential states only) const signInStatus = useMemo(() => { // 1. Initial load – waiting for storage if (initiallyRetrievingTokensFromStorage) return "CHECKING"; // 2. Library is busy signing in/out if (busyState.includes(signingInStatus)) { return signingInStatus === "SIGNING_OUT" ? "SIGNING_OUT" : "SIGNING_IN"; } // 3. Check if we have the essential tokens if (!hasEssentialTokens()) { return "NOT_SIGNED_IN"; } // We have tokens, now check expiration if possible const expiresAt = tokens?.expireAt || tokensParsed?.expireAt; // If we can't determine expiration, assume tokens are valid if (!expiresAt) { return "SIGNED_IN"; } // Check if tokens are expired const now = Date.now(); const expireAtTime = expiresAt instanceof Date ? expiresAt.valueOf() : new Date(expiresAt).valueOf(); // If expireAtTime is NaN (invalid date), assume tokens are valid if (isNaN(expireAtTime)) { return "SIGNED_IN"; } // Allow a grace period during token refresh to prevent temporary logout // The refresh process typically completes within 15-20 seconds // If we're currently refreshing tokens, maintain signed-in status // even if tokens are technically expired if (isRefreshingTokens && now < expireAtTime + REFRESH_GRACE_PERIOD_MS) { return "SIGNED_IN"; } // Expired tokens = not signed in if (now >= expireAtTime) { return "NOT_SIGNED_IN"; } // Valid tokens = signed in return "SIGNED_IN"; }, [ initiallyRetrievingTokensFromStorage, signingInStatus, tokens?.expireAt, tokensParsed?.expireAt, isRefreshingTokens, hasEssentialTokens, ]); // Track FIDO2 authenticators for the user const isSignedIn = signInStatus === "SIGNED_IN"; const revalidateFido2Credentials = useCallback(() => { const { debug } = configure(); // Only proceed when signed in (list credentials even after SRP) if (!isSignedIn) { debug?.("Not signed in, skipping credential listing"); // Don't aggressively clear credentials - let sign out handle this // Return a no-op cleanup function to maintain consistent API return () => { }; } // Only proceed with operations if signed in const cancel = new AbortController(); // List credentials for signed-in user debug?.("Listing FIDO2 credentials"); fido2ListCredentials() .then((res) => { if (!cancel.signal.aborted) { debug?.("Fetched FIDO2 credentials:", res.authenticators); dispatch({ type: "SET_FIDO2_CREDENTIALS", payload: res.authenticators.map((credential) => toFido2Credential(credential)), }); } }) .catch((err) => { if (!cancel.signal.aborted) { debug?.("Failed to list credentials:", err); } }); return () => cancel.abort(); }, [isSignedIn, toFido2Credential]); useEffect(() => { const cleanup = revalidateFido2Credentials(); return cleanup; }, [revalidateFido2Credentials]); // Track last fetch time to prevent spam const lastMfaFetchTimeRef = useRef(0); // Track OAuth callback processing to prevent multiple executions const oauthProcessingRef = useRef(false); // Fetch TOTP MFA status when the user is signed in – with rate limiting useEffect(() => { // Early return if not signed in or no token if (!isSignedIn || !hasAccessToken()) return; const now = Date.now(); const timeSinceLastFetch = now - lastMfaFetchTimeRef.current; // Skip if we've already fetched MFA status for this token value if (tokens?.accessToken === lastFetchedMfaTokenRef.current) return; // Skip if we fetched recently (within cooldown period) if (timeSinceLastFetch < MFA_FETCH_COOLDOWN) { const { debug } = configure(); debug?.(`Skipping getUser call - cooldown active (${Math.round((MFA_FETCH_COOLDOWN - timeSinceLastFetch) / 1000)}s remaining)`); return; } // At this point, we know tokens exists because hasAccessToken() returned true // But TypeScript doesn't know this, so we need to check again if (!tokens?.accessToken) return; lastFetchedMfaTokenRef.current = tokens.accessToken; lastMfaFetchTimeRef.current = now; const abortController = new AbortController(); // Get MFA settings for the signed-in user getUser({ accessToken: tokens.accessToken, abort: abortController.signal }) .then((user) => { if (abortController.signal.aborted) return; // If we have a valid user object with MFA settings, use them if (user && typeof user === "object" && !("__type" in user)) { const hasMfa = user.UserMFASettingList?.includes("SOFTWARE_TOKEN_MFA") || false; const preferredMfa = user.PreferredMfaSetting === "SOFTWARE_TOKEN_MFA"; dispatch({ type: "SET_TOTP_MFA_STATUS", payload: { enabled: hasMfa, preferred: preferredMfa, availableMfaTypes: user.UserMFASettingList || [], }, }); } else { // Default to no MFA dispatch({ type: "SET_TOTP_MFA_STATUS", payload: { enabled: false, preferred: false, availableMfaTypes: [], }, }); } }) .catch(() => { if (abortController.signal.aborted) return; // On error we keep the previously known MFA status to avoid // falsely disabling security-gated UI. Log for debugging. const { debug } = configure(); debug?.("getUser failed; retaining previous TOTP MFA status"); }); return () => { abortController.abort(); }; }, [isSignedIn, tokens?.accessToken, hasAccessToken]); useEffect(() => { const { debug } = configure(); debug?.("fido2Credentials state updated:", fido2Credentials); }, [fido2Credentials]); /** * Replace the current token bundle with a fresh one – no merging. * Cognito rotates refresh-tokens, so keeping stale fields would be dangerous. */ const updateTokens = useCallback((next) => { // Only update if tokens actually changed const current = tokens; // If both undefined, no change needed if (!current && !next) return; // If one is undefined, definitely update if (!current || !next) { _setTokens(next); parseAndSetTokens(next); return; } // Check if tokens actually changed const hasChanges = current.accessToken !== next.accessToken || current.idToken !== next.idToken || current.refreshToken !== next.refreshToken || current.expireAt?.getTime() !== next.expireAt?.getTime(); if (hasChanges) { _setTokens(next); parseAndSetTokens(next); } // If no changes, skip update to prevent re-renders }, [tokens, parseAndSetTokens, _setTokens]); 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) => { // Update signingInStatus to indicate refresh is in progress const { debug } = configure(); debug?.("Manually refreshing tokens"); // Set appropriate status based on auth method const status = signedInStatusForAuth(tokens?.authMethod); status && setSigninInStatus(status); return refreshTokens({ abort, tokensCb: (newTokens) => { if (newTokens) { // Process tokens and update UI state with auth method updateTokens(newTokens); // Update signing status after refresh based on auth method const newStatus = signedInStatusForAuth(newTokens.authMethod ?? tokens?.authMethod); newStatus && setSigninInStatus(newStatus); } // Consistent return type - void }, isRefreshingCb: setIsRefreshingTokens, }); }, /** Force an immediate token refresh regardless of current token state */ forceRefreshTokens: (abort) => { // Update signingInStatus to indicate refresh is in progress const { debug } = configure(); debug?.("Forcing immediate token refresh"); // Set appropriate status based on auth method const status = signedInStatusForAuth(tokens?.authMethod); status && setSigninInStatus(status); return forceRefreshTokens({ abort, tokensCb: (newTokens) => { if (newTokens) { // Process tokens and update UI state with auth method updateTokens(newTokens); // Update signing status after refresh based on auth method const newStatus = signedInStatusForAuth(newTokens.authMethod ?? tokens?.authMethod); newStatus && setSigninInStatus(newStatus); } // Consistent return type - void }, isRefreshingCb: setIsRefreshingTokens, }); }, /** Mark the user as active to potentially trigger token refresh */ markUserActive: () => { if (!useActivityTracking) { // Provide feedback that activity tracking is disabled const { debug } = configure(); debug?.("markUserActive called but activity tracking is disabled"); return; } dispatch({ type: "SET_LAST_ACTIVITY", payload: Date.now() }); // Schedule a refresh if tokens exist but only if we're not currently refreshing if (tokens && !isRefreshingTokens) { // Using void to properly handle the promise void scheduleRefresh({ tokensCb: (newTokens) => { if (newTokens) { updateTokens(newTokens); } // Consistent return type - void }, isRefreshingCb: setIsRefreshingTokens, }).catch((err) => { const { debug } = configure(); debug?.("Failed to schedule refresh on user activity:", err); }); } }, /** 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, /** The device key for remembered device authentication */ deviceKey, /** * Confirm a device for trusted device authentication. * The device key must be available from a recent authentication response. */ confirmDevice: async (deviceName) => { const accessToken = getAccessTokenOrThrow("confirm device"); if (!deviceKey) { throw new Error("No device key available"); } const { debug, crypto } = configure(); debug?.("Confirming device:", deviceKey); // Generate device verifier config internally // Generate a random salt const saltBuffer = new Uint8Array(16); crypto.getRandomValues(saltBuffer); const salt = bufferToBase64(saltBuffer); // Generate a random password verifier const passwordVerifierBuffer = new Uint8Array(64); crypto.getRandomValues(passwordVerifierBuffer); const passwordVerifier = bufferToBase64(passwordVerifierBuffer); // Create device verifier config const deviceVerifierConfig = { passwordVerifier, salt, }; const result = await confirmDeviceApi({ accessToken, deviceKey, deviceName, deviceSecretVerifierConfig: deviceVerifierConfig, }); // If user confirmation is necessary, set device as remembered if (result.UserConfirmationNecessary) { debug?.("User confirmation necessary for device, setting as remembered"); await updateDeviceStatus({ accessToken, deviceKey, deviceRememberedStatus: "remembered", }); } // Ensure device key is stored in persistent storage if (tokens?.username) { await storeDeviceKey(tokens.username, deviceKey); } return result; }, /** * Update the status of a device (remembered or not_remembered). * Use this after getting userConfirmationNecessary=true in tokens * to set the device as remembered based on user choice. */ updateDeviceStatus: async ({ deviceKey, deviceRememberedStatus, }) => { const accessToken = getAccessTokenOrThrow("update device status"); const { debug } = configure(); debug?.(`Setting device ${deviceKey} as ${deviceRememberedStatus}`); await updateDeviceStatus({ accessToken, deviceKey, deviceRememberedStatus, }); }, /** * Forget a device to stop using it for trusted device authentication * Note that this is different from just clearing the local device key * as it also removes the device from the user's account on the server */ forgetDevice: async (deviceKeyToForget = deviceKey || "") => { const accessToken = getAccessTokenOrThrow("forget device"); if (!deviceKeyToForget) { throw new Error("No device key provided"); } const { debug, storage, clientId } = configure(); debug?.("Forgetting device:", deviceKeyToForget); await forgetDeviceApi({ accessToken, deviceKey: deviceKeyToForget, }); // If forgetting the current device, clear it if (deviceKeyToForget === deviceKey) { // Remove the device key from storage const deviceKeyStorageKey = `Passwordless.${clientId}.deviceKey`; const result = storage.removeItem(deviceKeyStorageKey); if (result instanceof Promise) { await result; } // Clear deviceKey in state dispatch({ type: "SET_DEVICE_KEY", payload: null }); } }, /** * Clear the stored device key locally without removing it from the server */ clearDeviceKey: () => { const { storage, clientId, debug } = configure(); const deviceKeyStorageKey = `Passwordless.${clientId}.deviceKey`; const result = storage.removeItem(deviceKeyStorageKey); if (result instanceof Promise) { result.catch((err) => { debug?.("Failed to remove device key from storage:", err); }); } // Clear deviceKey in state dispatch({ type: "SET_DEVICE_KEY", payload: null }); }, /** Register a FIDO2 credential with the Relying Party */ fido2CreateCredential: async (...args) => { dispatch({ type: "SET_CREATING_CREDENTIAL", payload: true }); try { const storedCredential = await fido2CreateCredential(...args); const credential = toFido2Credential(storedCredential); dispatch({ type: "SET_FIDO2_CREDENTIALS", payload: fido2Credentials ? [...fido2Credentials, credential] : [credential], }); return storedCredential; } finally { dispatch({ type: "SET_CREATING_CREDENTIAL", payload: false }); } }, /** Sign out */ signOut: (options) => { dispatch({ type: "SET_ERROR", payload: undefined }); dispatch({ type: "SET_AUTH_METHOD", payload: undefined }); const signingOut = signOut({ statusCb: setSigninInStatus, tokensRemovedLocallyCb: () => { _setTokens(undefined); parseAndSetTokens(undefined); dispatch({ type: "SET_FIDO2_CREDENTIALS", payload: undefined }); }, currentStatus: signingInStatus, skipTokenRevocation: options?.skipTokenRevocation, }); signingOut.signedOut.catch((error) => dispatch({ type: "SET_ERROR", payload: error })); return signingOut; }, /** Sign in with FIDO2 (e.g. Face ID or Touch) */ authenticateWithFido2: ({ username, credentials, clientMetadata, } = {}) => { const { debug } = configure(); debug?.("Starting FIDO2 sign-in (hook)"); dispatch({ type: "SET_ERROR", payload: undefined }); dispatch({ type: "SET_AUTH_METHOD", payload: "FIDO2" }); const signinIn = authenticateWithFido2({ username, credentials, clientMetadata, statusCb: setSigninInStatus, tokensCb: async (newTokens) => { // 1) Update tokens in state and deviceKey updateTokens(newTokens); if (newTokens.deviceKey) { dispatch({ type: "SET_DEVICE_KEY", payload: newTokens.deviceKey }); } else { try { const existing = await getRememberedDevice(newTokens.username); if (existing?.deviceKey) { dispatch({ type: "SET_DEVICE_KEY", payload: existing.deviceKey, }); } } catch { // ignore } } // 2) If a rememberDevice callback is provided and user confirmation is needed, prompt if (newTokens.userConfirmationNecessary) { try { if (newTokens.deviceKey && newTokens.accessToken) { debug?.(`Fido2 sign-in setting device ${newTokens.deviceKey} as remembered`); await updateDeviceStatus({ accessToken: newTokens.accessToken, deviceKey: newTokens.deviceKey, deviceRememberedStatus: "remembered", }); // Update local record const rec = await getRememberedDevice(newTokens.username); if (rec && rec.deviceKey === newTokens.deviceKey) { await setRememberedDevice(newTokens.username, { ...rec, remembered: true, }); } } else { debug?.("User opted NOT to remember this device");