UNPKG

@joinmeow/cognito-passwordless-auth

Version:

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

1,107 lines 54.6 kB
/** * 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 { parseJwtPayload, throwIfNot2xx, bufferToBase64 } from "./util.js"; import { configure } from "./config.js"; import { retrieveTokens } from "./storage.js"; import { CognitoSecurityProvider } from "./cognito-security.js"; import { createDeviceSrpAuthHandler } from "./device.js"; const AWS_REGION_REGEXP = /^[a-z]{2}-[a-z]+-\d$/; export function isErrorResponse(obj) { return (!!obj && typeof obj === "object" && "__type" in obj && "message" in obj); } export function assertIsNotErrorResponse(obj) { if (isErrorResponse(obj)) { const err = new Error(); err.name = obj.__type; err.message = obj.message; throw err; } } export function assertIsNotChallengeResponse(obj) { if (isChallengeResponse(obj)) { throw new Error(`Unexpected challenge: ${obj.ChallengeName}`); } } export function assertIsNotAuthenticatedResponse(obj) { if (isAuthenticatedResponse(obj)) { throw new Error("Unexpected authentication response"); } } export function isChallengeResponse(obj) { return (!!obj && typeof obj === "object" && "ChallengeName" in obj && "ChallengeParameters" in obj); } export function assertIsChallengeResponse(obj) { assertIsNotErrorResponse(obj); assertIsNotAuthenticatedResponse(obj); if (!isChallengeResponse(obj)) { throw new Error("Expected challenge response"); } } export function isAuthenticatedResponse(obj) { return !!obj && typeof obj === "object" && "AuthenticationResult" in obj; } export function assertIsAuthenticatedResponse(obj) { assertIsNotErrorResponse(obj); assertIsNotChallengeResponse(obj); if (!isAuthenticatedResponse(obj)) { throw new Error("Expected authentication response"); } } export function assertIsSignInResponse(obj) { assertIsNotErrorResponse(obj); if (!isAuthenticatedResponse(obj) && !isChallengeResponse(obj)) { throw new Error("Expected sign-in response"); } } export async function initiateAuth({ authflow, authParameters, clientMetadata, deviceKey, abort, }) { const { fetch, cognitoIdpEndpoint, proxyApiHeaders, clientId, clientSecret, debug, } = configure(); // Enhance with security context data if it's an authentication flow and a username is provided let userContextData; if (authflow !== "REFRESH_TOKEN" && authParameters.USERNAME) { try { // Use our security provider to get encoded data const securityProvider = CognitoSecurityProvider.getInstance(); const encodedData = await securityProvider.getSecurityData(authParameters.USERNAME); if (encodedData) { userContextData = { EncodedData: encodedData, }; debug?.("User context data successfully collected for initiateAuth"); } } catch (err) { // Don't fail auth if context collection fails debug?.("Failed to collect user context data for initiateAuth:", err); } } // Add device key to auth parameters if provided and it's a valid authentication flow for device if (deviceKey && (authflow === "REFRESH_TOKEN" || authflow === "USER_PASSWORD_AUTH" || authflow === "USER_SRP_AUTH" || authflow === "CUSTOM_AUTH")) { debug?.(`Including device key ${deviceKey} in ${authflow} flow`); authParameters.DEVICE_KEY = deviceKey; } return fetch(cognitoIdpEndpoint.match(AWS_REGION_REGEXP) ? `https://cognito-idp.${cognitoIdpEndpoint}.amazonaws.com/` : cognitoIdpEndpoint, { signal: abort, headers: { "x-amz-target": "AWSCognitoIdentityProviderService.InitiateAuth", "content-type": "application/x-amz-json-1.1", ...proxyApiHeaders, }, method: "POST", body: JSON.stringify({ AuthFlow: authflow, ClientId: clientId, AuthParameters: { ...authParameters, ...(clientSecret && { SECRET_HASH: await calculateSecretHash(authParameters.USERNAME), }), }, ClientMetadata: clientMetadata, ...(userContextData && { UserContextData: userContextData }), }), }).then(extractInitiateAuthResponse(authflow)); } export async function respondToAuthChallenge({ challengeName, challengeResponses, session, clientMetadata, abort, }) { const { fetch, cognitoIdpEndpoint, proxyApiHeaders, clientId, clientSecret, debug, } = configure(); // Enhance with security context data if a username is provided let userContextData; if (challengeResponses.USERNAME) { try { // Use our security provider to get encoded data const securityProvider = CognitoSecurityProvider.getInstance(); const encodedData = await securityProvider.getSecurityData(challengeResponses.USERNAME); if (encodedData) { userContextData = { EncodedData: encodedData, }; debug?.("User context data successfully collected for respondToAuthChallenge"); } } catch (err) { // Don't fail auth if context collection fails debug?.("Failed to collect user context data for respondToAuthChallenge:", err); } } return fetch(cognitoIdpEndpoint.match(AWS_REGION_REGEXP) ? `https://cognito-idp.${cognitoIdpEndpoint}.amazonaws.com/` : cognitoIdpEndpoint, { headers: { "x-amz-target": "AWSCognitoIdentityProviderService.RespondToAuthChallenge", "content-type": "application/x-amz-json-1.1", ...proxyApiHeaders, }, method: "POST", body: JSON.stringify({ ChallengeName: challengeName, ChallengeResponses: { ...challengeResponses, ...(clientSecret && { SECRET_HASH: await calculateSecretHash(challengeResponses.USERNAME), }), }, ClientId: clientId, Session: session, ClientMetadata: clientMetadata, ...(userContextData && { UserContextData: userContextData }), }), signal: abort, }).then(extractChallengeResponse); } /** * Confirms the sign-up of a user in Amazon Cognito. * Automatically collects and includes threat protection data when available. * * @param params - The parameters for confirming the sign-up. * @param params.username - The username or alias (e-mail, phone number) of the user. * @param params.confirmationCode - The confirmation code received by the user. * @param [params.clientMetadata] - Additional metadata to be passed to the server. * @param [params.forceAliasCreation] - When true, forces user confirmation despite existing aliases. * @param [params.abort] - An optional AbortSignal object that can be used to abort the request. * @returns A promise that resolves to the response of the confirmation request. */ export async function confirmSignUp({ username, confirmationCode, clientMetadata, forceAliasCreation, abort, }) { const { fetch, cognitoIdpEndpoint, proxyApiHeaders, clientId, clientSecret } = configure(); // Security-forward approach: attempt to collect user context data by default let userContextData; try { // Use our security provider to get encoded data const securityProvider = CognitoSecurityProvider.getInstance(); const encodedData = await securityProvider.getSecurityData(username); if (encodedData) { userContextData = { EncodedData: encodedData, }; } } catch (err) { // Don't fail the sign-up if context collection fails } return fetch(cognitoIdpEndpoint.match(AWS_REGION_REGEXP) ? `https://cognito-idp.${cognitoIdpEndpoint}.amazonaws.com/` : cognitoIdpEndpoint, { headers: { "x-amz-target": "AWSCognitoIdentityProviderService.ConfirmSignUp", "content-type": "application/x-amz-json-1.1", ...proxyApiHeaders, }, method: "POST", body: JSON.stringify({ Username: username, ConfirmationCode: confirmationCode, ClientId: clientId, ClientMetadata: clientMetadata, ...(forceAliasCreation !== undefined && { ForceAliasCreation: forceAliasCreation, }), ...(userContextData && { UserContextData: userContextData }), ...(clientSecret && { SecretHash: await calculateSecretHash(username), }), }), signal: abort, }).then(throwIfNot2xx); } export async function revokeToken({ refreshToken, abort, }) { const { fetch, cognitoIdpEndpoint, proxyApiHeaders, clientId } = configure(); return fetch(cognitoIdpEndpoint.match(AWS_REGION_REGEXP) ? `https://cognito-idp.${cognitoIdpEndpoint}.amazonaws.com/` : cognitoIdpEndpoint, { headers: { "x-amz-target": "AWSCognitoIdentityProviderService.RevokeToken", "content-type": "application/x-amz-json-1.1", ...proxyApiHeaders, }, method: "POST", body: JSON.stringify({ Token: refreshToken, ClientId: clientId, }), signal: abort, }).then(throwIfNot2xx); } export async function getTokensFromRefreshToken({ refreshToken, deviceKey, clientMetadata, abort, }) { const { fetch, cognitoIdpEndpoint, proxyApiHeaders, clientId, clientSecret, debug, } = configure(); debug?.("Getting tokens using refresh token with GetTokensFromRefreshToken API"); // Build the request body const requestBody = { ClientId: clientId, RefreshToken: refreshToken, }; // Add optional parameters if provided if (deviceKey) { debug?.(`Including device key in refresh token request: ${deviceKey}`); requestBody.DeviceKey = deviceKey; } if (clientMetadata) { debug?.(`Including client metadata in refresh token request: ${JSON.stringify(clientMetadata)}`); requestBody.ClientMetadata = clientMetadata; } if (clientSecret) { debug?.(`Including client secret in refresh token request (length: ${clientSecret.length})`); requestBody.ClientSecret = clientSecret; } debug?.(`Requesting tokens from endpoint: ${cognitoIdpEndpoint}`); debug?.(`Request body structure: ${JSON.stringify({ hasClientId: !!clientId, refreshTokenLength: refreshToken.length, hasDeviceKey: !!deviceKey, hasClientMetadata: !!clientMetadata, hasClientSecret: !!clientSecret, })}`); try { const response = await fetch(cognitoIdpEndpoint.match(AWS_REGION_REGEXP) ? `https://cognito-idp.${cognitoIdpEndpoint}.amazonaws.com/` : cognitoIdpEndpoint, { signal: abort, headers: { "x-amz-target": "AWSCognitoIdentityProviderService.GetTokensFromRefreshToken", "content-type": "application/x-amz-json-1.1", ...proxyApiHeaders, }, method: "POST", body: JSON.stringify(requestBody), }); // Parse response JSON const resOk = await throwIfNot2xx(response); const json = await resOk.json(); assertIsNotErrorResponse(json); // Ensure we have a valid AuthenticationResult if (!json || typeof json !== "object") { debug?.(`Invalid response - not an object: ${typeof json}`); throw new Error("Invalid response from GetTokensFromRefreshToken"); } if (!("AuthenticationResult" in json)) { debug?.(`Invalid response - missing AuthenticationResult property. Keys: ${Object.keys(json).join(", ")}`); throw new Error("Invalid response from GetTokensFromRefreshToken"); } // Explicitly validate and construct a properly typed response const authResult = json.AuthenticationResult; if (!authResult.AccessToken || typeof authResult.AccessToken !== "string") { debug?.(`Invalid response - missing or invalid AccessToken`); throw new Error("Invalid response from GetTokensFromRefreshToken: missing AccessToken"); } if (!authResult.IdToken || typeof authResult.IdToken !== "string") { debug?.(`Invalid response - missing or invalid IdToken`); throw new Error("Invalid response from GetTokensFromRefreshToken: missing IdToken"); } if (!authResult.ExpiresIn || typeof authResult.ExpiresIn !== "number") { debug?.(`Invalid response - missing or invalid ExpiresIn`); throw new Error("Invalid response from GetTokensFromRefreshToken: missing ExpiresIn"); } if (!authResult.TokenType || typeof authResult.TokenType !== "string") { debug?.(`Invalid response - missing or invalid TokenType`); throw new Error("Invalid response from GetTokensFromRefreshToken: missing TokenType"); } // Create a properly typed response object const typedResponse = { AuthenticationResult: { AccessToken: authResult.AccessToken, IdToken: authResult.IdToken, RefreshToken: authResult.RefreshToken, ExpiresIn: authResult.ExpiresIn, TokenType: authResult.TokenType, NewDeviceMetadata: authResult.NewDeviceMetadata, }, }; debug?.(`AuthenticationResult structure: ${JSON.stringify({ hasAccessToken: !!authResult.AccessToken, hasIdToken: !!authResult.IdToken, hasRefreshToken: !!authResult.RefreshToken, hasExpiresIn: !!authResult.ExpiresIn, hasTokenType: !!authResult.TokenType, hasNewDeviceMetadata: !!authResult.NewDeviceMetadata, })}`); return typedResponse; } catch (error) { debug?.(`Error in getTokensFromRefreshToken: ${error instanceof Error ? error.message : String(error)}`); debug?.(`Error stack: ${error instanceof Error ? error.stack : "No stack available"}`); throw error; } } export async function getId({ identityPoolId, abort, }) { const { fetch } = configure(); const identityPoolRegion = identityPoolId.split(":")[0]; const { idToken } = (await retrieveTokens()) ?? {}; if (!idToken) { throw new Error("Missing ID token"); } const iss = new URL(parseJwtPayload(idToken)["iss"]); return fetch(`https://cognito-identity.${identityPoolRegion}.amazonaws.com/`, { signal: abort, headers: { "x-amz-target": "AWSCognitoIdentityService.GetId", "content-type": "application/x-amz-json-1.1", }, method: "POST", body: JSON.stringify({ IdentityPoolId: identityPoolId, Logins: { [`${iss.hostname}${iss.pathname}`]: idToken, }, }), }) .then(throwIfNot2xx) .then((res) => res.json()); } /** * Retrieves the user attributes from the Cognito Identity Provider. * * @param abort - An optional `AbortSignal` object that can be used to abort the request. * @returns A promise that resolves to an array of user attributes, where each attribute is represented by an object with `Name` and `Value` properties. */ export async function getUser({ abort, accessToken, }) { const { fetch, cognitoIdpEndpoint, proxyApiHeaders } = configure(); const token = accessToken ?? (await retrieveTokens())?.accessToken; return await fetch(cognitoIdpEndpoint.match(AWS_REGION_REGEXP) ? `https://cognito-idp.${cognitoIdpEndpoint}.amazonaws.com/` : cognitoIdpEndpoint, { headers: { "x-amz-target": "AWSCognitoIdentityProviderService.GetUser", "content-type": "application/x-amz-json-1.1", ...proxyApiHeaders, }, method: "POST", body: JSON.stringify({ AccessToken: token, }), signal: abort, }) .then(throwIfNot2xx) .then((res) => res.json()); } export async function getCredentialsForIdentity({ identityId, abort, }) { const { fetch } = configure(); const identityPoolRegion = identityId.split(":")[0]; const { idToken } = (await retrieveTokens()) ?? {}; if (!idToken) { throw new Error("Missing ID token"); } const iss = new URL(parseJwtPayload(idToken)["iss"]); return fetch(`https://cognito-identity.${identityPoolRegion}.amazonaws.com/`, { signal: abort, headers: { "x-amz-target": "AWSCognitoIdentityService.GetCredentialsForIdentity", "content-type": "application/x-amz-json-1.1", }, method: "POST", body: JSON.stringify({ IdentityId: identityId, Logins: { [`${iss.hostname}${iss.pathname}`]: idToken, }, }), }) .then(throwIfNot2xx) .then((res) => res.json()); } export async function signUp({ username, password, userAttributes, clientMetadata, validationData, abort, }) { const { fetch, cognitoIdpEndpoint, proxyApiHeaders, clientId, clientSecret, debug, } = configure(); // Enhance with security context data let userContextData; try { // Use our security provider to get encoded data const securityProvider = CognitoSecurityProvider.getInstance(); const encodedData = await securityProvider.getSecurityData(username); if (encodedData) { userContextData = { EncodedData: encodedData, }; debug?.("User context data successfully collected for signUp"); } } catch (err) { // Don't fail sign-up if context collection fails debug?.("Failed to collect user context data for signUp:", err); } return fetch(cognitoIdpEndpoint.match(AWS_REGION_REGEXP) ? `https://cognito-idp.${cognitoIdpEndpoint}.amazonaws.com/` : cognitoIdpEndpoint, { headers: { "x-amz-target": "AWSCognitoIdentityProviderService.SignUp", "content-type": "application/x-amz-json-1.1", ...proxyApiHeaders, }, method: "POST", body: JSON.stringify({ Username: username, Password: password, UserAttributes: userAttributes && userAttributes.map(({ name, value }) => ({ Name: name, Value: value, })), ValidationData: validationData && validationData.map(({ name, value }) => ({ Name: name, Value: value, })), ClientMetadata: clientMetadata, ClientId: clientId, ...(clientSecret && { SecretHash: await calculateSecretHash(username), }), ...(userContextData && { UserContextData: userContextData }), }), signal: abort, }).then(throwIfNot2xx); } export async function updateUserAttributes({ clientMetadata, userAttributes, abort, accessToken, }) { const { fetch, cognitoIdpEndpoint, proxyApiHeaders } = configure(); const token = accessToken ?? (await retrieveTokens())?.accessToken; await fetch(cognitoIdpEndpoint.match(AWS_REGION_REGEXP) ? `https://cognito-idp.${cognitoIdpEndpoint}.amazonaws.com/` : cognitoIdpEndpoint, { headers: { "x-amz-target": "AWSCognitoIdentityProviderService.UpdateUserAttributes", "content-type": "application/x-amz-json-1.1", ...proxyApiHeaders, }, method: "POST", body: JSON.stringify({ AccessToken: token, ClientMetadata: clientMetadata, UserAttributes: userAttributes.map(({ name, value }) => ({ Name: name, Value: value, })), }), signal: abort, }).then(throwIfNot2xx); } export async function getUserAttributeVerificationCode({ attributeName, clientMetadata, abort, accessToken, }) { const { fetch, cognitoIdpEndpoint, proxyApiHeaders } = configure(); const token = accessToken ?? (await retrieveTokens())?.accessToken; await fetch(cognitoIdpEndpoint.match(AWS_REGION_REGEXP) ? `https://cognito-idp.${cognitoIdpEndpoint}.amazonaws.com/` : cognitoIdpEndpoint, { headers: { "x-amz-target": "AWSCognitoIdentityProviderService.GetUserAttributeVerificationCode", "content-type": "application/x-amz-json-1.1", ...proxyApiHeaders, }, method: "POST", body: JSON.stringify({ AccessToken: token, ClientMetadata: clientMetadata, AttributeName: attributeName, }), signal: abort, }).then(throwIfNot2xx); } export async function verifyUserAttribute({ attributeName, code, abort, accessToken, }) { const { fetch, cognitoIdpEndpoint, proxyApiHeaders } = configure(); const token = accessToken ?? (await retrieveTokens())?.accessToken; await fetch(cognitoIdpEndpoint.match(AWS_REGION_REGEXP) ? `https://cognito-idp.${cognitoIdpEndpoint}.amazonaws.com/` : cognitoIdpEndpoint, { headers: { "x-amz-target": "AWSCognitoIdentityProviderService.VerifyUserAttribute", "content-type": "application/x-amz-json-1.1", ...proxyApiHeaders, }, method: "POST", body: JSON.stringify({ AccessToken: token, AttributeName: attributeName, Code: code, }), signal: abort, }).then(throwIfNot2xx); } export async function setUserMFAPreference({ smsMfaSettings, softwareTokenMfaSettings, abort, accessToken, }) { const { fetch, cognitoIdpEndpoint, proxyApiHeaders } = configure(); const token = accessToken ?? (await retrieveTokens())?.accessToken; await fetch(cognitoIdpEndpoint.match(AWS_REGION_REGEXP) ? `https://cognito-idp.${cognitoIdpEndpoint}.amazonaws.com/` : cognitoIdpEndpoint, { headers: { "x-amz-target": "AWSCognitoIdentityProviderService.SetUserMFAPreference", "content-type": "application/x-amz-json-1.1", ...proxyApiHeaders, }, method: "POST", body: JSON.stringify({ AccessToken: token, SMSMfaSettings: smsMfaSettings && { Enabled: smsMfaSettings.enabled, PreferredMfa: smsMfaSettings.preferred, }, SoftwareTokenMfaSettings: softwareTokenMfaSettings && { Enabled: softwareTokenMfaSettings.enabled, PreferredMfa: softwareTokenMfaSettings.preferred, }, }), signal: abort, }).then(throwIfNot2xx); } export async function handleAuthResponse({ authResponse, username, smsMfaCode, otpMfaCode, newPassword, customChallengeAnswer, deviceHandler, clientMetadata, abort, }) { const { debug } = configure(); // IMPORTANT: Log the username being used for challenges debug?.(`Using username "${username}" for all auth challenges`); let currentDeviceHandler = deviceHandler; for (;;) { if (isAuthenticatedResponse(authResponse)) { let deviceKey = undefined; // Extract deviceKey if present in NewDeviceMetadata if (authResponse.AuthenticationResult.NewDeviceMetadata?.DeviceKey) { deviceKey = authResponse.AuthenticationResult.NewDeviceMetadata.DeviceKey; debug?.("Device key obtained from authentication result:", deviceKey); } else if (currentDeviceHandler?.deviceKey) { // If we're using a device key for authentication, keep track of it deviceKey = currentDeviceHandler.deviceKey; debug?.("Using device key from device handler:", deviceKey); } return { idToken: authResponse.AuthenticationResult.IdToken, accessToken: authResponse.AuthenticationResult.AccessToken, expireAt: new Date(Date.now() + authResponse.AuthenticationResult.ExpiresIn * 1000), refreshToken: authResponse.AuthenticationResult.RefreshToken, username, newDeviceMetadata: authResponse.AuthenticationResult.NewDeviceMetadata ? { deviceKey: authResponse.AuthenticationResult.NewDeviceMetadata.DeviceKey, deviceGroupKey: authResponse.AuthenticationResult.NewDeviceMetadata .DeviceGroupKey, } : undefined, deviceKey, }; } const responseParameters = {}; if (authResponse.ChallengeName === "SMS_MFA") { if (!smsMfaCode) throw new Error("Missing MFA Code"); responseParameters.SMS_MFA_CODE = await smsMfaCode(); } else if (authResponse.ChallengeName === "NEW_PASSWORD_REQUIRED") { if (!newPassword) throw new Error("Missing new password"); responseParameters.NEW_PASSWORD = await newPassword(); } else if (authResponse.ChallengeName === "CUSTOM_CHALLENGE") { if (!customChallengeAnswer) throw new Error("Missing custom challenge answer"); responseParameters.ANSWER = await customChallengeAnswer(); } else if (authResponse.ChallengeName === "SOFTWARE_TOKEN_MFA") { if (!otpMfaCode) throw new Error("Missing Software MFA Code"); responseParameters.SOFTWARE_TOKEN_MFA_CODE = await otpMfaCode(); // Log the MFA challenge response debug?.(`Responding to SOFTWARE_TOKEN_MFA challenge with USERNAME "${username}"`); } else if (authResponse.ChallengeName === "DEVICE_SRP_AUTH") { const deviceKey = authResponse.ChallengeParameters.DEVICE_KEY; debug?.("Handling DEVICE_SRP_AUTH challenge (step 1)"); // Ensure we have a handler if (!currentDeviceHandler) { const created = await createDeviceSrpAuthHandler(username, deviceKey); if (!created) { throw new Error("Failed to create device SRP handler - device password may be missing"); } currentDeviceHandler = created; } const { srpAHex } = await currentDeviceHandler.generateStep1(); // Build minimal response params responseParameters.DEVICE_KEY = currentDeviceHandler.deviceKey; responseParameters.SRP_A = srpAHex; } else if (authResponse.ChallengeName === "DEVICE_PASSWORD_VERIFIER") { // Get challenge parameters const srpB = authResponse.ChallengeParameters.SRP_B; const secretBlock = authResponse.ChallengeParameters.SECRET_BLOCK; const deviceKey = authResponse.ChallengeParameters.DEVICE_KEY; debug?.("Handling DEVICE_PASSWORD_VERIFIER challenge"); debug?.("DEVICE_PASSWORD_VERIFIER parameters:", { ChallengeParameters: authResponse.ChallengeParameters, }); // Ensure we have a handler (might have been established in previous step) if (!currentDeviceHandler) { const created = await createDeviceSrpAuthHandler(username, deviceKey); if (!created) { throw new Error("Failed to create device SRP handler - device password may be missing"); } currentDeviceHandler = created; } const deviceSrpResult = await currentDeviceHandler.generateStep2(srpB, secretBlock, authResponse.ChallengeParameters.SALT); responseParameters.DEVICE_KEY = currentDeviceHandler.deviceKey; responseParameters.PASSWORD_CLAIM_SIGNATURE = deviceSrpResult.passwordVerifier; responseParameters.PASSWORD_CLAIM_SECRET_BLOCK = deviceSrpResult.passwordClaimSecretBlock; responseParameters.TIMESTAMP = deviceSrpResult.timestamp; } else { throw new Error(`Unsupported challenge: ${authResponse.ChallengeName}`); } debug?.(`Invoking respondToAuthChallenge ...`); const nextAuthResult = await respondToAuthChallenge({ challengeName: authResponse.ChallengeName, challengeResponses: { USERNAME: username, // Always use the same username (USER_ID_FOR_SRP) from the initial auth ...responseParameters, }, clientMetadata, session: authResponse.Session, abort, }); debug?.(`Response from respondToAuthChallenge:`, nextAuthResult); authResponse = nextAuthResult; } } function extractInitiateAuthResponse(authflow) { return async (res) => { await throwIfNot2xx(res); const body = await res.json(); if (authflow === "REFRESH_TOKEN") { assertIsAuthenticatedResponse(body); } else { assertIsSignInResponse(body); } return body; }; } async function extractChallengeResponse(res) { await throwIfNot2xx(res); const body = await res.json(); assertIsSignInResponse(body); return body; } async function calculateSecretHash(username) { const { crypto, clientId, clientSecret } = configure(); username ?? (username = (await retrieveTokens())?.username); if (!username) { throw new Error("Failed to determine username for calculating secret hash"); } const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(clientSecret), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"]); const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${username}${clientId}`)); return bufferToBase64(signature); } /** * Resends the confirmation code to a user who has signed up but not confirmed their account. * Automatically collects and includes threat protection data when available. * * @param params - The parameters for resending the confirmation code. * @param params.username - The username or alias (e-mail, phone number) of the user. * @param [params.clientMetadata] - Additional metadata to be passed to the server. * @param [params.abort] - An optional AbortSignal object that can be used to abort the request. * @returns A promise that resolves to the response containing code delivery details. */ export async function resendConfirmationCode({ username, clientMetadata, abort, }) { const { fetch, cognitoIdpEndpoint, proxyApiHeaders, clientId, clientSecret } = configure(); // Security-forward approach: attempt to collect user context data by default let userContextData; try { // Use our security provider to get encoded data const securityProvider = CognitoSecurityProvider.getInstance(); const encodedData = await securityProvider.getSecurityData(username); if (encodedData) { userContextData = { EncodedData: encodedData, }; } } catch (err) { // Don't fail if context collection fails } return fetch(cognitoIdpEndpoint.match(AWS_REGION_REGEXP) ? `https://cognito-idp.${cognitoIdpEndpoint}.amazonaws.com/` : cognitoIdpEndpoint, { headers: { "x-amz-target": "AWSCognitoIdentityProviderService.ResendConfirmationCode", "content-type": "application/x-amz-json-1.1", ...proxyApiHeaders, }, method: "POST", body: JSON.stringify({ Username: username, ClientId: clientId, ClientMetadata: clientMetadata, ...(clientSecret && { SecretHash: await calculateSecretHash(username), }), ...(userContextData && { UserContextData: userContextData }), }), signal: abort, }) .then(throwIfNot2xx) .then((res) => res.json()); } /** * Sends a password-reset confirmation code to the user. * Automatically collects and includes threat protection data when available. * * @param params - The parameters for the forgot password request. * @param params.username - The username or alias (e-mail, phone number) of the user. * @param [params.clientMetadata] - Additional metadata to be passed to the server. * @param [params.abort] - An optional AbortSignal object that can be used to abort the request. * @returns A promise that resolves to the response containing code delivery details. */ export async function forgotPassword({ username, clientMetadata, abort, }) { const { fetch, cognitoIdpEndpoint, proxyApiHeaders, clientId, clientSecret } = configure(); // Security-forward approach: attempt to collect user context data by default let userContextData; try { // Use our security provider to get encoded data const securityProvider = CognitoSecurityProvider.getInstance(); const encodedData = await securityProvider.getSecurityData(username); if (encodedData) { userContextData = { EncodedData: encodedData, }; } } catch (err) { // Don't fail if context collection fails } return fetch(cognitoIdpEndpoint.match(AWS_REGION_REGEXP) ? `https://cognito-idp.${cognitoIdpEndpoint}.amazonaws.com/` : cognitoIdpEndpoint, { headers: { "x-amz-target": "AWSCognitoIdentityProviderService.ForgotPassword", "content-type": "application/x-amz-json-1.1", ...proxyApiHeaders, }, method: "POST", body: JSON.stringify({ Username: username, ClientId: clientId, ClientMetadata: clientMetadata, ...(clientSecret && { SecretHash: await calculateSecretHash(username), }), ...(userContextData && { UserContextData: userContextData }), }), signal: abort, }) .then(throwIfNot2xx) .then((res) => res.json()); } /** * Completes the password reset process by validating the confirmation code and setting a new password. * Automatically collects and includes threat protection data when available. * * @param params - The parameters for confirming the forgot password request. * @param params.username - The username or alias (e-mail, phone number) of the user. * @param params.confirmationCode - The confirmation code sent to the user. * @param params.password - The new password for the user. * @param [params.clientMetadata] - Additional metadata to be passed to the server. * @param [params.abort] - An optional AbortSignal object that can be used to abort the request. * @returns A promise that resolves when the password has been successfully reset. */ export async function confirmForgotPassword({ username, confirmationCode, password, clientMetadata, abort, }) { const { fetch, cognitoIdpEndpoint, proxyApiHeaders, clientId, clientSecret } = configure(); // Security-forward approach: attempt to collect user context data by default let userContextData; try { // Use our security provider to get encoded data const securityProvider = CognitoSecurityProvider.getInstance(); const encodedData = await securityProvider.getSecurityData(username); if (encodedData) { userContextData = { EncodedData: encodedData, }; } } catch (err) { // Don't fail if context collection fails } return fetch(cognitoIdpEndpoint.match(AWS_REGION_REGEXP) ? `https://cognito-idp.${cognitoIdpEndpoint}.amazonaws.com/` : cognitoIdpEndpoint, { headers: { "x-amz-target": "AWSCognitoIdentityProviderService.ConfirmForgotPassword", "content-type": "application/x-amz-json-1.1", ...proxyApiHeaders, }, method: "POST", body: JSON.stringify({ Username: username, ConfirmationCode: confirmationCode, Password: password, ClientId: clientId, ClientMetadata: clientMetadata, ...(clientSecret && { SecretHash: await calculateSecretHash(username), }), ...(userContextData && { UserContextData: userContextData }), }), signal: abort, }).then(throwIfNot2xx); } /** * Changes the password for a signed-in user. * Requires a valid access token from the signed-in user. * * @param params - The parameters for changing the password. * @param params.accessToken - A valid access token for the signed-in user. * @param params.previousPassword - The user's current password. * @param params.proposedPassword - The new password. * @param [params.abort] - An optional AbortSignal object that can be used to abort the request. * @returns A promise that resolves when the password has been successfully changed. */ export async function changePassword({ accessToken, previousPassword, proposedPassword, abort, }) { const { fetch, cognitoIdpEndpoint, proxyApiHeaders } = configure(); return fetch(cognitoIdpEndpoint.match(AWS_REGION_REGEXP) ? `https://cognito-idp.${cognitoIdpEndpoint}.amazonaws.com/` : cognitoIdpEndpoint, { headers: { "x-amz-target": "AWSCognitoIdentityProviderService.ChangePassword", "content-type": "application/x-amz-json-1.1", ...proxyApiHeaders, }, method: "POST", body: JSON.stringify({ AccessToken: accessToken, PreviousPassword: previousPassword, ProposedPassword: proposedPassword, }), signal: abort, }).then(throwIfNot2xx); } /** * Changes the password for the currently signed-in user, using the stored access token. * This is a convenience method that automatically uses the access token from storage. * * @param params - The parameters for changing the password. * @param params.previousPassword - The user's current password. * @param params.proposedPassword - The new password. * @param [params.abort] - An optional AbortSignal object that can be used to abort the request. * @returns A promise that resolves when the password has been successfully changed. */ export async function changePasswordForCurrentUser({ previousPassword, proposedPassword, abort, }) { const tokens = await retrieveTokens(); if (!tokens?.accessToken) { throw new Error("No access token available. User must be signed in to change password."); } return changePassword({ accessToken: tokens.accessToken, previousPassword, proposedPassword, abort, }); } /** * Begins setup of time-based one-time password (TOTP) multi-factor authentication (MFA) for a user. * Returns a unique private key that can be used with authenticator apps like Google Authenticator or Authy. * * @param params - The parameters for associating a software token. * @param [params.accessToken] - A valid access token for the signed-in user. Required if session is not provided. * @param [params.session] - A session string from a challenge response. Required if accessToken is not provided. * @param [params.abort] - An optional AbortSignal object that can be used to abort the request. * @returns A promise that resolves to the response containing the secret code and session. */ export async function associateSoftwareToken({ accessToken, session, abort, }) { const { fetch, cognitoIdpEndpoint, proxyApiHeaders } = configure(); if (!accessToken && !session) { throw new Error("Either accessToken or session must be provided"); } return fetch(cognitoIdpEndpoint.match(AWS_REGION_REGEXP) ? `https://cognito-idp.${cognitoIdpEndpoint}.amazonaws.com/` : cognitoIdpEndpoint, { headers: { "x-amz-target": "AWSCognitoIdentityProviderService.AssociateSoftwareToken", "content-type": "application/x-amz-json-1.1", ...proxyApiHeaders, }, method: "POST", body: JSON.stringify({ ...(accessToken && { AccessToken: accessToken }), ...(session && { Session: session }), }), signal: abort, }) .then(throwIfNot2xx) .then((res) => res.json()); } /** * Verifies the time-based one-time password (TOTP) multi-factor authentication (MFA) setup for a user. * This should be called after associateSoftwareToken to complete the MFA setup. * * @param params - The parameters for verifying a software token. * @param params.userCode - The time-based one-time password that the user provides from their authenticator app. * @param [params.accessToken] - A valid access token for the signed-in user. Required if session is not provided. * @param [params.session] - A session string from associateSoftwareToken or a challenge response. Required if accessToken is not provided. * @param [params.friendlyDeviceName] - A friendly name for the device that will be generating TOTP codes. * @param [params.abort] - An optional AbortSignal object that can be used to abort the request. * @returns A promise that resolves to the response containing the status of the verification. */ export async function verifySoftwareToken({ userCode, accessToken, session, friendlyDeviceName, abort, }) { const { fetch, cognitoIdpEndpoint, proxyApiHeaders } = configure(); if (!accessToken && !session) { throw new Error("Either accessToken or session must be provided"); } return fetch(cognitoIdpEndpoint.match(AWS_REGION_REGEXP) ? `https://cognito-idp.${cognitoIdpEndpoint}.amazonaws.com/` : cognitoIdpEndpoint, { headers: { "x-amz-target": "AWSCognitoIdentityProviderService.VerifySoftwareToken", "content-type": "application/x-amz-json-1.1", ...proxyApiHeaders, }, method: "POST", body: JSON.stringify({ UserCode: userCode, ...(accessToken && { AccessToken: accessToken }), ...(session && { Session: session }), ...(friendlyDeviceName && { FriendlyDeviceName: friendlyDeviceName }), }), signal: abort, }) .then(throwIfNot2xx) .then((res) => res.json()); } /** * Convenience method for beginning TOTP MFA setup for the currently signed-in user. * Automatically uses the stored access token. * * @param [params.abort] - An optional AbortSignal object that can be used to abort the request. * @returns A promise that resolves to the response containing the secret code. */ export async function associateSoftwareTokenForCurrentUser({ abort, } = {}) { const tokens = await retrieveTokens(); if (!tokens?.accessToken) { throw new Error("No access token available. User must be signed in to set up TOTP MFA."); } return associateSoftwareToken({ accessToken: tokens.accessToken, abort, }); } /** * Convenience method for verifying TOTP MFA setup for the currently signed-in user. * Automatically uses the stored access token. * * @param params.userCode - The time-based one-time password that the user provides from their authenticator app. * @param [params.friendlyDeviceName] - A friendly name for the device that will be generating TOTP codes. * @param [params.abort] - An optional AbortSignal object that can be used to abort the request. * @returns A promise that resolves to the response containing the status of the verification. */ export async function verifySoftwareTokenForCurrentUser({ userCode, friendlyDeviceName, abort, }) { const tokens = await retrieveTokens(); if (!tokens?.accessToken) { throw new Error("No access token available. User must be signed in to verify TOTP MFA."); } return verifySoftwareToken({ userCode, accessToken: tokens.accessToken, friendlyDeviceName, abort, }); } /** * Confirms a device to be tracked for a user. This allows for "Remember this device" functionality. * For remembered devices, MFA challenges can be skipped on subsequent sign-ins. * * @param params - The parameters for confirming a device. * @param params.accessToken - A valid access token for the signed-in user. * @param params.deviceKey - The device key returned during authentication. * @param params.deviceName - A friendly name for the device. * @param params.deviceSecretVerifierConfig - The SRP configuration for the device. * @param [params.abort] - An optional AbortSignal object that can be used to abort the request. * @returns A promise that resolves to the response indicating if user confirmation is necessary. */ export async function confirmDevice({ accessToken, deviceKey, deviceName, deviceSecretVerifierConfig, abort, }) { const { fetch, cognitoIdpEndpoint, proxyApiHeaders, debug } = configure(); debug?.("📱 [Confirm Device] Initiating device confirmation API call"); debug?.("📱 [Confirm Device] Device key:", deviceKey); debug?.("📱 [Confirm Device] Device name:", deviceName || "Not provided"); // Validate device key format before making the API call if (!deviceKey.includes("_")) { debug?.("❌ [Confirm Device] Invalid device key format (missing underscore):", deviceKey); throw new Error("Invalid device key format: missing region_uuid format"); } const [region, uuid] = deviceKey.split("_"); if (!region || !uuid) { debug?.("❌ [Confirm Device] Device key missing region or UUID part"); throw new Error("Invalid device key: missing region or UUID part"); } // Check that UUID looks like a valid UUID (basic validation) if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(uuid)) { debug?.("❌ [Confirm Device] Device key contains invalid UUID format:", uuid); throw new Error("Invalid device key: UUID part has invalid format"); } // Log verifier details for debugging debug?.("📱 [Confirm Device] Password verifier length:", deviceSecretVerifierConfig.passwordVerifier.length); debug?.("📱 [Confirm Device] Salt length:", deviceSecretVerifierConfig.salt.length); try { debug?.("📱 [Confirm Device] Sending API request to ConfirmDevice endpoint"); const requestBody = { AccessToken: accessToken, DeviceKey: deviceKey, DeviceSecretVerifierConfig: { PasswordVerifier: deviceSecretVerifierConfig.passwordVerifier, Salt: deviceSecretVerifierConfig.salt, }, ...(deviceName && { DeviceName: deviceName }), }; debug?.("📱 [Confirm Device] Request structure:", JSON.stringify({ hasAccessToken: !!accessToken, accessTokenLength: accessToken.length, deviceKey, hasDeviceName: !!deviceName, verifierLength: deviceSecretVerifierConfig.passwordVerifier.length, saltLength: deviceSecretVerifierConfig.salt.length, })); const response = await fetch(cognitoIdpEndpoint.match(AWS_REGION_REGEXP) ? `https://cognito-idp.${cognitoIdpEndpoint}.amazonaws.com/` : cognitoIdpEndpoint, { headers: { "x-amz-target": "AWSCognitoIdentityProviderService.ConfirmDevice", "content-type": "application/x-amz-json-1.1", ...proxyApiHeaders, }, method: "POST", body: JSON.stringify(requestBody), signal: abort, }); // Parse response JSON const resOk = await throwIfNot2xx(response); const json = await resOk.json(); assertIsNotErrorResponse(json); debug?.("✅ [Confirm Device] Device confirmation API call successful"); debug?.("📱 [Confirm Device] Response:", JSON.stringify(json)); return json; } catch (error) { debug?.("❌ [Confirm Device] Device confirmation API call failed:", error); // Add detailed error message const errorMessage = error instanceof Error ? error.message : String(error); debug?.("❌ [Confirm Device] Error details:", errorMessage); throw error; } } /** * Updates the device status for a user's device. * This is typically called after confirmDevice if the user is prompted to remember the device. * * @param params - The parameters for updating device status. * @param params.accessToken - A valid access token for the signed-in user. * @param params.deviceKey - The device key returned during authentication. * @param params.deviceRememberedStatus - The remembered status of the device (remembered or not_remembered). * @param [params.abort] - An optional AbortSignal object that can be used