UNPKG

@joinmeow/cognito-passwordless-auth

Version:

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

263 lines (262 loc) 12 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 { configure } from "./config.js"; import { bufferToBase64 } from "./util.js"; import { modPow, getConstants, hexToArrayBuffer, arrayBufferToHex, arrayBufferToBigInt, padHex, generateSmallA, calculateLargeAHex, verifyDeviceSrp, } from "./srp.js"; import { storeDeviceKey, setRememberedDevice, getRememberedDevice, } from "./storage.js"; import { confirmDevice } from "./cognito-api.js"; // Helper function to get device info for naming function getDeviceName() { if (typeof navigator === "undefined") { return "Unknown Device"; } const ua = navigator.userAgent; // Get OS type let os = "Unknown"; if (ua.includes("iPhone")) os = "iPhone"; else if (ua.includes("iPad")) os = "iPad"; else if (ua.includes("Android")) os = "Android"; else if (ua.includes("Windows")) os = "Windows"; else if (ua.includes("Mac")) os = "Mac"; else if (ua.includes("Linux")) os = "Linux"; // Get browser type let browser = ""; if (ua.includes("Chrome") && !ua.includes("Edg")) browser = "Chrome"; else if (ua.includes("Firefox")) browser = "Firefox"; else if (ua.includes("Safari") && !ua.includes("Chrome")) browser = "Safari"; else if (ua.includes("Edg")) browser = "Edge"; return browser ? `${os} ${browser}` : os; } /** * Generate a secure random device password for SRP calculations * @returns Base64-encoded random password */ async function generateDevicePassword() { const { crypto } = configure(); // Generate a random 40-byte password as used in the Python example const randomPasswordBuffer = new Uint8Array(40); crypto.getRandomValues(randomPasswordBuffer); return bufferToBase64(randomPasswordBuffer); } /** * Calculate SRP verification values for device confirmation * This follows the same pattern as the Python example */ async function calculateDeviceVerifier(_username, deviceKey, deviceGroupKey, devicePassword) { const { crypto } = configure(); // Generate salt const saltBuffer = new Uint8Array(16); crypto.getRandomValues(saltBuffer); // Ensure first bit is not set (making it positive) saltBuffer[0] = saltBuffer[0] & 0x7f; const salt = bufferToBase64(saltBuffer); // Create FULL_PASSWORD = SHA256_HASH(DeviceGroupKey + deviceKey + ":" + DEVICE_PASSWORD) const fullPasswordString = `${deviceGroupKey}${deviceKey}:${devicePassword}`; const fullPasswordHash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(fullPasswordString)); // Create x = SHA256_HASH(salt + FULL_PASSWORD) const saltHex = arrayBufferToHex(saltBuffer); const saltAndPassword = await new Blob([ hexToArrayBuffer(padHex(saltHex)), fullPasswordHash, ]).arrayBuffer(); const x = await crypto.subtle.digest("SHA-256", saltAndPassword); // Get SRP constants const { g, N } = await getConstants(); // Calculate the password verifier (g^x % N) const passwordVerifierBigInt = modPow(g, arrayBufferToBigInt(x), N); const passwordVerifierHex = padHex(passwordVerifierBigInt.toString(16)); // Convert to the required format for Cognito const passwordVerifierBytes = hexToArrayBuffer(passwordVerifierHex); const passwordVerifier = bufferToBase64(passwordVerifierBytes); return { passwordVerifier, salt, }; } /** * Create a device handler for SRP authentication with a device * Used for DEVICE_SRP_AUTH and DEVICE_PASSWORD_VERIFIER challenges */ export async function createDeviceSrpAuthHandler(username, deviceKey) { const { debug } = configure(); const record = await getRememberedDevice(username); if (!record || record.deviceKey !== deviceKey) { debug?.(`No remembered device record matching device key ${deviceKey} for user ${username}`); return undefined; } const { password: devicePassword, groupKey: deviceGroupKey } = record; // state kept between the two steps const smallA = generateSmallA(); let bigAHex; return { deviceKey, generateStep1: async () => { debug?.("🔄 [Device SRP] Starting Step 1: DEVICE_SRP_AUTH challenge"); // generate random 'a' and A bigAHex = await calculateLargeAHex(smallA); debug?.("🚀 [Device SRP] Generated SRP_A", bigAHex); debug?.("✅ [Device SRP] Step 1 completed successfully"); return { srpAHex: bigAHex }; }, generateStep2: async (srpB, secretBlock, salt) => { debug?.("🔄 [Device SRP] Starting Step 2: DEVICE_PASSWORD_VERIFIER challenge"); if (!bigAHex) { debug?.("❌ [Device SRP] Step 2 failed: generateStep1 was not called first"); throw new Error("generateStep2 called before generateStep1"); } if (!salt) { debug?.("❌ [Device SRP] Step 2 failed: Missing salt parameter"); throw new Error("Missing salt for DEVICE_PASSWORD_VERIFIER"); } debug?.(`🔑 [Device SRP] Salt: ${salt}`); debug?.(`🔑 [Device SRP] Secret Block: ${secretBlock}`); const result = await verifyDeviceSrp({ deviceGroupKey, deviceKey, devicePassword, srpB, secretBlock, salt, smallA, srpAHex: bigAHex, }); debug?.("✅ [Device SRP] Step 2 completed successfully"); return result; }, }; } /** * Automatically handle device confirmation for authentication flows when NewDeviceMetadata is present. * This confirms the device using the device key provided in the authentication response. * We NEVER generate a device key - only use what Cognito provides. * * @param tokens The tokens from sign-in with newDeviceMetadata * @param deviceName Optional device name, defaults to auto-detected device type * @returns The updated tokens with deviceKey set and a userConfirmationNecessary flag */ export async function handleDeviceConfirmation(tokens, deviceName) { const { debug, userPoolId } = configure(); debug?.("🔍 [Device Confirmation] Starting device confirmation process"); // We MUST have newDeviceMetadata with a deviceKey to confirm a device if (!tokens.newDeviceMetadata?.deviceKey) { debug?.("🔍 [Device Confirmation] No new device metadata present, skipping device confirmation"); return tokens; } const deviceKey = tokens.newDeviceMetadata.deviceKey; const deviceGroupKey = tokens.newDeviceMetadata.deviceGroupKey; debug?.("🔍 [Device Confirmation] Device metadata received:", JSON.stringify({ deviceKey, deviceGroupKey, })); // Validate device key format if (!deviceKey.includes("_")) { debug?.("❌ [Device Confirmation] Invalid device key format (missing underscore): " + deviceKey); // Just return tokens without attempting confirmation tokens.deviceKey = deviceKey; return tokens; } // Extract and validate the region and UUID parts const [region, uuid] = deviceKey.split("_"); debug?.("🔍 [Device Confirmation] Device key components: region=" + region + ", uuid=" + uuid); if (!region || !uuid) { debug?.("❌ [Device Confirmation] Device key missing region or UUID part"); tokens.deviceKey = deviceKey; return tokens; } if (!tokens.accessToken) { debug?.("❌ [Device Confirmation] Missing access token required for device confirmation"); throw new Error("Missing access token required for device confirmation"); } if (!userPoolId) { debug?.("❌ [Device Confirmation] UserPoolId must be configured for device confirmation"); throw new Error("UserPoolId must be configured for device confirmation"); } // Use provided name or detect device type const finalDeviceName = deviceName || getDeviceName(); debug?.("🔍 [Device Confirmation] Using device name:", finalDeviceName); try { debug?.("🔍 [Device Confirmation] Generating device password"); const devicePassword = await generateDevicePassword(); debug?.("🔍 [Device Confirmation] Calculating device verifier using SRP"); const deviceVerifierConfig = await calculateDeviceVerifier(tokens.username, deviceKey, deviceGroupKey, devicePassword); debug?.("🔍 [Device Confirmation] Device verifier config created using SRP calculation"); debug?.("🔍 [Device Confirmation] Device verifier config:", deviceVerifierConfig); debug?.("🔍 [Device Confirmation] Calling confirmDevice API with the device key"); debug?.(`🔍 [Device Confirmation] Access token length: ${tokens.accessToken.length}`); debug?.(`🔍 [Device Confirmation] Request details: - deviceKey: ${deviceKey} - deviceName: ${finalDeviceName} - passwordVerifier length: ${deviceVerifierConfig.passwordVerifier.length} - salt length: ${deviceVerifierConfig.salt.length} - username: ${tokens.username} `); // Call confirmDevice with the device key const result = await confirmDevice({ accessToken: tokens.accessToken, deviceKey, deviceName: finalDeviceName, deviceSecretVerifierConfig: deviceVerifierConfig, }); debug?.("✅ [Device Confirmation] Device confirmation successful, result:", JSON.stringify(result)); // Note whether user confirmation is necessary if (result.UserConfirmationNecessary) { debug?.("🔍 [Device Confirmation] User confirmation necessary for device. Application should ask user if they want to remember this device."); } else { debug?.("🔍 [Device Confirmation] Device automatically remembered based on user pool settings."); } // Set the deviceKey in the tokens tokens.deviceKey = deviceKey; debug?.("🔍 [Device Confirmation] Storing device key and remembered status"); // Build or replace single remembered-device record for this user await setRememberedDevice(tokens.username, { deviceKey, groupKey: deviceGroupKey, password: devicePassword, remembered: !result.UserConfirmationNecessary, }); // Store device key for convenience (legacy behaviour) await storeDeviceKey(tokens.username, deviceKey); debug?.("✅ [Device Confirmation] Device confirmation completed successfully"); return { ...tokens, userConfirmationNecessary: result.UserConfirmationNecessary, }; } catch (error) { debug?.("❌ [Device Confirmation] Error during device confirmation:", error); const errorMsg = error instanceof Error ? error.message : String(error); debug?.(`❌ [Device Confirmation] Error details: ${errorMsg}`); // If device confirmation fails, we still set the deviceKey on the tokens object // for the current session, but DON'T store it in persistent storage // as it may be invalid for future authentication attempts tokens.deviceKey = deviceKey; return tokens; } }