@joinmeow/cognito-passwordless-auth
Version:
Passwordless authentication with Amazon Cognito: FIDO2 (WebAuthn, support for Passkeys)
1,090 lines (1,089 loc) • 76.6 kB
JavaScript
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");