@joinmeow/cognito-passwordless-auth
Version:
Passwordless authentication with Amazon Cognito: FIDO2 (WebAuthn, support for Passkeys)
317 lines (316 loc) • 13.6 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 { 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, };