UNPKG

@joinmeow/cognito-passwordless-auth

Version:

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

228 lines (227 loc) 12 kB
"use strict"; /** * 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. */ describe("30-Minute Session Lottery Bug Scenario", () => { /** * This test demonstrates the exact scenario that occurred: * - User session hits exactly 30 minutes * - Tokens expire during page navigation * - API calls with expired tokens trigger logout * - Background refresh takes 13 seconds * - Grace period prevents logout during refresh */ // Helper to simulate the signInStatus computation from hooks.tsx const computeSignInStatus = (expireAt, isRefreshingTokens, now = Date.now()) => { if (!expireAt) return "NOT_SIGNED_IN"; const expireAtTime = expireAt instanceof Date ? expireAt.valueOf() : new Date(expireAt).valueOf(); // Grace period logic from the fix const REFRESH_GRACE_PERIOD_MS = 30000; if (isRefreshingTokens && now < expireAtTime + REFRESH_GRACE_PERIOD_MS) { return "SIGNED_IN"; } if (now >= expireAtTime) { return "NOT_SIGNED_IN"; } return "SIGNED_IN"; }; describe("The Exact 30-Minute Lottery Bug Timeline", () => { it("should demonstrate the bug scenario step by step", () => { // Timeline setup const loginTime = new Date("2024-07-24T22:43:00.000Z").valueOf(); const tokenLifetime = 30 * 60 * 1000; // 30 minutes const tokenExpireTime = new Date(loginTime + tokenLifetime); // Key timestamps from the actual bug const t1_TokensExpire = new Date("2024-07-24T23:13:00.000Z").valueOf(); const t2_ApiCallFails = new Date("2024-07-24T23:13:14.625Z").valueOf(); const t3_StatusChecking = new Date("2024-07-24T23:13:15.531Z").valueOf(); const t4_UserLoggedOut = new Date("2024-07-24T23:13:15.558Z").valueOf(); const t5_RefreshComplete = new Date("2024-07-24T23:13:28.482Z").valueOf(); // Step 1: User is happily browsing at 29:59 let currentTime = t1_TokensExpire - 1000; // 1 second before expiry let isRefreshing = false; expect(computeSignInStatus(tokenExpireTime, isRefreshing, currentTime)).toBe("SIGNED_IN"); // Step 2: Tokens expire at exactly 30 minutes currentTime = t1_TokensExpire; expect(computeSignInStatus(tokenExpireTime, isRefreshing, currentTime)).toBe("NOT_SIGNED_IN"); // Step 3: User navigates, triggering API calls with expired tokens currentTime = t2_ApiCallFails; expect(computeSignInStatus(tokenExpireTime, isRefreshing, currentTime)).toBe("NOT_SIGNED_IN"); // Without grace period, user sees logout here! // Step 4: Refresh process starts currentTime = t3_StatusChecking; isRefreshing = true; // Refresh has started // WITH GRACE PERIOD: User stays signed in expect(computeSignInStatus(tokenExpireTime, isRefreshing, currentTime)).toBe("SIGNED_IN"); // WITHOUT GRACE PERIOD: User would be logged out const withoutGracePeriod = currentTime >= tokenExpireTime.valueOf(); expect(withoutGracePeriod).toBe(true); // Would show NOT_SIGNED_IN // Step 5: During the 13-second refresh currentTime = t4_UserLoggedOut; expect(computeSignInStatus(tokenExpireTime, isRefreshing, currentTime)).toBe("SIGNED_IN"); // Step 6: Refresh completes after 13 seconds currentTime = t5_RefreshComplete; isRefreshing = false; // Refresh complete // New tokens would be set, but let's verify grace period worked const timeSinceExpiry = currentTime - tokenExpireTime.valueOf(); expect(timeSinceExpiry).toBe(28482); // ~28.5 seconds after expiry expect(timeSinceExpiry).toBeLessThan(30000); // Still within grace period }); it("should test the consecutive 401 scenario", () => { /** * The actual bug involved multiple API calls failing: * 1. GET /billpay/bills - 401 * 2. GET /pylon/user-hash - 401 * These consecutive 401s could trigger forced logout */ const tokenExpireTime = new Date(Date.now() - 5000); // Expired 5 seconds ago let consecutive401Count = 0; const MAX_CONSECUTIVE_401 = 5; // Simulate API calls with expired tokens const apiCalls = [ "/billpay/bills?statuses=PENDING_PAYMENT,PENDING_APPROVAL", "/pylon/user-hash", "/billpay/accounts", "/user/profile", "/notifications/unread", ]; // Without refresh in progress let isRefreshing = false; apiCalls.forEach((endpoint) => { const status = computeSignInStatus(tokenExpireTime, isRefreshing); if (status === "NOT_SIGNED_IN") { consecutive401Count++; console.log(`API call to ${endpoint} would fail - 401 count: ${consecutive401Count}`); } }); // All 5 calls would fail, triggering forced logout expect(consecutive401Count).toBe(5); expect(consecutive401Count >= MAX_CONSECUTIVE_401).toBe(true); // Force logout! // Reset and test with refresh in progress consecutive401Count = 0; isRefreshing = true; // Refresh started after first 401 apiCalls.forEach((endpoint, index) => { // First call triggers refresh if (index === 0) { isRefreshing = true; } const status = computeSignInStatus(tokenExpireTime, isRefreshing); if (status === "NOT_SIGNED_IN") { consecutive401Count++; } }); // With grace period, no 401s after refresh starts expect(consecutive401Count).toBe(0); // No forced logout! }); it("should test various edge cases around token expiration", () => { const baseTime = Date.now(); const tokenExpireTime = new Date(baseTime); const testCases = [ { name: "Tokens expired 1 second ago, no refresh", timeOffset: 1000, isRefreshing: false, expected: "NOT_SIGNED_IN", }, { name: "Tokens expired 1 second ago, refresh in progress", timeOffset: 1000, isRefreshing: true, expected: "SIGNED_IN", // Grace period }, { name: "Tokens expired 13 seconds ago (typical refresh time), refreshing", timeOffset: 13000, isRefreshing: true, expected: "SIGNED_IN", // Grace period }, { name: "Tokens expired 29.9 seconds ago, refreshing", timeOffset: 29900, isRefreshing: true, expected: "SIGNED_IN", // Just within grace period }, { name: "Tokens expired 30 seconds ago, refreshing", timeOffset: 30000, isRefreshing: true, expected: "NOT_SIGNED_IN", // Exactly at grace period limit }, { name: "Tokens expired 31 seconds ago, refreshing", timeOffset: 31000, isRefreshing: true, expected: "NOT_SIGNED_IN", // Beyond grace period }, ]; testCases.forEach((testCase) => { const currentTime = baseTime + testCase.timeOffset; const result = computeSignInStatus(tokenExpireTime, testCase.isRefreshing, currentTime); expect(result).toBe(testCase.expected); console.log(`${testCase.name}: ${result} (${result === testCase.expected ? "✓" : "✗"})`); }); }); it("should calculate the probability of hitting this bug", () => { /** * Let's calculate how "lucky" you were to hit this bug! */ // Assumptions const avgSessionDuration = 45 * 60 * 1000; // 45 minutes average session const tokenLifetime = 30 * 60 * 1000; // 30 minute tokens const avgPageNavigationTime = 5000; // 5 seconds between pages const refreshDuration = 13000; // 13 second refresh const gracePeriodDuration = 30000; // 30 second grace period // Window where bug can occur: tokens must expire during navigation // AND refresh must be triggered AND take long enough to be noticed const vulnerableWindow = avgPageNavigationTime; // 5 seconds // Probability calculations const probTokensExpireDuringSession = tokenLifetime / avgSessionDuration; // 0.67 const probNavigatingDuringExpiry = vulnerableWindow / tokenLifetime; // 0.0028 const probRefreshTakesLongEnough = refreshDuration / gracePeriodDuration; // 0.43 const totalProbability = probTokensExpireDuringSession * probNavigatingDuringExpiry * probRefreshTakesLongEnough; console.log("Lottery Bug Probability Analysis:"); console.log(`- Tokens expire during session: ${(probTokensExpireDuringSession * 100).toFixed(1)}%`); console.log(`- User navigating during expiry: ${(probNavigatingDuringExpiry * 100).toFixed(2)}%`); console.log(`- Refresh noticeable to user: ${(probRefreshTakesLongEnough * 100).toFixed(1)}%`); console.log(`- Total probability: ${(totalProbability * 100).toFixed(3)}% (1 in ${Math.round(1 / totalProbability)})`); // You hit a 0.12% chance bug - truly a lottery! expect(totalProbability).toBeLessThan(0.002); // Less than 0.2% chance }); }); describe("Grace Period Effectiveness", () => { it("should verify grace period covers typical refresh scenarios", () => { const refreshTimes = [ { region: "us-east-1", time: 8000, name: "Fast refresh" }, { region: "eu-west-1", time: 13000, name: "Typical refresh" }, { region: "ap-southeast-1", time: 20000, name: "Slow refresh" }, { region: "sa-east-1", time: 25000, name: "Very slow refresh" }, ]; const tokenExpireTime = new Date(Date.now() - 1000); // Expired 1 second ago const gracePeriod = 30000; // 30 seconds refreshTimes.forEach(({ region, time, name }) => { const currentTime = tokenExpireTime.valueOf() + time; const isWithinGracePeriod = time < gracePeriod; const status = computeSignInStatus(tokenExpireTime, true, // Refreshing currentTime); expect(status).toBe(isWithinGracePeriod ? "SIGNED_IN" : "NOT_SIGNED_IN"); console.log(`${name} (${region}): ${time}ms - ${status}`); }); }); }); });