fix: Implement unified authentication system
- Created single auth-unified.ts module as the source of truth - Deprecated old auth.ts and simple-auth.ts (now proxy to unified) - Fixed dashboard SSR auth using Astro.cookies for better compatibility - Added comprehensive auth test page at /auth-test-unified - Resolved cookie handling issues in Docker environment 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -40,9 +40,7 @@ import CookieConsent from '../components/CookieConsent.astro';
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Import stylesheets -->
|
<!-- CSS is imported via the global style block below -->
|
||||||
<link rel="stylesheet" href="/src/styles/global.css" />
|
|
||||||
<link rel="stylesheet" href="/src/styles/glassmorphism.css" />
|
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen flex flex-col">
|
<body class="min-h-screen flex flex-col">
|
||||||
<!-- Skip Links for Accessibility -->
|
<!-- Skip Links for Accessibility -->
|
||||||
@@ -110,4 +108,5 @@ import CookieConsent from '../components/CookieConsent.astro';
|
|||||||
|
|
||||||
<style is:global>
|
<style is:global>
|
||||||
@import '../styles/global.css';
|
@import '../styles/global.css';
|
||||||
|
@import '../styles/glassmorphism.css';
|
||||||
</style>
|
</style>
|
||||||
@@ -50,9 +50,7 @@ import CookieConsent from '../components/CookieConsent.astro';
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Import stylesheets -->
|
<!-- CSS is imported via the global style block below -->
|
||||||
<link rel="stylesheet" href="/src/styles/global.css" />
|
|
||||||
<link rel="stylesheet" href="/src/styles/glassmorphism.css" />
|
|
||||||
</head>
|
</head>
|
||||||
<body class="min-h-screen">
|
<body class="min-h-screen">
|
||||||
<!-- Skip Links for Accessibility -->
|
<!-- Skip Links for Accessibility -->
|
||||||
@@ -99,4 +97,5 @@ import CookieConsent from '../components/CookieConsent.astro';
|
|||||||
|
|
||||||
<style is:global>
|
<style is:global>
|
||||||
@import '../styles/global.css';
|
@import '../styles/global.css';
|
||||||
|
@import '../styles/glassmorphism.css';
|
||||||
</style>
|
</style>
|
||||||
383
src/lib/auth-unified.ts
Normal file
383
src/lib/auth-unified.ts
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
/**
|
||||||
|
* Unified Authentication Module
|
||||||
|
* This is the SINGLE source of truth for all authentication in the application.
|
||||||
|
* All auth-related functions should be imported from this file.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { createSupabaseServerClient, createSupabaseServerClientFromRequest } from './supabase-ssr';
|
||||||
|
import { logSecurityEvent, logUserActivity } from './logger';
|
||||||
|
import type { AstroCookies } from 'astro';
|
||||||
|
import type { User, Session } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
// Custom error class for auth-related errors
|
||||||
|
export class AuthError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public code: 'NO_SESSION' | 'INVALID_TOKEN' | 'NO_PERMISSION' | 'EXPIRED' | 'NO_ORGANIZATION',
|
||||||
|
public statusCode: number = 401
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'AuthError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth context interface
|
||||||
|
export interface AuthContext {
|
||||||
|
user: User;
|
||||||
|
session: Session;
|
||||||
|
isAdmin: boolean;
|
||||||
|
isSuperAdmin: boolean;
|
||||||
|
organizationId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type guard for authentication
|
||||||
|
export function isAuthenticated(auth: AuthContext | null): auth is AuthContext {
|
||||||
|
return auth !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Universal auth verification function
|
||||||
|
* Works with both Request objects (API routes) and AstroCookies (Astro pages)
|
||||||
|
*/
|
||||||
|
export async function verifyAuth(requestOrCookies: Request | AstroCookies): Promise<AuthContext | null> {
|
||||||
|
try {
|
||||||
|
// Create appropriate Supabase client based on input type
|
||||||
|
const supabase = requestOrCookies instanceof Request
|
||||||
|
? createSupabaseServerClientFromRequest(requestOrCookies)
|
||||||
|
: createSupabaseServerClient(requestOrCookies);
|
||||||
|
|
||||||
|
// Get session from cookies
|
||||||
|
const { data: { session }, error } = await supabase.auth.getSession();
|
||||||
|
|
||||||
|
if (error || !session) {
|
||||||
|
// Try Authorization header as fallback (only for Request objects)
|
||||||
|
if (requestOrCookies instanceof Request) {
|
||||||
|
const authHeader = requestOrCookies.headers.get('Authorization');
|
||||||
|
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||||
|
const accessToken = authHeader.substring(7);
|
||||||
|
const { data: { user }, error: tokenError } = await supabase.auth.getUser(accessToken);
|
||||||
|
|
||||||
|
if (tokenError || !user) {
|
||||||
|
logSecurityEvent({
|
||||||
|
type: 'auth_failure',
|
||||||
|
ipAddress: getClientIP(requestOrCookies),
|
||||||
|
userAgent: requestOrCookies.headers.get('User-Agent') || undefined,
|
||||||
|
severity: 'medium',
|
||||||
|
details: { error: tokenError?.message, reason: 'invalid_bearer_token' }
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a minimal session for bearer token auth
|
||||||
|
return await buildAuthContext(user, accessToken, supabase, requestOrCookies);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No valid session or token found
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build and return auth context
|
||||||
|
return await buildAuthContext(session.user, session.access_token, supabase, requestOrCookies);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Auth] Verification error:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build auth context with user data from database
|
||||||
|
*/
|
||||||
|
async function buildAuthContext(
|
||||||
|
user: User,
|
||||||
|
accessToken: string,
|
||||||
|
supabaseClient: any,
|
||||||
|
requestOrCookies: Request | AstroCookies
|
||||||
|
): Promise<AuthContext> {
|
||||||
|
// Get additional user data from database
|
||||||
|
const { data: userRecord, error: dbError } = await supabaseClient
|
||||||
|
.from('users')
|
||||||
|
.select('role, organization_id, is_super_admin')
|
||||||
|
.eq('id', user.id)
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (dbError) {
|
||||||
|
console.error('[Auth] Database user lookup failed:', dbError.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log successful authentication
|
||||||
|
if (requestOrCookies instanceof Request) {
|
||||||
|
logUserActivity({
|
||||||
|
action: 'auth_success',
|
||||||
|
userId: user.id,
|
||||||
|
ipAddress: getClientIP(requestOrCookies),
|
||||||
|
userAgent: requestOrCookies.headers.get('User-Agent') || undefined,
|
||||||
|
details: { organizationId: userRecord?.organization_id, role: userRecord?.role }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
session: {
|
||||||
|
access_token: accessToken,
|
||||||
|
refresh_token: '',
|
||||||
|
expires_in: 3600,
|
||||||
|
expires_at: Date.now() / 1000 + 3600,
|
||||||
|
token_type: 'bearer',
|
||||||
|
user
|
||||||
|
} as Session,
|
||||||
|
isAdmin: userRecord?.role === 'admin' || false,
|
||||||
|
isSuperAdmin: userRecord?.role === 'admin' && userRecord?.is_super_admin === true,
|
||||||
|
organizationId: userRecord?.organization_id || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Require authentication - throws if not authenticated
|
||||||
|
*/
|
||||||
|
export async function requireAuth(requestOrCookies: Request | AstroCookies): Promise<AuthContext> {
|
||||||
|
const auth = await verifyAuth(requestOrCookies);
|
||||||
|
|
||||||
|
if (!auth) {
|
||||||
|
if (requestOrCookies instanceof Request) {
|
||||||
|
logSecurityEvent({
|
||||||
|
type: 'access_denied',
|
||||||
|
ipAddress: getClientIP(requestOrCookies),
|
||||||
|
userAgent: requestOrCookies.headers.get('User-Agent') || undefined,
|
||||||
|
severity: 'low',
|
||||||
|
details: { reason: 'no_authentication' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new AuthError('Authentication required', 'NO_SESSION');
|
||||||
|
}
|
||||||
|
|
||||||
|
return auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Require admin access - throws if not admin
|
||||||
|
*/
|
||||||
|
export async function requireAdmin(requestOrCookies: Request | AstroCookies): Promise<AuthContext> {
|
||||||
|
const auth = await requireAuth(requestOrCookies);
|
||||||
|
|
||||||
|
if (!auth.isAdmin) {
|
||||||
|
if (requestOrCookies instanceof Request) {
|
||||||
|
logSecurityEvent({
|
||||||
|
type: 'access_denied',
|
||||||
|
userId: auth.user.id,
|
||||||
|
ipAddress: getClientIP(requestOrCookies),
|
||||||
|
userAgent: requestOrCookies.headers.get('User-Agent') || undefined,
|
||||||
|
severity: 'medium',
|
||||||
|
details: { reason: 'insufficient_privileges', requiredRole: 'admin' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new AuthError('Admin access required', 'NO_PERMISSION', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Require super admin access - throws if not super admin
|
||||||
|
*/
|
||||||
|
export async function requireSuperAdmin(requestOrCookies: Request | AstroCookies): Promise<AuthContext> {
|
||||||
|
const auth = await requireAuth(requestOrCookies);
|
||||||
|
|
||||||
|
if (!auth.isSuperAdmin) {
|
||||||
|
if (requestOrCookies instanceof Request) {
|
||||||
|
logSecurityEvent({
|
||||||
|
type: 'access_denied',
|
||||||
|
userId: auth.user.id,
|
||||||
|
ipAddress: getClientIP(requestOrCookies),
|
||||||
|
userAgent: requestOrCookies.headers.get('User-Agent') || undefined,
|
||||||
|
severity: 'high',
|
||||||
|
details: { reason: 'insufficient_privileges', requiredRole: 'super_admin' }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new AuthError('Super admin access required', 'NO_PERMISSION', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if user has access to a specific organization
|
||||||
|
*/
|
||||||
|
export async function requireOrganizationAccess(
|
||||||
|
requestOrCookies: Request | AstroCookies,
|
||||||
|
organizationId: string
|
||||||
|
): Promise<AuthContext> {
|
||||||
|
const auth = await requireAuth(requestOrCookies);
|
||||||
|
|
||||||
|
if (auth.organizationId !== organizationId && !auth.isAdmin) {
|
||||||
|
if (requestOrCookies instanceof Request) {
|
||||||
|
logSecurityEvent({
|
||||||
|
type: 'access_denied',
|
||||||
|
userId: auth.user.id,
|
||||||
|
ipAddress: getClientIP(requestOrCookies),
|
||||||
|
userAgent: requestOrCookies.headers.get('User-Agent') || undefined,
|
||||||
|
severity: 'high',
|
||||||
|
details: {
|
||||||
|
reason: 'organization_access_violation',
|
||||||
|
userOrganization: auth.organizationId,
|
||||||
|
requestedOrganization: organizationId
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw new AuthError('Access denied to this organization', 'NO_PERMISSION', 403);
|
||||||
|
}
|
||||||
|
|
||||||
|
return auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware wrapper for auth-protected routes
|
||||||
|
*/
|
||||||
|
export async function withAuth<T>(
|
||||||
|
requestOrCookies: Request | AstroCookies,
|
||||||
|
handler: (auth: AuthContext) => T | Promise<T>
|
||||||
|
): Promise<T> {
|
||||||
|
const auth = await verifyAuth(requestOrCookies);
|
||||||
|
if (!auth) {
|
||||||
|
throw new AuthError('Not authenticated', 'NO_SESSION');
|
||||||
|
}
|
||||||
|
return handler(auth);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate CSRF token for forms
|
||||||
|
*/
|
||||||
|
export function generateCSRFToken(): string {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify CSRF token
|
||||||
|
*/
|
||||||
|
export function verifyCSRFToken(request: Request, sessionToken: string): boolean {
|
||||||
|
const submittedToken = request.headers.get('X-CSRF-Token') ||
|
||||||
|
request.headers.get('X-Requested-With');
|
||||||
|
|
||||||
|
return submittedToken === sessionToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rate limiting - simple in-memory implementation
|
||||||
|
* For production, use Redis or a proper rate limiting service
|
||||||
|
*/
|
||||||
|
const rateLimitStore = new Map<string, { count: number; lastReset: number }>();
|
||||||
|
|
||||||
|
export function checkRateLimit(
|
||||||
|
identifier: string,
|
||||||
|
maxRequests: number = 10,
|
||||||
|
windowMs: number = 60000
|
||||||
|
): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const windowStart = now - windowMs;
|
||||||
|
|
||||||
|
let entry = rateLimitStore.get(identifier);
|
||||||
|
|
||||||
|
if (!entry || entry.lastReset < windowStart) {
|
||||||
|
entry = { count: 0, lastReset: now };
|
||||||
|
rateLimitStore.set(identifier, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.count++;
|
||||||
|
|
||||||
|
// Clean up old entries periodically
|
||||||
|
if (Math.random() < 0.01) { // 1% chance
|
||||||
|
cleanupRateLimit(windowStart);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAllowed = entry.count <= maxRequests;
|
||||||
|
|
||||||
|
// Log rate limit violations
|
||||||
|
if (!isAllowed) {
|
||||||
|
logSecurityEvent({
|
||||||
|
type: 'rate_limit',
|
||||||
|
ipAddress: identifier.includes(':') ? identifier.split(':')[1] : identifier,
|
||||||
|
severity: 'medium',
|
||||||
|
details: {
|
||||||
|
maxRequests,
|
||||||
|
windowMs,
|
||||||
|
currentCount: entry.count,
|
||||||
|
identifier
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return isAllowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupRateLimit(cutoff: number) {
|
||||||
|
for (const [key, entry] of rateLimitStore.entries()) {
|
||||||
|
if (entry.lastReset < cutoff) {
|
||||||
|
rateLimitStore.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get client IP address from request
|
||||||
|
*/
|
||||||
|
export function getClientIP(request: Request): string {
|
||||||
|
// Try various headers that might contain the real IP
|
||||||
|
const forwardedFor = request.headers.get('X-Forwarded-For');
|
||||||
|
const realIP = request.headers.get('X-Real-IP');
|
||||||
|
const cfConnectingIP = request.headers.get('CF-Connecting-IP');
|
||||||
|
|
||||||
|
if (cfConnectingIP) return cfConnectingIP;
|
||||||
|
if (realIP) return realIP;
|
||||||
|
if (forwardedFor) return forwardedFor.split(',')[0].trim();
|
||||||
|
|
||||||
|
// Fallback
|
||||||
|
return request.headers.get('X-Client-IP') || 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create secure response with auth headers
|
||||||
|
*/
|
||||||
|
export function createAuthResponse(
|
||||||
|
body: string | object,
|
||||||
|
status: number = 200,
|
||||||
|
additionalHeaders: Record<string, string> = {}
|
||||||
|
): Response {
|
||||||
|
const headers = {
|
||||||
|
'Content-Type': typeof body === 'string' ? 'text/plain' : 'application/json',
|
||||||
|
'X-Content-Type-Options': 'nosniff',
|
||||||
|
'X-Frame-Options': 'DENY',
|
||||||
|
'X-XSS-Protection': '1; mode=block',
|
||||||
|
...additionalHeaders
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
typeof body === 'string' ? body : JSON.stringify(body),
|
||||||
|
{ status, headers }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Debug utilities (only in development)
|
||||||
|
*/
|
||||||
|
export const authDebug = {
|
||||||
|
logCookies: (request: Request) => {
|
||||||
|
if (process.env.NODE_ENV !== 'development') return;
|
||||||
|
const cookieHeader = request.headers.get('Cookie');
|
||||||
|
console.log('[Auth Debug] Cookies:', cookieHeader);
|
||||||
|
},
|
||||||
|
|
||||||
|
logSession: (session: Session | null) => {
|
||||||
|
if (process.env.NODE_ENV !== 'development') return;
|
||||||
|
console.log('[Auth Debug] Session:', session ? {
|
||||||
|
user_id: session.user.id,
|
||||||
|
expires_at: new Date(session.expires_at! * 1000).toISOString()
|
||||||
|
} : 'No session');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Re-export frequently used functions with simpler names (backwards compatibility)
|
||||||
|
export {
|
||||||
|
verifyAuth as verifyAuthSimple,
|
||||||
|
requireAdmin as requireAdminSimple,
|
||||||
|
requireSuperAdmin as requireSuperAdminSimple
|
||||||
|
};
|
||||||
317
src/lib/auth.ts
317
src/lib/auth.ts
@@ -1,316 +1,7 @@
|
|||||||
import { supabase } from './supabase';
|
|
||||||
import { logSecurityEvent, logUserActivity } from './logger';
|
|
||||||
import type { User, Session } from '@supabase/supabase-js';
|
|
||||||
|
|
||||||
// Get the Supabase URL for cookie name generation
|
|
||||||
const supabaseUrl = import.meta.env.PUBLIC_SUPABASE_URL || import.meta.env.SUPABASE_URL;
|
|
||||||
|
|
||||||
export interface AuthContext {
|
|
||||||
user: User;
|
|
||||||
session: Session;
|
|
||||||
isAdmin?: boolean;
|
|
||||||
organizationId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Server-side authentication verification
|
* DEPRECATED: This file is deprecated. Use auth-unified.ts instead.
|
||||||
* Validates the auth token from cookies or headers
|
* This file now proxies all exports to the unified auth module for backwards compatibility.
|
||||||
*/
|
*/
|
||||||
export async function verifyAuth(request: Request): Promise<AuthContext | null> {
|
|
||||||
try {
|
|
||||||
// Get auth token from Authorization header or cookies
|
|
||||||
const authHeader = request.headers.get('Authorization');
|
|
||||||
const cookieHeader = request.headers.get('Cookie');
|
|
||||||
|
|
||||||
let accessToken: string | null = null;
|
|
||||||
|
|
||||||
// Try Authorization header first
|
|
||||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
|
||||||
accessToken = authHeader.substring(7);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try cookies if no auth header
|
|
||||||
if (!accessToken && cookieHeader) {
|
|
||||||
const cookies = parseCookies(cookieHeader);
|
|
||||||
|
|
||||||
// Extract Supabase project ref for cookie names
|
|
||||||
const projectRef = supabaseUrl.split('//')[1].split('.')[0];
|
|
||||||
|
|
||||||
// Check for various Supabase cookie patterns
|
|
||||||
accessToken = cookies[`sb-${projectRef}-auth-token`] ||
|
|
||||||
cookies[`sb-${projectRef}-auth-token.0`] ||
|
|
||||||
cookies[`sb-${projectRef}-auth-token.1`] ||
|
|
||||||
cookies['sb-access-token'] ||
|
|
||||||
cookies['supabase-auth-token'] ||
|
|
||||||
cookies['access_token'];
|
|
||||||
|
|
||||||
// Log for debugging (only if no token found)
|
|
||||||
if (!accessToken) {
|
|
||||||
console.log('Auth debug - no token found:', {
|
|
||||||
projectRef,
|
|
||||||
cookieKeys: Object.keys(cookies).filter(k => k.includes('sb') || k.includes('supabase')),
|
|
||||||
tokenSource: 'none'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!accessToken) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify the token with Supabase
|
|
||||||
const { data: { user }, error } = await supabase.auth.getUser(accessToken);
|
|
||||||
|
|
||||||
if (error || !user) {
|
|
||||||
// Log failed authentication attempt
|
|
||||||
logSecurityEvent({
|
|
||||||
type: 'auth_failure',
|
|
||||||
ipAddress: getClientIPFromHeaders(request),
|
|
||||||
userAgent: request.headers.get('User-Agent') || undefined,
|
|
||||||
severity: 'medium',
|
|
||||||
details: { error: error?.message, reason: 'invalid_token' }
|
|
||||||
});
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get user's organization
|
|
||||||
const { data: userRecord } = await supabase
|
|
||||||
.from('users')
|
|
||||||
.select('organization_id, role')
|
|
||||||
.eq('id', user.id)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
// Mock session object (since we're doing server-side verification)
|
|
||||||
const session: Session = {
|
|
||||||
access_token: accessToken,
|
|
||||||
refresh_token: '', // Not needed for verification
|
|
||||||
expires_in: 3600,
|
|
||||||
expires_at: Date.now() / 1000 + 3600,
|
|
||||||
token_type: 'bearer',
|
|
||||||
user
|
|
||||||
};
|
|
||||||
|
|
||||||
// Log successful authentication
|
|
||||||
logUserActivity({
|
|
||||||
action: 'auth_success',
|
|
||||||
userId: user.id,
|
|
||||||
ipAddress: getClientIPFromHeaders(request),
|
|
||||||
userAgent: request.headers.get('User-Agent') || undefined,
|
|
||||||
details: { organizationId: userRecord?.organization_id, role: userRecord?.role }
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
// Re-export everything from the unified auth module
|
||||||
user,
|
export * from './auth-unified';
|
||||||
session,
|
|
||||||
isAdmin: userRecord?.role === 'admin',
|
|
||||||
organizationId: userRecord?.organization_id
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error verifying auth:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Middleware function to protect routes
|
|
||||||
*/
|
|
||||||
export async function requireAuth(request: Request): Promise<AuthContext> {
|
|
||||||
const auth = await verifyAuth(request);
|
|
||||||
|
|
||||||
if (!auth) {
|
|
||||||
logSecurityEvent({
|
|
||||||
type: 'access_denied',
|
|
||||||
ipAddress: getClientIPFromHeaders(request),
|
|
||||||
userAgent: request.headers.get('User-Agent') || undefined,
|
|
||||||
severity: 'low',
|
|
||||||
details: { reason: 'no_authentication' }
|
|
||||||
});
|
|
||||||
throw new Error('Authentication required');
|
|
||||||
}
|
|
||||||
|
|
||||||
return auth;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Middleware function to require admin access
|
|
||||||
*/
|
|
||||||
export async function requireAdmin(request: Request): Promise<AuthContext> {
|
|
||||||
const auth = await requireAuth(request);
|
|
||||||
|
|
||||||
if (!auth.isAdmin) {
|
|
||||||
logSecurityEvent({
|
|
||||||
type: 'access_denied',
|
|
||||||
userId: auth.user.id,
|
|
||||||
ipAddress: getClientIPFromHeaders(request),
|
|
||||||
userAgent: request.headers.get('User-Agent') || undefined,
|
|
||||||
severity: 'medium',
|
|
||||||
details: { reason: 'insufficient_privileges', requiredRole: 'admin' }
|
|
||||||
});
|
|
||||||
throw new Error('Admin access required');
|
|
||||||
}
|
|
||||||
|
|
||||||
return auth;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if user has access to a specific organization
|
|
||||||
*/
|
|
||||||
export async function requireOrganizationAccess(
|
|
||||||
request: Request,
|
|
||||||
organizationId: string
|
|
||||||
): Promise<AuthContext> {
|
|
||||||
const auth = await requireAuth(request);
|
|
||||||
|
|
||||||
if (auth.organizationId !== organizationId && !auth.isAdmin) {
|
|
||||||
logSecurityEvent({
|
|
||||||
type: 'access_denied',
|
|
||||||
userId: auth.user.id,
|
|
||||||
ipAddress: getClientIPFromHeaders(request),
|
|
||||||
userAgent: request.headers.get('User-Agent') || undefined,
|
|
||||||
severity: 'high',
|
|
||||||
details: {
|
|
||||||
reason: 'organization_access_violation',
|
|
||||||
userOrganization: auth.organizationId,
|
|
||||||
requestedOrganization: organizationId
|
|
||||||
}
|
|
||||||
});
|
|
||||||
throw new Error('Access denied to this organization');
|
|
||||||
}
|
|
||||||
|
|
||||||
return auth;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate CSRF token
|
|
||||||
*/
|
|
||||||
export function generateCSRFToken(): string {
|
|
||||||
return crypto.randomUUID();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Verify CSRF token
|
|
||||||
*/
|
|
||||||
export function verifyCSRFToken(request: Request, sessionToken: string): boolean {
|
|
||||||
const submittedToken = request.headers.get('X-CSRF-Token') ||
|
|
||||||
request.headers.get('X-Requested-With');
|
|
||||||
|
|
||||||
return submittedToken === sessionToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rate limiting - simple in-memory implementation
|
|
||||||
* For production, use Redis or a proper rate limiting service
|
|
||||||
*/
|
|
||||||
const rateLimitStore = new Map<string, { count: number; lastReset: number }>();
|
|
||||||
|
|
||||||
export function checkRateLimit(
|
|
||||||
identifier: string,
|
|
||||||
maxRequests: number = 10,
|
|
||||||
windowMs: number = 60000
|
|
||||||
): boolean {
|
|
||||||
const now = Date.now();
|
|
||||||
const windowStart = now - windowMs;
|
|
||||||
|
|
||||||
let entry = rateLimitStore.get(identifier);
|
|
||||||
|
|
||||||
if (!entry || entry.lastReset < windowStart) {
|
|
||||||
entry = { count: 0, lastReset: now };
|
|
||||||
rateLimitStore.set(identifier, entry);
|
|
||||||
}
|
|
||||||
|
|
||||||
entry.count++;
|
|
||||||
|
|
||||||
// Clean up old entries periodically
|
|
||||||
if (Math.random() < 0.01) { // 1% chance
|
|
||||||
cleanupRateLimit(windowStart);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isAllowed = entry.count <= maxRequests;
|
|
||||||
|
|
||||||
// Log rate limit violations
|
|
||||||
if (!isAllowed) {
|
|
||||||
logSecurityEvent({
|
|
||||||
type: 'rate_limit',
|
|
||||||
ipAddress: identifier.includes(':') ? identifier.split(':')[1] : identifier,
|
|
||||||
severity: 'medium',
|
|
||||||
details: {
|
|
||||||
maxRequests,
|
|
||||||
windowMs,
|
|
||||||
currentCount: entry.count,
|
|
||||||
identifier
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return isAllowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanupRateLimit(cutoff: number) {
|
|
||||||
for (const [key, entry] of rateLimitStore.entries()) {
|
|
||||||
if (entry.lastReset < cutoff) {
|
|
||||||
rateLimitStore.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse cookies from cookie header
|
|
||||||
*/
|
|
||||||
function parseCookies(cookieHeader: string): Record<string, string> {
|
|
||||||
const cookies: Record<string, string> = {};
|
|
||||||
|
|
||||||
cookieHeader.split(';').forEach(cookie => {
|
|
||||||
const [name, ...rest] = cookie.trim().split('=');
|
|
||||||
if (name && rest.length > 0) {
|
|
||||||
cookies[name] = rest.join('=');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return cookies;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create secure response with auth headers
|
|
||||||
*/
|
|
||||||
export function createAuthResponse(
|
|
||||||
body: string | object,
|
|
||||||
status: number = 200,
|
|
||||||
additionalHeaders: Record<string, string> = {}
|
|
||||||
): Response {
|
|
||||||
const headers = {
|
|
||||||
'Content-Type': typeof body === 'string' ? 'text/plain' : 'application/json',
|
|
||||||
'X-Content-Type-Options': 'nosniff',
|
|
||||||
'X-Frame-Options': 'DENY',
|
|
||||||
'X-XSS-Protection': '1; mode=block',
|
|
||||||
...additionalHeaders
|
|
||||||
};
|
|
||||||
|
|
||||||
return new Response(
|
|
||||||
typeof body === 'string' ? body : JSON.stringify(body),
|
|
||||||
{ status, headers }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get client IP address for rate limiting
|
|
||||||
*/
|
|
||||||
export function getClientIP(request: Request): string {
|
|
||||||
return getClientIPFromHeaders(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper function to extract IP from headers
|
|
||||||
*/
|
|
||||||
function getClientIPFromHeaders(request: Request): string {
|
|
||||||
// Try various headers that might contain the real IP
|
|
||||||
const forwardedFor = request.headers.get('X-Forwarded-For');
|
|
||||||
const realIP = request.headers.get('X-Real-IP');
|
|
||||||
const cfConnectingIP = request.headers.get('CF-Connecting-IP');
|
|
||||||
|
|
||||||
if (cfConnectingIP) return cfConnectingIP;
|
|
||||||
if (realIP) return realIP;
|
|
||||||
if (forwardedFor) return forwardedFor.split(',')[0].trim();
|
|
||||||
|
|
||||||
// Fallback to connection IP (may not be available in all environments)
|
|
||||||
return request.headers.get('X-Client-IP') || 'unknown';
|
|
||||||
}
|
|
||||||
@@ -1,126 +1,7 @@
|
|||||||
import { supabase } from './supabase';
|
|
||||||
import { createSupabaseServerClientFromRequest } from './supabase-ssr';
|
|
||||||
import type { User, Session } from '@supabase/supabase-js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse cookie header into key-value pairs
|
* DEPRECATED: This file is deprecated. Use auth-unified.ts instead.
|
||||||
|
* This file now proxies all exports to the unified auth module for backwards compatibility.
|
||||||
*/
|
*/
|
||||||
function parseCookies(cookieHeader: string): Record<string, string> {
|
|
||||||
return cookieHeader.split(';').reduce((acc, cookie) => {
|
|
||||||
const [key, value] = cookie.trim().split('=');
|
|
||||||
if (key && value) {
|
|
||||||
acc[key] = decodeURIComponent(value);
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<string, string>);
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AuthContext {
|
// Re-export everything from the unified auth module
|
||||||
user: User;
|
export * from './auth-unified';
|
||||||
session: Session;
|
|
||||||
isAdmin?: boolean;
|
|
||||||
isSuperAdmin?: boolean;
|
|
||||||
organizationId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simplified authentication verification without logging
|
|
||||||
* Uses Supabase's built-in server-side session parsing
|
|
||||||
*/
|
|
||||||
export async function verifyAuthSimple(request: Request): Promise<AuthContext | null> {
|
|
||||||
try {
|
|
||||||
// Create SSR Supabase client
|
|
||||||
const supabaseSSR = createSupabaseServerClientFromRequest(request);
|
|
||||||
|
|
||||||
// Get the session from cookies
|
|
||||||
const { data: { session }, error } = await supabaseSSR.auth.getSession();
|
|
||||||
|
|
||||||
console.log('Session check:', session ? 'Session found' : 'No session', error?.message);
|
|
||||||
|
|
||||||
if (error || !session) {
|
|
||||||
// Try Authorization header as fallback
|
|
||||||
const authHeader = request.headers.get('Authorization');
|
|
||||||
console.log('Auth header:', authHeader ? 'Present' : 'Not present');
|
|
||||||
|
|
||||||
if (authHeader && authHeader.startsWith('Bearer ')) {
|
|
||||||
const accessToken = authHeader.substring(7);
|
|
||||||
|
|
||||||
// Verify the token with Supabase
|
|
||||||
const { data, error } = await supabase.auth.getUser(accessToken);
|
|
||||||
|
|
||||||
if (error || !data.user) {
|
|
||||||
console.log('Bearer token verification failed:', error?.message);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return await buildAuthContext(data.user, accessToken, supabaseSSR);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return await buildAuthContext(session.user, session.access_token, supabaseSSR);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Auth verification failed:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build auth context with user data
|
|
||||||
*/
|
|
||||||
async function buildAuthContext(user: any, accessToken: string, supabaseClient: any): Promise<AuthContext> {
|
|
||||||
// Get user role from database
|
|
||||||
const { data: userRecord, error: dbError } = await supabaseClient
|
|
||||||
.from('users')
|
|
||||||
.select('role, organization_id, is_super_admin')
|
|
||||||
.eq('id', user.id)
|
|
||||||
.single();
|
|
||||||
|
|
||||||
if (dbError) {
|
|
||||||
console.log('Database user lookup failed:', dbError.message);
|
|
||||||
// Continue without role data - better than failing entirely
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
user,
|
|
||||||
session: { access_token: accessToken } as Session,
|
|
||||||
isAdmin: userRecord?.role === 'admin' || false,
|
|
||||||
isSuperAdmin: userRecord?.role === 'admin' && userRecord?.is_super_admin === true,
|
|
||||||
organizationId: userRecord?.organization_id
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simplified admin requirement check
|
|
||||||
*/
|
|
||||||
export async function requireAdminSimple(request: Request): Promise<AuthContext> {
|
|
||||||
const auth = await verifyAuthSimple(request);
|
|
||||||
|
|
||||||
if (!auth) {
|
|
||||||
throw new Error('Authentication required');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!auth.isAdmin) {
|
|
||||||
throw new Error('Admin access required');
|
|
||||||
}
|
|
||||||
|
|
||||||
return auth;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Super admin requirement check
|
|
||||||
*/
|
|
||||||
export async function requireSuperAdminSimple(request: Request): Promise<AuthContext> {
|
|
||||||
const auth = await verifyAuthSimple(request);
|
|
||||||
|
|
||||||
if (!auth) {
|
|
||||||
throw new Error('Authentication required');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!auth.isSuperAdmin) {
|
|
||||||
throw new Error('Super admin access required');
|
|
||||||
}
|
|
||||||
|
|
||||||
return auth;
|
|
||||||
}
|
|
||||||
202
src/pages/auth-test-unified.astro
Normal file
202
src/pages/auth-test-unified.astro
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
---
|
||||||
|
import Layout from '../layouts/Layout.astro';
|
||||||
|
import { verifyAuth, authDebug } from '../lib/auth-unified';
|
||||||
|
|
||||||
|
export const prerender = false;
|
||||||
|
|
||||||
|
// Test authentication
|
||||||
|
let auth = null;
|
||||||
|
let authError = null;
|
||||||
|
let cookies = null;
|
||||||
|
let headers = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Test unified auth
|
||||||
|
auth = await verifyAuth(Astro.request);
|
||||||
|
|
||||||
|
// Capture debug info
|
||||||
|
cookies = Astro.request.headers.get('Cookie') || 'No cookies';
|
||||||
|
headers = {
|
||||||
|
'Authorization': Astro.request.headers.get('Authorization') || 'Not set',
|
||||||
|
'User-Agent': Astro.request.headers.get('User-Agent') || 'Not set',
|
||||||
|
'X-Forwarded-For': Astro.request.headers.get('X-Forwarded-For') || 'Not set'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Use debug utilities in development
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
authDebug.logCookies(Astro.request);
|
||||||
|
authDebug.logSession(auth?.session || null);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
authError = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
}
|
||||||
|
---
|
||||||
|
|
||||||
|
<Layout title="Unified Auth Test">
|
||||||
|
<div class="min-h-screen bg-gray-100 p-8">
|
||||||
|
<div class="max-w-6xl mx-auto">
|
||||||
|
<h1 class="text-3xl font-bold mb-8">Unified Authentication System Test</h1>
|
||||||
|
|
||||||
|
<!-- Auth Status Card -->
|
||||||
|
<div class="bg-white rounded-lg shadow-lg p-6 mb-6">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Authentication Status</h2>
|
||||||
|
|
||||||
|
{auth ? (
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="p-4 bg-green-100 border border-green-400 rounded">
|
||||||
|
<h3 class="font-semibold text-green-800">✅ Authenticated</h3>
|
||||||
|
<div class="mt-2 space-y-1 text-sm">
|
||||||
|
<p><strong>User ID:</strong> <code class="bg-gray-100 px-1">{auth.user.id}</code></p>
|
||||||
|
<p><strong>Email:</strong> <code class="bg-gray-100 px-1">{auth.user.email}</code></p>
|
||||||
|
<p><strong>Is Admin:</strong> <span class={auth.isAdmin ? 'text-green-600 font-semibold' : 'text-gray-600'}>{auth.isAdmin ? 'Yes' : 'No'}</span></p>
|
||||||
|
<p><strong>Is Super Admin:</strong> <span class={auth.isSuperAdmin ? 'text-purple-600 font-semibold' : 'text-gray-600'}>{auth.isSuperAdmin ? 'Yes' : 'No'}</span></p>
|
||||||
|
<p><strong>Organization ID:</strong> <code class="bg-gray-100 px-1">{auth.organizationId || 'None'}</code></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Session Info -->
|
||||||
|
<div class="p-4 bg-blue-50 border border-blue-200 rounded">
|
||||||
|
<h3 class="font-semibold text-blue-800">Session Information</h3>
|
||||||
|
<div class="mt-2 space-y-1 text-sm">
|
||||||
|
<p><strong>Token Type:</strong> {auth.session.token_type}</p>
|
||||||
|
<p><strong>Expires At:</strong> {new Date((auth.session.expires_at || 0) * 1000).toLocaleString()}</p>
|
||||||
|
<p><strong>Access Token:</strong> <code class="bg-gray-100 px-1 text-xs">{auth.session.access_token.substring(0, 20)}...</code></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<a href="/dashboard" class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">
|
||||||
|
User Dashboard
|
||||||
|
</a>
|
||||||
|
{auth.isAdmin && (
|
||||||
|
<a href="/admin/dashboard" class="bg-purple-500 text-white px-4 py-2 rounded hover:bg-purple-600">
|
||||||
|
Admin Dashboard
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{auth.isSuperAdmin && (
|
||||||
|
<a href="/admin/super-dashboard" class="bg-red-500 text-white px-4 py-2 rounded hover:bg-red-600">
|
||||||
|
Super Dashboard
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<a href="/api/auth/session" target="_blank" class="bg-gray-500 text-white px-4 py-2 rounded hover:bg-gray-600">
|
||||||
|
Check Session API
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="p-4 bg-red-100 border border-red-400 rounded">
|
||||||
|
<h3 class="font-semibold text-red-800">❌ Not Authenticated</h3>
|
||||||
|
<p class="mt-2">You are not logged in or your session has expired.</p>
|
||||||
|
{authError && (
|
||||||
|
<p class="mt-2"><strong>Error:</strong> <code class="bg-red-50 px-1">{authError}</code></p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<a href="/login" class="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600">
|
||||||
|
Login
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Request Debug Info -->
|
||||||
|
<div class="bg-white rounded-lg shadow-lg p-6 mb-6">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Request Debug Information</h2>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Cookies -->
|
||||||
|
<div class="p-4 bg-gray-50 rounded">
|
||||||
|
<h3 class="font-semibold mb-2">Cookies</h3>
|
||||||
|
<pre class="text-xs bg-gray-100 p-2 rounded overflow-x-auto">{cookies}</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Headers -->
|
||||||
|
<div class="p-4 bg-gray-50 rounded">
|
||||||
|
<h3 class="font-semibold mb-2">Relevant Headers</h3>
|
||||||
|
<pre class="text-xs bg-gray-100 p-2 rounded overflow-x-auto">{JSON.stringify(headers, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Test Actions -->
|
||||||
|
<div class="bg-white rounded-lg shadow-lg p-6 mb-6">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Test Actions</h2>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div class="p-4 border rounded">
|
||||||
|
<h3 class="font-semibold mb-2">Test Protected Routes</h3>
|
||||||
|
<div class="space-y-2 text-sm">
|
||||||
|
<p><a href="/dashboard" class="text-blue-600 hover:underline">→ Dashboard (requires auth)</a></p>
|
||||||
|
<p><a href="/events/new" class="text-blue-600 hover:underline">→ New Event (requires auth)</a></p>
|
||||||
|
<p><a href="/scan" class="text-blue-600 hover:underline">→ Scanner (requires auth)</a></p>
|
||||||
|
<p><a href="/calendar" class="text-blue-600 hover:underline">→ Calendar (requires auth)</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-4 border rounded">
|
||||||
|
<h3 class="font-semibold mb-2">Test Auth APIs</h3>
|
||||||
|
<div class="space-y-2 text-sm">
|
||||||
|
<p><a href="/api/auth/session" target="_blank" class="text-blue-600 hover:underline">→ Session API</a></p>
|
||||||
|
<p><a href="/api/auth/logout" class="text-blue-600 hover:underline">→ Logout</a></p>
|
||||||
|
<p><a href="/auth-test" class="text-blue-600 hover:underline">→ Old Auth Test (for comparison)</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Auth System Info -->
|
||||||
|
<div class="bg-white rounded-lg shadow-lg p-6">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Unified Auth System Information</h2>
|
||||||
|
|
||||||
|
<div class="prose prose-sm max-w-none">
|
||||||
|
<h3 class="text-lg font-semibold">Features</h3>
|
||||||
|
<ul>
|
||||||
|
<li>✅ Single source of truth for authentication</li>
|
||||||
|
<li>✅ Works with both Request objects and AstroCookies</li>
|
||||||
|
<li>✅ Supabase SSR integration</li>
|
||||||
|
<li>✅ Bearer token fallback support</li>
|
||||||
|
<li>✅ Role-based access control (User/Admin/Super Admin)</li>
|
||||||
|
<li>✅ Organization-based access control</li>
|
||||||
|
<li>✅ Security logging and rate limiting</li>
|
||||||
|
<li>✅ Type-safe auth context</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 class="text-lg font-semibold mt-4">Usage</h3>
|
||||||
|
<pre class="bg-gray-100 p-3 rounded text-xs overflow-x-auto">
|
||||||
|
// In Astro pages
|
||||||
|
import { verifyAuth, requireAuth } from '../lib/auth-unified';
|
||||||
|
|
||||||
|
// Check auth (returns null if not authenticated)
|
||||||
|
const auth = await verifyAuth(Astro.request);
|
||||||
|
|
||||||
|
// Require auth (throws if not authenticated)
|
||||||
|
const auth = await requireAuth(Astro.request);
|
||||||
|
|
||||||
|
// Require admin
|
||||||
|
const auth = await requireAdmin(Astro.request);
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Layout>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Client-side session check
|
||||||
|
async function checkClientSession() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/auth/session');
|
||||||
|
const data = await response.json();
|
||||||
|
console.log('[Client] Session check:', data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Client] Session check failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check session on page load
|
||||||
|
checkClientSession();
|
||||||
|
</script>
|
||||||
@@ -6,12 +6,11 @@ import { verifyAuth } from '../lib/auth';
|
|||||||
// Enable server-side rendering for auth checks
|
// Enable server-side rendering for auth checks
|
||||||
export const prerender = false;
|
export const prerender = false;
|
||||||
|
|
||||||
// Disable server-side auth check temporarily to fix redirect loop
|
// Server-side auth check using cookies for better SSR compatibility
|
||||||
// We'll handle auth check on the client side in the script section
|
const auth = await verifyAuth(Astro.cookies);
|
||||||
// const auth = await verifyAuth(Astro.request);
|
if (!auth) {
|
||||||
// if (!auth) {
|
return Astro.redirect('/login');
|
||||||
// return Astro.redirect('/login');
|
}
|
||||||
// }
|
|
||||||
---
|
---
|
||||||
|
|
||||||
<Layout title="Dashboard - Black Canyon Tickets">
|
<Layout title="Dashboard - Black Canyon Tickets">
|
||||||
|
|||||||
@@ -25,6 +25,8 @@
|
|||||||
--bg-orb-1: rgba(147, 51, 234, 0.2);
|
--bg-orb-1: rgba(147, 51, 234, 0.2);
|
||||||
--bg-orb-2: rgba(59, 130, 246, 0.2);
|
--bg-orb-2: rgba(59, 130, 246, 0.2);
|
||||||
--bg-orb-3: rgba(99, 102, 241, 0.1);
|
--bg-orb-3: rgba(99, 102, 241, 0.1);
|
||||||
|
--bg-orb-4: rgba(147, 51, 234, 0.15);
|
||||||
|
--bg-orb-5: rgba(59, 130, 246, 0.12);
|
||||||
|
|
||||||
/* Premium Color Palette */
|
/* Premium Color Palette */
|
||||||
--success-color: #34d399;
|
--success-color: #34d399;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
@import "./glassmorphism.css";
|
|
||||||
|
|
||||||
/* Accessibility Styles */
|
/* Accessibility Styles */
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user