UNPKG

@joinmeow/cognito-passwordless-auth

Version:

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

209 lines (208 loc) 7.41 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"; /** * Custom error class for lock acquisition timeouts */ export class LockTimeoutError extends Error { constructor(key, timeout) { super(`Timeout acquiring lock '${key}' after ${timeout}ms`); this.name = "LockTimeoutError"; } } const DEFAULT_RETRY_DELAY_MS = 50; const DEFAULT_TIMEOUT_MS = 15000; // 15 seconds to accommodate worst-case refresh (9s) + buffer const STALE_LOCK_TIMEOUT_MS = 30000; // 30 seconds function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * Generate a unique lock ID */ function generateLockId() { if (typeof globalThis.crypto !== "undefined" && globalThis.crypto.randomUUID) { return globalThis.crypto.randomUUID(); } return `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; } /** * Parse lock data from storage value */ function parseLockData(value) { if (!value) return null; try { const data = JSON.parse(value); if (data && typeof data === "object" && "id" in data && typeof data.id === "string" && "timestamp" in data && typeof data.timestamp === "number") { return data; } } catch { // Handle legacy locks if (value === "true") { return { id: "legacy", timestamp: 0 }; } } return null; } /** * Check if a lock is stale */ function isLockStale(lockData) { const age = Date.now() - lockData.timestamp; return age > STALE_LOCK_TIMEOUT_MS; } /** * Simplified storage-based lock without in-process queue */ export async function withStorageLock(key, fn, timeoutMs = DEFAULT_TIMEOUT_MS, abort) { const { storage, debug } = configure(); debug?.("withStorageLock: attempting to acquire lock", key, { timeoutMs }); // Check for abort signal before starting if (abort?.aborted) { throw new DOMException("Operation aborted", "AbortError"); } const start = Date.now(); let lockReleased = false; const isBrowser = typeof globalThis !== "undefined" && typeof globalThis.addEventListener === "function"; let onStorage = () => { }; // Generate unique lock ID for this attempt const lockId = generateLockId(); const lockData = { id: lockId, timestamp: Date.now(), }; // Setup storage event listener for faster lock release detection if (isBrowser) { onStorage = (e) => { if (e.key === key && (!e.newValue || e.newValue !== JSON.stringify(lockData))) { debug?.("withStorageLock: storage event detected lock release", key); lockReleased = true; } }; globalThis.addEventListener("storage", onStorage); } try { // Poll until lock is free or timeout let pollDelay = DEFAULT_RETRY_DELAY_MS; const maxPollDelay = 500; let consecutiveChecks = 0; while (!lockReleased) { // Check for abort signal if (abort?.aborted) { throw new DOMException("Operation aborted", "AbortError"); } const currentValue = await storage.getItem(key); const currentLock = parseLockData(currentValue); // Check if lock is free or stale if (!currentLock || isLockStale(currentLock)) { if (currentLock && isLockStale(currentLock)) { debug?.("withStorageLock: clearing stale lock", key, { lockAge: Date.now() - currentLock.timestamp, }); } break; // Lock is available } // Check timeout if (Date.now() - start > timeoutMs) { debug?.("withStorageLock: timeout acquiring lock", key, { elapsed: Date.now() - start, }); throw new LockTimeoutError(key, timeoutMs); } // Adaptive polling: increase delay after several consecutive checks consecutiveChecks++; if (consecutiveChecks > 3) { pollDelay = Math.min(pollDelay * 1.5, maxPollDelay); } await sleep(pollDelay); } } finally { if (isBrowser) { globalThis.removeEventListener("storage", onStorage); } } // Acquire lock with atomic check let acquired = false; const maxAcquisitionAttempts = 3; for (let attempt = 1; attempt <= maxAcquisitionAttempts; attempt++) { try { await storage.setItem(key, JSON.stringify(lockData)); // Verify we own the lock (handles race condition) const verifyValue = await storage.getItem(key); const verifyLock = parseLockData(verifyValue); if (verifyLock && verifyLock.id === lockId) { acquired = true; debug?.("withStorageLock: acquired lock", key, { lockId, attempt, elapsedMs: Date.now() - start, }); break; } else { debug?.("withStorageLock: lock acquisition race detected", key, { ourId: lockId, actualId: verifyLock?.id, attempt, }); // Small backoff before retry if (attempt < maxAcquisitionAttempts) { await sleep(DEFAULT_RETRY_DELAY_MS * attempt); } } } catch (error) { debug?.("withStorageLock: storage error during lock acquisition", key, error); // Handle storage errors if (attempt === maxAcquisitionAttempts) { throw new Error(`Failed to acquire lock due to storage error: ${String(error)}`); } } } if (!acquired) { throw new Error(`Failed to acquire lock after ${maxAcquisitionAttempts} attempts: ${key}`); } try { return await fn(); } finally { debug?.("withStorageLock: releasing lock", key); try { // Only remove our lock const currentValue = await storage.getItem(key); const currentLock = parseLockData(currentValue); if (currentLock && currentLock.id === lockId) { await storage.removeItem(key); } else { debug?.("withStorageLock: lock already released or taken by another process", key); } } catch (error) { // Log but don't throw - the operation succeeded debug?.("withStorageLock: error releasing lock", key, error); } } }