@joinmeow/cognito-passwordless-auth
Version:
Passwordless authentication with Amazon Cognito: FIDO2 (WebAuthn, support for Passkeys)
263 lines (262 loc) • 12 kB
JavaScript
/**
* 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;
}
}