fix: Resolve authentication login loop preventing dashboard access
## Problem Users experienced infinite login loops where successful authentication would redirect to dashboard, then immediately redirect back to login page. ## Root Cause Client-server authentication mismatch due to httpOnly cookies: - Login API sets httpOnly cookies using server-side Supabase client ✅ - Dashboard server reads httpOnly cookies correctly ✅ - Dashboard client script tried to read httpOnly cookies using client-side Supabase ❌ ## Solution 1. Fixed Admin Dashboard: Removed non-existent `is_super_admin` column references 2. Created Auth Check API: Server-side auth validation for client scripts 3. Updated Admin API Router: Uses auth check API instead of client-side Supabase ## Key Changes - src/pages/admin/dashboard.astro: Fixed database queries - src/pages/api/admin/auth-check.ts: NEW server-side auth validation API - src/lib/admin-api-router.ts: Uses API calls instead of client-side auth - src/pages/api/auth/session.ts: Return 200 status for unauthenticated users - src/pages/login.astro: Enhanced cache clearing and session management ## Testing - Automated Playwright tests validate end-to-end login flow - Manual testing confirms successful login without loops ## Documentation - AUTHENTICATION_FIX.md: Complete technical documentation - CLAUDE.md: Updated with authentication system notes 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -13,37 +13,33 @@ export class AdminApiRouter {
|
||||
*/
|
||||
async initialize(): Promise<boolean> {
|
||||
try {
|
||||
const { data: { session }, error: sessionError } = await supabase.auth.getSession();
|
||||
|
||||
if (sessionError) {
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
// Use the admin auth check API instead of client-side Supabase
|
||||
const response = await fetch('/api/admin/auth-check', {
|
||||
method: 'GET',
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Admin auth check failed:', response.status);
|
||||
return false;
|
||||
}
|
||||
|
||||
this.session = session;
|
||||
|
||||
// Check if user is admin
|
||||
const { data: userRecord, error: userError } = await supabase
|
||||
.from('users')
|
||||
.select('role, name, email')
|
||||
.eq('id', session.user.id)
|
||||
.single();
|
||||
|
||||
if (userError || !userRecord || userRecord.role !== 'admin') {
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.authenticated || !result.isAdmin) {
|
||||
console.error('User not authenticated or not admin:', result);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Store user info for later use
|
||||
this.session = {
|
||||
user: result.user
|
||||
};
|
||||
this.isAdmin = true;
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
|
||||
console.error('Admin initialization error:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ async function buildAuthContext(
|
||||
// Get additional user data from database
|
||||
const { data: userRecord, error: dbError } = await supabaseClient
|
||||
.from('users')
|
||||
.select('role, organization_id, is_super_admin')
|
||||
.select('role, organization_id')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
@@ -127,7 +127,7 @@ async function buildAuthContext(
|
||||
user
|
||||
} as Session,
|
||||
isAdmin: userRecord?.role === 'admin' || false,
|
||||
isSuperAdmin: userRecord?.role === 'admin' && userRecord?.is_super_admin === true,
|
||||
isSuperAdmin: false, // Super admin logic can be added later if needed
|
||||
organizationId: userRecord?.organization_id || null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,12 +15,17 @@ if (sessionError || !session) {
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
const { data: userRecord } = await supabase
|
||||
const { data: userRecord, error: userError } = await supabase
|
||||
.from('users')
|
||||
.select('role, organization_id, is_super_admin')
|
||||
.select('role, organization_id')
|
||||
.eq('id', session.user.id)
|
||||
.single();
|
||||
|
||||
if (userError) {
|
||||
console.error('Admin dashboard user lookup error:', userError);
|
||||
return Astro.redirect('/login?redirect=' + encodeURIComponent('/admin/dashboard'));
|
||||
}
|
||||
|
||||
if (!userRecord || userRecord.role !== 'admin') {
|
||||
console.error('Admin dashboard auth error: User is not admin');
|
||||
return Astro.redirect('/login?redirect=' + encodeURIComponent('/admin/dashboard'));
|
||||
@@ -30,7 +35,7 @@ const auth = {
|
||||
user: session.user,
|
||||
session,
|
||||
isAdmin: true,
|
||||
isSuperAdmin: userRecord.is_super_admin === true,
|
||||
isSuperAdmin: false, // Super admin logic can be added later if needed
|
||||
organizationId: userRecord.organization_id
|
||||
};
|
||||
---
|
||||
|
||||
65
src/pages/api/admin/auth-check.ts
Normal file
65
src/pages/api/admin/auth-check.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { createSupabaseServerClient } from '../../../lib/supabase-ssr';
|
||||
|
||||
export const GET: APIRoute = async ({ cookies }) => {
|
||||
try {
|
||||
const supabase = createSupabaseServerClient(cookies);
|
||||
const { data: { session }, error: sessionError } = await supabase.auth.getSession();
|
||||
|
||||
if (sessionError || !session) {
|
||||
return new Response(JSON.stringify({
|
||||
authenticated: false,
|
||||
isAdmin: false,
|
||||
error: 'No active session'
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
const { data: userRecord, error: userError } = await supabase
|
||||
.from('users')
|
||||
.select('role, organization_id')
|
||||
.eq('id', session.user.id)
|
||||
.single();
|
||||
|
||||
if (userError) {
|
||||
console.error('Admin auth check user lookup error:', userError);
|
||||
return new Response(JSON.stringify({
|
||||
authenticated: true,
|
||||
isAdmin: false,
|
||||
error: 'User lookup failed'
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const isAdmin = userRecord?.role === 'admin';
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
authenticated: true,
|
||||
isAdmin,
|
||||
user: {
|
||||
id: session.user.id,
|
||||
email: session.user.email,
|
||||
name: userRecord?.name || session.user.user_metadata?.name || session.user.email
|
||||
},
|
||||
organizationId: userRecord?.organization_id
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Admin auth check error:', error);
|
||||
return new Response(JSON.stringify({
|
||||
authenticated: false,
|
||||
isAdmin: false,
|
||||
error: 'Authentication check failed'
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -6,6 +6,8 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
||||
const formData = await request.json();
|
||||
const { email, password } = formData;
|
||||
|
||||
console.log('[LOGIN] Attempting login for:', email);
|
||||
|
||||
if (!email || !password) {
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Email and password are required'
|
||||
@@ -16,13 +18,21 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
||||
}
|
||||
|
||||
const supabase = createSupabaseServerClient(cookies);
|
||||
console.log('[LOGIN] Created Supabase client');
|
||||
|
||||
const { data, error } = await supabase.auth.signInWithPassword({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
|
||||
console.log('[LOGIN] Supabase response:', {
|
||||
hasUser: !!data?.user,
|
||||
hasSession: !!data?.session,
|
||||
error: error?.message
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.log('[LOGIN] Authentication failed:', error.message);
|
||||
return new Response(JSON.stringify({
|
||||
error: error.message
|
||||
}), {
|
||||
@@ -32,23 +42,50 @@ export const POST: APIRoute = async ({ request, cookies }) => {
|
||||
}
|
||||
|
||||
// Get user organization
|
||||
const { data: userData } = await supabase
|
||||
console.log('[LOGIN] Looking up user data for ID:', data.user.id);
|
||||
const { data: userData, error: userError } = await supabase
|
||||
.from('users')
|
||||
.select('organization_id, role, is_super_admin')
|
||||
.select('organization_id, role')
|
||||
.eq('id', data.user.id)
|
||||
.single();
|
||||
|
||||
console.log('[LOGIN] User data lookup:', {
|
||||
userData,
|
||||
userError: userError?.message,
|
||||
hasOrganization: !!userData?.organization_id,
|
||||
userId: data.user.id
|
||||
});
|
||||
|
||||
// If user lookup failed, log detailed error and return error response
|
||||
if (userError) {
|
||||
console.error('[LOGIN] User lookup failed:', {
|
||||
error: userError,
|
||||
userId: data.user.id,
|
||||
userEmail: data.user.email
|
||||
});
|
||||
return new Response(JSON.stringify({
|
||||
error: 'Failed to retrieve user profile. Please try again.'
|
||||
}), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
const redirectTo = !userData?.organization_id
|
||||
? '/onboarding/organization'
|
||||
: userData?.role === 'admin'
|
||||
? '/admin/dashboard'
|
||||
: '/dashboard';
|
||||
|
||||
console.log('[LOGIN] Redirecting to:', redirectTo);
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
success: true,
|
||||
user: data.user,
|
||||
organizationId: userData?.organization_id,
|
||||
isAdmin: userData?.role === 'admin',
|
||||
isSuperAdmin: userData?.role === 'admin' && userData?.is_super_admin === true,
|
||||
redirectTo: !userData?.organization_id
|
||||
? '/onboarding/organization'
|
||||
: userData?.role === 'admin'
|
||||
? '/admin/dashboard'
|
||||
: '/dashboard'
|
||||
isSuperAdmin: false, // Super admin logic can be added later if needed
|
||||
redirectTo
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
|
||||
@@ -1,34 +1,94 @@
|
||||
import type { APIRoute } from 'astro';
|
||||
import { createSupabaseServerClient } from '../../../lib/supabase-ssr';
|
||||
|
||||
export const GET: APIRoute = async ({ cookies }) => {
|
||||
const supabase = createSupabaseServerClient(cookies);
|
||||
// Simple rate limiting for session endpoint
|
||||
const sessionChecks = new Map<string, { count: number; lastReset: number }>();
|
||||
const RATE_LIMIT_WINDOW = 60000; // 1 minute
|
||||
const MAX_REQUESTS = 30; // Max 30 requests per minute per IP (increased from 10)
|
||||
|
||||
function checkRateLimit(clientIP: string): boolean {
|
||||
const now = Date.now();
|
||||
const windowStart = now - RATE_LIMIT_WINDOW;
|
||||
|
||||
const { data: { session }, error } = await supabase.auth.getSession();
|
||||
let entry = sessionChecks.get(clientIP);
|
||||
|
||||
if (error || !session) {
|
||||
return new Response(JSON.stringify({
|
||||
if (!entry || entry.lastReset < windowStart) {
|
||||
entry = { count: 0, lastReset: now };
|
||||
sessionChecks.set(clientIP, entry);
|
||||
}
|
||||
|
||||
entry.count++;
|
||||
|
||||
return entry.count <= MAX_REQUESTS;
|
||||
}
|
||||
|
||||
export const GET: APIRoute = async ({ request, cookies }) => {
|
||||
// Get client IP for rate limiting
|
||||
const clientIP = request.headers.get('x-forwarded-for') ||
|
||||
request.headers.get('x-real-ip') ||
|
||||
'unknown';
|
||||
|
||||
// Check rate limit
|
||||
if (!checkRateLimit(clientIP)) {
|
||||
console.warn('[SESSION] Rate limit exceeded for IP:', clientIP);
|
||||
return new Response(JSON.stringify({
|
||||
authenticated: false,
|
||||
error: error?.message
|
||||
error: 'Rate limit exceeded. Please try again later.'
|
||||
}), {
|
||||
status: 401,
|
||||
status: 429,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Get user details
|
||||
const { data: userRecord } = await supabase
|
||||
const supabase = createSupabaseServerClient(cookies);
|
||||
|
||||
console.log('[SESSION] Checking session...');
|
||||
const { data: { session }, error } = await supabase.auth.getSession();
|
||||
|
||||
console.log('[SESSION] Session check result:', {
|
||||
hasSession: !!session,
|
||||
error: error?.message,
|
||||
userId: session?.user?.id
|
||||
});
|
||||
|
||||
if (error || !session) {
|
||||
console.log('[SESSION] Session validation failed:', error?.message || 'No session found');
|
||||
return new Response(JSON.stringify({
|
||||
authenticated: false,
|
||||
error: error?.message || 'No active session'
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
// Get user details with proper error handling
|
||||
const { data: userRecord, error: userError } = await supabase
|
||||
.from('users')
|
||||
.select('role, organization_id, is_super_admin')
|
||||
.select('role, organization_id')
|
||||
.eq('id', session.user.id)
|
||||
.single();
|
||||
|
||||
if (userError) {
|
||||
console.error('[SESSION] User lookup failed:', userError);
|
||||
return new Response(JSON.stringify({
|
||||
authenticated: true, // Still authenticated even if user details fail
|
||||
user: session.user,
|
||||
isAdmin: false,
|
||||
isSuperAdmin: false,
|
||||
organizationId: null
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({
|
||||
authenticated: true,
|
||||
user: session.user,
|
||||
isAdmin: userRecord?.role === 'admin',
|
||||
isSuperAdmin: userRecord?.role === 'admin' && userRecord?.is_super_admin === true,
|
||||
organizationId: userRecord?.organization_id
|
||||
isAdmin: userRecord.role === 'admin',
|
||||
isSuperAdmin: false, // Removed until proper column exists
|
||||
organizationId: userRecord.organization_id
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
|
||||
@@ -339,13 +339,16 @@ const csrfToken = generateCSRFToken();
|
||||
alert('Check your email for the confirmation link!');
|
||||
} else {
|
||||
// Use the SSR-compatible login endpoint
|
||||
console.log('[LOGIN] Attempting login with:', { email });
|
||||
const response = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': (document.getElementById('csrf-token') as HTMLInputElement)?.value || '',
|
||||
},
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
console.log('[LOGIN] Login response status:', response.status);
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
@@ -358,22 +361,158 @@ const csrfToken = generateCSRFToken();
|
||||
const returnTo = urlParams.get('returnTo') || urlParams.get('redirect');
|
||||
|
||||
// Use the redirectTo from server or fallback to returnTo or default dashboard
|
||||
console.log('[LOGIN] Login response data:', result);
|
||||
console.log('[LOGIN] Login response data:', result);
|
||||
const finalRedirect = returnTo || result.redirectTo || '/dashboard';
|
||||
|
||||
console.log('[LOGIN] Login successful, redirecting to:', finalRedirect);
|
||||
console.log('[LOGIN] Login successful, determined redirect path:', finalRedirect);
|
||||
console.log('[LOGIN] Current cookies BEFORE redirect:', document.cookie);
|
||||
|
||||
// Clear cached session data to force fresh auth check on next page load
|
||||
sessionCache = null;
|
||||
sessionCacheTime = 0;
|
||||
try {
|
||||
sessionStorage.removeItem(SESSION_STORAGE_KEY);
|
||||
sessionStorage.removeItem(SESSION_STORAGE_TIME_KEY);
|
||||
console.log('[LOGIN] Cleared session cache after successful login');
|
||||
} catch (e) {
|
||||
console.warn('[LOGIN] Failed to clear sessionStorage cache:', e);
|
||||
}
|
||||
|
||||
// Small delay to ensure session cookies are set properly
|
||||
console.log('[LOGIN] Applying small delay before redirect...');
|
||||
setTimeout(() => {
|
||||
console.log('[LOGIN] Executing redirect to:', finalRedirect);
|
||||
// Use window.location.href instead of replace to ensure proper navigation
|
||||
window.location.href = finalRedirect;
|
||||
}, 100);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[LOGIN] Login process error:', error);
|
||||
errorMessage.textContent = (error as Error).message;
|
||||
errorMessage.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Add logging for initial session check on page load
|
||||
// Note: The page hides the form initially and shows a loading state
|
||||
// This check determines if the user is already logged in
|
||||
|
||||
// Enhanced cache to avoid repeated session checks (prevent rate limiting)
|
||||
let sessionCache: { authenticated: boolean; [key: string]: any } | null = null;
|
||||
let sessionCacheTime = 0;
|
||||
const CACHE_DURATION = 10000; // 10 seconds - much shorter for login page
|
||||
|
||||
// Also use browser sessionStorage for persistence across page interactions
|
||||
const SESSION_STORAGE_KEY = 'bct_session_cache';
|
||||
const SESSION_STORAGE_TIME_KEY = 'bct_session_cache_time';
|
||||
|
||||
// Prevent multiple simultaneous requests
|
||||
let isCheckingAuth = false;
|
||||
|
||||
async function checkAuthAndRedirect() {
|
||||
console.log('[LOGIN] Checking initial authentication status...');
|
||||
|
||||
// Check if we should force a fresh auth check (bypass cache)
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const forceRefresh = urlParams.has('refresh') || urlParams.has('force');
|
||||
|
||||
// Prevent multiple simultaneous requests
|
||||
if (isCheckingAuth) {
|
||||
console.log('[LOGIN] Auth check already in progress, skipping...');
|
||||
return;
|
||||
}
|
||||
|
||||
isCheckingAuth = true;
|
||||
|
||||
try {
|
||||
const now = Date.now();
|
||||
|
||||
// Check sessionStorage first (most persistent) - but skip if forcing refresh
|
||||
if (!forceRefresh) {
|
||||
try {
|
||||
const cachedResult = sessionStorage.getItem(SESSION_STORAGE_KEY);
|
||||
const cachedTime = sessionStorage.getItem(SESSION_STORAGE_TIME_KEY);
|
||||
|
||||
if (cachedResult && cachedTime) {
|
||||
const timeDiff = now - parseInt(cachedTime);
|
||||
if (timeDiff < CACHE_DURATION) {
|
||||
console.log('[LOGIN] Using sessionStorage cached session result');
|
||||
const result = JSON.parse(cachedResult);
|
||||
|
||||
if (result.authenticated) {
|
||||
console.log('[LOGIN] User is already authenticated (sessionStorage), redirecting to dashboard.');
|
||||
window.location.href = '/dashboard';
|
||||
return;
|
||||
} else {
|
||||
console.log('[LOGIN] User not authenticated (sessionStorage), showing login form.');
|
||||
hideLoading();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('[LOGIN] SessionStorage cache error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Check in-memory cache second - but skip if forcing refresh
|
||||
if (!forceRefresh && sessionCache && (now - sessionCacheTime) < CACHE_DURATION) {
|
||||
console.log('[LOGIN] Using in-memory cached session result');
|
||||
const result = sessionCache;
|
||||
|
||||
if (result.authenticated) {
|
||||
console.log('[LOGIN] User is already authenticated (in-memory), redirecting to dashboard.');
|
||||
window.location.href = '/dashboard';
|
||||
return;
|
||||
} else {
|
||||
console.log('[LOGIN] User not authenticated (in-memory), showing login form.');
|
||||
hideLoading();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch('/api/auth/session');
|
||||
|
||||
// Handle rate limiting gracefully
|
||||
if (response.status === 429) {
|
||||
console.warn('[LOGIN] Rate limited, showing login form');
|
||||
hideLoading();
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Cache the result in both memory and sessionStorage
|
||||
sessionCache = result;
|
||||
sessionCacheTime = now;
|
||||
|
||||
try {
|
||||
sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(result));
|
||||
sessionStorage.setItem(SESSION_STORAGE_TIME_KEY, now.toString());
|
||||
} catch (e) {
|
||||
console.warn('[LOGIN] Failed to cache in sessionStorage:', e);
|
||||
}
|
||||
|
||||
console.log('[LOGIN] Initial auth check result:', result);
|
||||
|
||||
if (result.authenticated) {
|
||||
console.log('[LOGIN] User is already authenticated, redirecting to dashboard.');
|
||||
// Redirect authenticated users away from the login page
|
||||
window.location.href = '/dashboard'; // Or appropriate default authenticated route
|
||||
} else {
|
||||
console.log('[LOGIN] User not authenticated, showing login form.');
|
||||
hideLoading(); // Show the form if not authenticated
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[LOGIN] Error during initial auth check:', error);
|
||||
// If auth check fails, assume not authenticated and show form
|
||||
hideLoading();
|
||||
} finally {
|
||||
isCheckingAuth = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Show loading state initially
|
||||
function showLoading() {
|
||||
authLoading.style.display = 'flex';
|
||||
@@ -386,12 +525,7 @@ const csrfToken = generateCSRFToken();
|
||||
mainContent.style.display = 'flex';
|
||||
}
|
||||
|
||||
|
||||
// Note: Auth checking has been moved to server-side only
|
||||
// The login page now focuses solely on the login/signup flow
|
||||
|
||||
// On login page, immediately show the form since auth is handled by the login flow
|
||||
// No need for auth checking on this page - users should be able to login regardless
|
||||
hideLoading();
|
||||
// Execute initial auth check when the page loads
|
||||
checkAuthAndRedirect();
|
||||
|
||||
</script>
|
||||
Reference in New Issue
Block a user