fix: resolve auth callback failure from IndexedDB cache issues (refs #188)
All checks were successful
Deploy to Staging / Build Images (pull_request) Successful in 3m23s
Deploy to Staging / Deploy to Staging (pull_request) Successful in 52s
Deploy to Staging / Verify Staging (pull_request) Successful in 9s
Deploy to Staging / Notify Staging Ready (pull_request) Successful in 7s
Deploy to Staging / Notify Staging Failure (pull_request) Has been skipped

Add allKeys() to IndexedDBStorage to eliminate Auth0 CacheKeyManifest
fallback, revert set()/remove() to non-blocking persist, add auth error
display on callback route, remove leaky force-auth-check interceptor,
and migrate debug console calls to centralized logger.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eric Gullickson
2026-02-15 09:06:40 -06:00
parent da59168d7b
commit b5b82db532
3 changed files with 58 additions and 102 deletions

View File

@@ -4,7 +4,7 @@
import React, { useState, useEffect, useTransition, useCallback, lazy } from 'react'; import React, { useState, useEffect, useTransition, useCallback, lazy } from 'react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { Routes, Route, Navigate, useLocation } from 'react-router-dom'; import { Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom';
import { useAuth0 } from '@auth0/auth0-react'; import { useAuth0 } from '@auth0/auth0-react';
import { useIsAuthInitialized } from './core/auth/auth-gate'; import { useIsAuthInitialized } from './core/auth/auth-gate';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
@@ -310,11 +310,11 @@ const EditVehicleScreen: React.FC<EditVehicleScreenProps> = ({ vehicle, onBack,
}; };
function App() { function App() {
const { isLoading, isAuthenticated, user } = useAuth0(); const { isLoading, isAuthenticated, user, error: authError } = useAuth0();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate();
const isAuthGateReady = useIsAuthInitialized(); const isAuthGateReady = useIsAuthInitialized();
const [_isPending, startTransition] = useTransition(); const [_isPending, startTransition] = useTransition();
console.log('[DEBUG App] Render check - isLoading:', isLoading, 'isAuthenticated:', isAuthenticated, 'isAuthGateReady:', isAuthGateReady);
// Initialize data synchronization // Initialize data synchronization
const { prefetchForNavigation } = useDataSync(); const { prefetchForNavigation } = useDataSync();
@@ -486,7 +486,6 @@ function App() {
} }
}, [navigateToScreen, navigateToVehicleSubScreen]); }, [navigateToScreen, navigateToVehicleSubScreen]);
console.log('MotoVaultPro status:', { isLoading, isAuthenticated, mobileMode, activeScreen, vehicleSubScreen, userAgent: navigator.userAgent });
const isGarageRoute = location.pathname === '/garage' || location.pathname.startsWith('/garage/'); const isGarageRoute = location.pathname === '/garage' || location.pathname.startsWith('/garage/');
const isCallbackRoute = location.pathname === '/callback'; const isCallbackRoute = location.pathname === '/callback';
@@ -568,6 +567,21 @@ function App() {
} }
if (isCallbackRoute) { if (isCallbackRoute) {
if (authError) {
return (
<ThemeProvider>
<div className="flex flex-col items-center justify-center min-h-screen gap-4 px-4">
<div className="text-lg text-red-600 text-center">Login failed: {authError.message}</div>
<button
onClick={() => navigate('/')}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Return to Homepage
</button>
</div>
</ThemeProvider>
);
}
if (isAuthenticated) { if (isAuthenticated) {
return ( return (
<ThemeProvider> <ThemeProvider>
@@ -669,7 +683,6 @@ function App() {
// Wait for auth gate to be ready before rendering protected routes // Wait for auth gate to be ready before rendering protected routes
// This prevents a race condition where the page renders before the auth token is ready // This prevents a race condition where the page renders before the auth token is ready
if (!isAuthGateReady) { if (!isAuthGateReady) {
console.log('[DEBUG App] Auth gate not ready yet, showing loading state');
if (mobileMode) { if (mobileMode) {
return ( return (
<ThemeProvider> <ThemeProvider>

View File

@@ -8,6 +8,7 @@ import { useNavigate } from 'react-router-dom';
import { apiClient, setAuthReady } from '../api/client'; import { apiClient, setAuthReady } from '../api/client';
import { createIndexedDBAdapter } from '../utils/indexeddb-storage'; import { createIndexedDBAdapter } from '../utils/indexeddb-storage';
import { setAuthInitialized } from './auth-gate'; import { setAuthInitialized } from './auth-gate';
import logger from '../../utils/logger';
interface Auth0ProviderProps { interface Auth0ProviderProps {
children: React.ReactNode; children: React.ReactNode;
@@ -20,12 +21,8 @@ export const Auth0Provider: React.FC<Auth0ProviderProps> = ({ children }) => {
const clientId = import.meta.env.VITE_AUTH0_CLIENT_ID; const clientId = import.meta.env.VITE_AUTH0_CLIENT_ID;
const audience = import.meta.env.VITE_AUTH0_AUDIENCE; const audience = import.meta.env.VITE_AUTH0_AUDIENCE;
// Basic component loading debug
console.log('[Auth0Provider] Component loaded', { domain, clientId, audience });
const onRedirectCallback = (appState?: { returnTo?: string }) => { const onRedirectCallback = (appState?: { returnTo?: string }) => {
console.log('[Auth0Provider] Redirect callback triggered', { appState, returnTo: appState?.returnTo }); logger.debug('Auth0 redirect callback triggered', { returnTo: appState?.returnTo });
// Route to callback page which will check user status and redirect appropriately // Route to callback page which will check user status and redirect appropriately
// Pass the intended destination as state for after status check // Pass the intended destination as state for after status check
navigate('/callback', { navigate('/callback', {
@@ -57,19 +54,10 @@ export const Auth0Provider: React.FC<Auth0ProviderProps> = ({ children }) => {
// Component to inject token into API client with mobile support // Component to inject token into API client with mobile support
const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) => { const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { getAccessTokenSilently, isAuthenticated, isLoading, user, logout } = useAuth0(); const { getAccessTokenSilently, isAuthenticated, isLoading, logout } = useAuth0();
const [retryCount, setRetryCount] = React.useState(0); const [retryCount, setRetryCount] = React.useState(0);
const validatingRef = React.useRef(false); const validatingRef = React.useRef(false);
// Basic component loading debug
console.log('[TokenInjector] Component loaded');
// Debug mobile authentication state
React.useEffect(() => {
const isMobile = /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
console.log(`[Auth Debug] Mobile: ${isMobile}, Loading: ${isLoading}, Authenticated: ${isAuthenticated}, User: ${user ? 'present' : 'null'}`);
}, [isAuthenticated, isLoading, user]);
// Helper function to get token with enhanced retry logic for mobile devices // Helper function to get token with enhanced retry logic for mobile devices
const getTokenWithRetry = async (maxRetries = 5, delayMs = 300): Promise<any> => { const getTokenWithRetry = async (maxRetries = 5, delayMs = 300): Promise<any> => {
for (let attempt = 0; attempt < maxRetries; attempt++) { for (let attempt = 0; attempt < maxRetries; attempt++) {
@@ -94,26 +82,21 @@ const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) =>
} }
const token = await getAccessTokenSilently(tokenOptions); const token = await getAccessTokenSilently(tokenOptions);
console.log(`[Mobile Auth] Token acquired successfully on attempt ${attempt + 1}`, { logger.debug(`Token acquired on attempt ${attempt + 1}`);
cacheMode: tokenOptions.cacheMode,
timeout: tokenOptions.timeoutInSeconds
});
return token; return token;
} catch (error: any) { } catch (error: any) {
console.warn(`[Mobile Auth] Attempt ${attempt + 1}/${maxRetries} failed:`, { logger.warn(`Token attempt ${attempt + 1}/${maxRetries} failed`, {
error: error.message || error, error: error.message || String(error),
cacheMode: attempt <= 2 ? 'on' : 'off'
}); });
// Mobile-specific: longer delays and more attempts // Mobile-specific: longer delays and more attempts
if (attempt < maxRetries - 1) { if (attempt < maxRetries - 1) {
const delay = delayMs * Math.pow(1.5, attempt); // Gentler exponential backoff const delay = delayMs * Math.pow(1.5, attempt);
console.log(`[Mobile Auth] Waiting ${Math.round(delay)}ms before retry...`);
await new Promise(resolve => setTimeout(resolve, delay)); await new Promise(resolve => setTimeout(resolve, delay));
} }
} }
} }
console.error('[Mobile Auth] All token acquisition attempts failed - authentication may be broken'); logger.error('All token acquisition attempts failed');
return null; return null;
}; };
@@ -130,7 +113,7 @@ const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) =>
const errorType = error?.error || error?.message || ''; const errorType = error?.error || error?.message || '';
if (errorType.includes('login_required') || errorType.includes('consent_required') || if (errorType.includes('login_required') || errorType.includes('consent_required') ||
errorType.includes('invalid_grant')) { errorType.includes('invalid_grant')) {
console.warn('[Auth] Stale token detected, clearing auth state'); logger.warn('Stale token detected, clearing auth state');
const { indexedDBStorage } = await import('../utils/indexeddb-storage'); const { indexedDBStorage } = await import('../utils/indexeddb-storage');
await indexedDBStorage.clearAll(); await indexedDBStorage.clearAll();
logout({ openUrl: false }); logout({ openUrl: false });
@@ -143,55 +126,6 @@ const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) =>
validateToken(); validateToken();
}, [isAuthenticated, isLoading, getAccessTokenSilently, logout]); }, [isAuthenticated, isLoading, getAccessTokenSilently, logout]);
// Force authentication check for devices when user seems logged in but isAuthenticated is false
React.useEffect(() => {
const isMobile = /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
// Debug current state
console.log('[Auth Debug] State check:', {
isMobile,
isLoading,
isAuthenticated,
pathname: window.location.pathname,
userAgent: navigator.userAgent.substring(0, 50) + '...'
});
// Trigger for mobile devices OR any device on protected route without authentication
if (!isLoading && !isAuthenticated && window.location.pathname !== '/') {
console.log('[Auth Debug] User on protected route but not authenticated, forcing token check...');
// Aggressive token check
const forceAuthCheck = async () => {
try {
// Try multiple approaches to get token
const token = await getAccessTokenSilently({
cacheMode: 'off' as const,
timeoutInSeconds: 10
});
console.log('[Auth Debug] Force auth successful, token acquired');
// Manually add to API client since isAuthenticated might still be false
if (token) {
console.log('[Auth Debug] Manually adding token to API client');
// Force add the token to subsequent requests
apiClient.interceptors.request.use((config) => {
if (!config.headers.Authorization) {
config.headers.Authorization = `Bearer ${token}`;
console.log('[Auth Debug] Token manually added to request');
}
return config;
});
setAuthReady(true);
}
} catch (error: any) {
console.log('[Auth Debug] Force auth failed:', error.message);
}
};
forceAuthCheck();
}
}, [isLoading, isAuthenticated, getAccessTokenSilently]);
React.useEffect(() => { React.useEffect(() => {
let interceptorId: number | undefined; let interceptorId: number | undefined;
@@ -202,34 +136,30 @@ const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) =>
try { try {
const { indexedDBStorage } = await import('../utils/indexeddb-storage'); const { indexedDBStorage } = await import('../utils/indexeddb-storage');
await indexedDBStorage.waitForReady(); await indexedDBStorage.waitForReady();
console.log('[Auth] IndexedDB storage is ready'); logger.debug('IndexedDB storage is ready');
} catch (error) { } catch (error) {
console.warn('[Auth] IndexedDB not ready, proceeding anyway:', error); logger.warn('IndexedDB not ready, proceeding anyway', { error: String(error) });
} }
// Minimal delay only for mobile devices (desktop needs no delay since IndexedDB is already ready) // Minimal delay only for mobile devices (desktop needs no delay since IndexedDB is already ready)
const isMobile = /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); const isMobile = /Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
if (isMobile) { if (isMobile) {
// Small delay for mobile browsers to settle after IndexedDB init
console.log('[Mobile Auth] Initializing token cache (mobile: true, delay: 50ms)');
await new Promise(resolve => setTimeout(resolve, 50)); await new Promise(resolve => setTimeout(resolve, 50));
} else {
console.log('[Auth] Initializing token cache (desktop, no delay)');
} }
try { try {
const token = await getTokenWithRetry(); const token = await getTokenWithRetry();
if (token) { if (token) {
console.log('[Mobile Auth] Token pre-warming successful'); logger.debug('Token pre-warming successful');
setRetryCount(0); setRetryCount(0);
setAuthReady(true); setAuthReady(true);
setAuthInitialized(true); // Signal that auth is fully ready setAuthInitialized(true); // Signal that auth is fully ready
} else { } else {
console.error('[Mobile Auth] Failed to acquire token after retries - will retry on API calls'); logger.error('Failed to acquire token after retries');
setRetryCount(prev => prev + 1); setRetryCount(prev => prev + 1);
} }
} catch (error) { } catch (error) {
console.error('[Mobile Auth] Token initialization failed:', error); logger.error('Token initialization failed', { error: String(error) });
setRetryCount(prev => prev + 1); setRetryCount(prev => prev + 1);
} }
}; };
@@ -248,11 +178,11 @@ const TokenInjector: React.FC<{ children: React.ReactNode }> = ({ children }) =>
config.headers.Authorization = `Bearer ${token}`; config.headers.Authorization = `Bearer ${token}`;
setAuthReady(true); setAuthReady(true);
} else { } else {
console.error('No token available for request to:', config.url); logger.error('No token available for request', { url: config.url });
// Allow request to proceed - backend will return 401 if needed // Allow request to proceed - backend will return 401 if needed
} }
} catch (error: any) { } catch (error: any) {
console.error('Failed to get access token for request:', error.message || error); logger.error('Failed to get access token for request', { error: error.message || String(error) });
// Allow request to proceed - backend will return 401 if needed // Allow request to proceed - backend will return 401 if needed
} }
return config; return config;

View File

@@ -3,6 +3,8 @@
* @ai-context Replaces localStorage with IndexedDB for mobile browser compatibility * @ai-context Replaces localStorage with IndexedDB for mobile browser compatibility
*/ */
import logger from '../../utils/logger';
interface StorageAdapter { interface StorageAdapter {
getItem(key: string): string | null; getItem(key: string): string | null;
setItem(key: string, value: string): void; setItem(key: string, value: string): void;
@@ -36,9 +38,9 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache {
this.db = await this.openDatabase(); this.db = await this.openDatabase();
await this.loadCacheFromDB(); await this.loadCacheFromDB();
this.isReady = true; this.isReady = true;
console.log('[IndexedDB] Storage initialized successfully'); logger.debug('IndexedDB storage initialized successfully');
} catch (error) { } catch (error) {
console.error('[IndexedDB] Initialization failed, using memory only:', error); logger.error('IndexedDB initialization failed, using memory only', { error: String(error) });
this.isReady = false; this.isReady = false;
} }
} }
@@ -48,7 +50,7 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache {
const request = indexedDB.open(this.dbName, this.dbVersion); const request = indexedDB.open(this.dbName, this.dbVersion);
request.onerror = () => { request.onerror = () => {
console.error(`IndexedDB open failed: ${request.error?.message}`); logger.error(`IndexedDB open failed: ${request.error?.message}`);
resolve(null as any); // Fallback to memory-only mode resolve(null as any); // Fallback to memory-only mode
}; };
@@ -84,13 +86,13 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache {
} }
cursor.continue(); cursor.continue();
} else { } else {
console.log(`[IndexedDB] Loaded ${this.memoryCache.size} items into cache`); logger.debug(`IndexedDB loaded ${this.memoryCache.size} items into cache`);
resolve(); resolve();
} }
}; };
request.onerror = () => { request.onerror = () => {
console.warn('[IndexedDB] Failed to load cache from DB:', request.error); logger.warn('IndexedDB failed to load cache from DB', { error: String(request.error) });
resolve(); resolve();
}; };
}); });
@@ -107,14 +109,14 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache {
const request = store.delete(key); const request = store.delete(key);
request.onsuccess = () => resolve(); request.onsuccess = () => resolve();
request.onerror = () => { request.onerror = () => {
console.warn(`[IndexedDB] Failed to delete ${key}:`, request.error); logger.warn(`IndexedDB failed to delete ${key}`, { error: String(request.error) });
resolve(); resolve();
}; };
} else { } else {
const request = store.put(value, key); const request = store.put(value, key);
request.onsuccess = () => resolve(); request.onsuccess = () => resolve();
request.onerror = () => { request.onerror = () => {
console.warn(`[IndexedDB] Failed to persist ${key}:`, request.error); logger.warn(`IndexedDB failed to persist ${key}`, { error: String(request.error) });
resolve(); resolve();
}; };
} }
@@ -132,7 +134,7 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache {
// Async persist to IndexedDB (non-blocking) // Async persist to IndexedDB (non-blocking)
if (this.isReady) { if (this.isReady) {
this.persistToDB(key, value).catch(error => { this.persistToDB(key, value).catch(error => {
console.warn(`[IndexedDB] Background persist failed for ${key}:`, error); logger.warn(`IndexedDB background persist failed for ${key}`, { error: String(error) });
}); });
} }
} }
@@ -143,7 +145,7 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache {
// Async remove from IndexedDB (non-blocking) // Async remove from IndexedDB (non-blocking)
if (this.isReady) { if (this.isReady) {
this.persistToDB(key, null).catch(error => { this.persistToDB(key, null).catch(error => {
console.warn(`[IndexedDB] Background removal failed for ${key}:`, error); logger.warn(`IndexedDB background removal failed for ${key}`, { error: String(error) });
}); });
} }
} }
@@ -193,6 +195,11 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache {
} }
// Auth0 Cache interface implementation // Auth0 Cache interface implementation
// allKeys() eliminates Auth0 SDK's CacheKeyManifest fallback (auth0-spa-js line 2319)
allKeys(): string[] {
return Array.from(this.memoryCache.keys());
}
async get(key: string): Promise<any> { async get(key: string): Promise<any> {
await this.initPromise; await this.initPromise;
const value = this.getItem(key); const value = this.getItem(key);
@@ -203,13 +210,19 @@ class IndexedDBStorage implements StorageAdapter, Auth0Cache {
await this.initPromise; await this.initPromise;
const stringValue = JSON.stringify(value); const stringValue = JSON.stringify(value);
this.memoryCache.set(key, stringValue); this.memoryCache.set(key, stringValue);
await this.persistToDB(key, stringValue); // Fire-and-forget: persist to IndexedDB for page reload survival
this.persistToDB(key, stringValue).catch(error => {
logger.warn(`IndexedDB background persist failed for ${key}`, { error: String(error) });
});
} }
async remove(key: string): Promise<void> { async remove(key: string): Promise<void> {
await this.initPromise; await this.initPromise;
this.memoryCache.delete(key); this.memoryCache.delete(key);
await this.persistToDB(key, null); // Fire-and-forget: remove from IndexedDB
this.persistToDB(key, null).catch(error => {
logger.warn(`IndexedDB background removal failed for ${key}`, { error: String(error) });
});
} }
// Additional methods for enhanced functionality // Additional methods for enhanced functionality