UNPKG

@joinmeow/cognito-passwordless-auth

Version:

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

317 lines (316 loc) 13.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 { configure } from "./config.js"; import { initiateAuth, respondToAuthChallenge, assertIsChallengeResponse, isAuthenticatedResponse, handleAuthResponse, } from "./cognito-api.js"; import { processTokens } from "./common.js"; import { bufferFromBase64, bufferToBase64 } from "./util.js"; import { retrieveDeviceKey } from "./storage.js"; import { createDeviceSrpAuthHandler } from "./device.js"; let _CONSTANTS; async function getConstants() { if (!_CONSTANTS) { const g = BigInt(2); const N = BigInt("0x" + "FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD1" + "29024E088A67CC74020BBEA63B139B22514A08798E3404DD" + "EF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245" + "E485B576625E7EC6F44C42E9A637ED6B0BFF5CB6F406B7ED" + "EE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3D" + "C2007CB8A163BF0598DA48361C55D39A69163FA8FD24CF5F" + "83655D23DCA3AD961C62F356208552BB9ED529077096966D" + "670C354E4ABC9804F1746C08CA18217C32905E462E36CE3B" + "E39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9" + "DE2BCBF6955817183995497CEA956AE515D2261898FA0510" + "15728E5A8AAAC42DAD33170D04507A33A85521ABDF1CBA64" + "ECFB850458DBEF0A8AEA71575D060C7DB3970F85A6E1E4C7" + "ABF5AE8CDB0933D71E8C94E04A25619DCEE3D2261AD2EE6B" + "F12FFA06D98A0864D87602733EC86A64521F2B18177B200C" + "BBE117577A615D6C770988C0BAD946E208E24FA074E5AB31" + "43DB5BFCE0FD108E4B82D120A93AD2CAFFFFFFFFFFFFFFFF"); const { crypto } = configure(); const k = arrayBufferToBigInt(await crypto.subtle.digest("SHA-256", hexToArrayBuffer(`${padHex(N.toString(16))}${padHex(g.toString(16))}`))); _CONSTANTS = { g, N, k, }; } return _CONSTANTS; } /** * modulo that works on negative bases too */ function modulo(base, mod) { return ((base % mod) + mod) % mod; } function modPow(base, exp, mod) { // Calculate: (base ** exp) % mod let result = BigInt(1); let x = modulo(base, mod); while (exp > BigInt(0)) { if (modulo(exp, BigInt(2))) { result = modulo(result * x, mod); } exp = exp / BigInt(2); x = modulo(x * x, mod); } return result; } function padHex(hexStr) { hexStr = hexStr.length % 2 ? `0${hexStr}` : hexStr; hexStr = parseInt(hexStr.slice(0, 2), 16) >> 7 ? `00${hexStr}` : hexStr; return hexStr; } function generateSmallA() { const { crypto } = configure(); const randomValues = new Uint8Array(128); crypto.getRandomValues(randomValues); return arrayBufferToBigInt(randomValues.buffer); } async function calculateLargeAHex(smallA) { const { g, N } = await getConstants(); return modPow(g, smallA, N).toString(16); } async function calculateSrpSignature({ smallA, largeAHex, srpBHex, salt, secretBlock, creds, }) { const { crypto } = configure(); // ---- 1. scramble parameter (u) ---- const aPlusBHex = padHex(largeAHex) + padHex(srpBHex); const uBuf = await crypto.subtle.digest("SHA-256", hexToArrayBuffer(aPlusBHex)); // ---- 2. credential-specific hash (x) ---- let identityHash; let identityPart1; // userPoolName OR deviceGroupKey let identityPart2; // username OR deviceKey if (creds.userPoolId !== undefined) { const { userPoolId, username, password } = creds; const [, userPoolName] = userPoolId.split("_"); identityPart1 = userPoolName; identityPart2 = username; identityHash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`${userPoolName}${username}:${password}`)); } else { const { deviceGroupKey, deviceKey, devicePassword } = creds; identityPart1 = deviceGroupKey; identityPart2 = deviceKey; identityHash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(`${deviceGroupKey}${deviceKey}:${devicePassword}`)); } const xBuf = await crypto.subtle.digest("SHA-256", await new Blob([hexToArrayBuffer(padHex(salt)), identityHash]).arrayBuffer()); // ---- 3. shared secret (S) ---- const { g, N, k } = await getConstants(); const gModPowXN = modPow(g, arrayBufferToBigInt(xBuf), N); const int = BigInt(`0x${srpBHex}`) - k * gModPowXN; const s = modPow(int, smallA + arrayBufferToBigInt(uBuf) * arrayBufferToBigInt(xBuf), N); // ---- 4. HKDF ---- const ikmHex = padHex(s.toString(16)); const saltHkdfHex = padHex(arrayBufferToHex(uBuf)); const infoBits = new Uint8Array([ ..."Caldera Derived Key".split("").map((c) => c.charCodeAt(0)), 1, ]).buffer; const prkKey = await crypto.subtle.importKey("raw", hexToArrayBuffer(saltHkdfHex), { name: "HMAC", hash: { name: "SHA-256" }, }, false, ["sign"]); const prk = await crypto.subtle.sign("HMAC", prkKey, hexToArrayBuffer(ikmHex)); const hkdfKey = await crypto.subtle.importKey("raw", prk, { name: "HMAC", hash: { name: "SHA-256" }, }, false, ["sign"]); const hkdf = (await crypto.subtle.sign("HMAC", hkdfKey, infoBits)).slice(0, 16); // ---- 5. signature ---- const timestamp = formatDate(new Date()); const parts = [ identityPart1.split("").map((c) => c.charCodeAt(0)), identityPart2.split("").map((c) => c.charCodeAt(0)), ...bufferFromBase64(secretBlock), timestamp.split("").map((c) => c.charCodeAt(0)), ].flat(); const msg = new Uint8Array(parts).buffer; const signatureKey = await crypto.subtle.importKey("raw", hkdf, { name: "HMAC", hash: { name: "SHA-256" }, }, false, ["sign"]); const signatureString = await crypto.subtle.sign("HMAC", signatureKey, msg); return { timestamp, passwordClaimSignature: bufferToBase64(signatureString), }; } function hexToArrayBuffer(hexStr) { if (hexStr.length % 2 !== 0) { throw new Error("hex string should have even number of characters"); } const octets = hexStr.match(/.{2}/gi).map((m) => parseInt(m, 16)); return new Uint8Array(octets); } function arrayBufferToHex(arrBuf) { return [...new Uint8Array(arrBuf)] .map((x) => x.toString(16).padStart(2, "0")) .join(""); } function arrayBufferToBigInt(arrBuf) { return BigInt(`0x${arrayBufferToHex(arrBuf)}`); } function formatDate(d) { const parts = new Intl.DateTimeFormat("en-u-hc-h23", { weekday: "short", year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit", second: "2-digit", timeZone: "UTC", timeZoneName: "short", hour12: false, }).formatToParts(d); const p = (type) => parts.find((part) => part.type === type)?.value; return [ p("weekday"), p("month"), p("day"), [p("hour"), p("minute"), p("second")].join(":"), p("timeZoneName"), p("year"), ].join(" "); } export function authenticateWithSRP({ username, password, smsMfaCode, otpMfaCode, newPassword, customChallengeAnswer, deviceKey, tokensCb, statusCb, clientMetadata, }) { const { userPoolId, debug } = configure(); if (!userPoolId) { throw new Error("UserPoolId must be configured"); } const abort = new AbortController(); const signedIn = (async () => { try { statusCb?.("SIGNING_IN_WITH_PASSWORD"); const smallA = generateSmallA(); const largeAHex = await calculateLargeAHex(smallA); debug?.(`Invoking initiateAuth without device key...`); const challenge = await initiateAuth({ authflow: "USER_SRP_AUTH", authParameters: { SRP_A: largeAHex, USERNAME: username, CHALLENGE_NAME: "SRP_A", }, clientMetadata, abort: abort.signal, }); debug?.(`Response from initiateAuth:`, challenge); assertIsChallengeResponse(challenge); const { SALT: saltHex, SRP_B: srpBHex, SECRET_BLOCK: secretBlockB64, USER_ID_FOR_SRP: userIdForSrp, } = challenge.ChallengeParameters; // CRITICAL: Store the USER_ID_FOR_SRP value to use throughout the entire auth flow debug?.(`Using USER_ID_FOR_SRP (${userIdForSrp}) for all authentication challenges`); // Now that we have initiated auth and have userIdForSrp, we can retrieve the device key // for this specific user const actualDeviceKey = deviceKey ?? (await retrieveDeviceKey(userIdForSrp)); // Pre-create a device SRP handler if we have a device key const deviceHandler = actualDeviceKey ? await createDeviceSrpAuthHandler(userIdForSrp, actualDeviceKey) : undefined; const { passwordClaimSignature, timestamp } = await calculateSrpSignature({ smallA, largeAHex, srpBHex, salt: saltHex, secretBlock: secretBlockB64, creds: { userPoolId, username: userIdForSrp, password, }, }); debug?.(`Invoking respondToAuthChallenge ...`); // Include device key in challenge response if available const challengeResponses = { USERNAME: userIdForSrp, // Use userIdForSrp instead of the original username PASSWORD_CLAIM_SECRET_BLOCK: secretBlockB64, TIMESTAMP: timestamp, PASSWORD_CLAIM_SIGNATURE: passwordClaimSignature, }; // Include the device key if it's available, regardless of remembered status // AWS documentation indicates the device key should be provided if available if (actualDeviceKey) { debug?.(`Including device key in authentication: ${actualDeviceKey}`); challengeResponses.DEVICE_KEY = actualDeviceKey; } const authResult = await respondToAuthChallenge({ challengeName: challenge.ChallengeName, challengeResponses, clientMetadata, session: challenge.Session, abort: abort.signal, }); debug?.(`Response from respondToAuthChallenge:`, authResult); // Handle any authentication challenges // Pass userIdForSrp instead of original username to fix device confirmation const tokens = await handleAuthResponse({ authResponse: authResult, username: userIdForSrp, // Use userIdForSrp instead of the original username smsMfaCode, otpMfaCode, newPassword, customChallengeAnswer, deviceHandler, clientMetadata, abort: abort.signal, }); // Check for new device metadata in the response if (isAuthenticatedResponse(authResult) && authResult.AuthenticationResult.NewDeviceMetadata) { debug?.("Got new device metadata in authentication response. This can be used for device authentication in future requests."); } // Always process tokens first - this handles device confirmation, storage, and refresh scheduling const processedTokens = (await processTokens({ ...tokens, authMethod: "SRP", // Explicitly set auth method for SRP auth }, abort.signal)); // Then call the custom tokensCb if provided (for application-specific needs only) if (tokensCb) { await tokensCb(processedTokens); } statusCb?.("SIGNED_IN_WITH_SRP_PASSWORD"); return processedTokens; } catch (err) { statusCb?.("PASSWORD_SIGNIN_FAILED"); throw err; } })(); return { signedIn, abort: () => abort.abort(), }; } async function verifyDeviceSrp({ deviceGroupKey, deviceKey, devicePassword, srpB, secretBlock, salt, smallA, srpAHex, }) { const { passwordClaimSignature, timestamp } = await calculateSrpSignature({ smallA, largeAHex: srpAHex, srpBHex: srpB, salt, secretBlock, creds: { deviceGroupKey, deviceKey, devicePassword, }, }); return { passwordVerifier: passwordClaimSignature, passwordClaimSecretBlock: secretBlock, timestamp, }; } // Helper functions that need to be exported export { modPow, getConstants, padHex, hexToArrayBuffer, arrayBufferToHex, arrayBufferToBigInt, formatDate, generateSmallA, calculateLargeAHex, calculateSrpSignature, verifyDeviceSrp, };