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:
2025-07-13 10:19:04 -06:00
parent 7fe90e7330
commit f4f929912d
9 changed files with 584 additions and 54 deletions

View File

@@ -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;
}
}

View File

@@ -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
};
}

View File

@@ -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
};
---

View 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' }
});
}
};

View File

@@ -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' }

View File

@@ -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' }

View File

@@ -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>