Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@
"Bash(vercel:*)",
"WebFetch(domain:console.groq.com)",
"Bash(ls:*)",
"Bash(sed:*)"
"Bash(sed:*)",
"Bash(echo $CLERK_JWT_ISSUER_DOMAIN)",
"Bash(curl:*)"
],
"deny": []
}
Expand Down
12 changes: 7 additions & 5 deletions convex/rateLimit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ export async function checkRateLimit(
throw new Error("Authentication required for rate limiting");
}

// Periodically clean up expired entries to prevent memory leaks
if (Math.random() < 0.01) { // 1% chance to cleanup on each call
cleanupExpiredRateLimits();
}

const config = RATE_LIMITS[operation];
const key = `${identity.subject}:${operation}`;
const now = Date.now();
Expand Down Expand Up @@ -95,9 +100,9 @@ export async function enforceRateLimit(
}

/**
* Clean up expired rate limit entries (should be called periodically)
* Clean up expired rate limit entries (called during rate limit checks)
*/
export function cleanupExpiredRateLimits(): void {
function cleanupExpiredRateLimits(): void {
const now = Date.now();
for (const [key, entry] of rateLimitStore.entries()) {
if (now > entry.resetTime) {
Expand All @@ -106,9 +111,6 @@ export function cleanupExpiredRateLimits(): void {
}
}

// Clean up expired entries every 5 minutes
setInterval(cleanupExpiredRateLimits, 5 * 60 * 1000);

/**
* Get current rate limit status for a user and operation
*/
Expand Down
7 changes: 5 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,14 +16,16 @@ import PrivacyPolicy from "./pages/PrivacyPolicy";
import Chat from "./pages/Chat";
import AuthGuard from "./components/AuthGuard";
import UserSync from "./components/UserSync";
import { AuthWrapper } from "./components/AuthWrapper";
import E2BDemo from "./pages/E2BDemo";

const queryClient = new QueryClient();

const App = () => (
<ConvexProviderWithClerk client={convex} useAuth={useAuth}>
<UserSync>
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<AuthWrapper>
<trpc.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<div className="min-h-screen bg-background">
Expand Down Expand Up @@ -56,7 +58,8 @@ const App = () => (
</div>
</TooltipProvider>
</QueryClientProvider>
</trpc.Provider>
</trpc.Provider>
</AuthWrapper>
</UserSync>
</ConvexProviderWithClerk>
);
Expand Down
94 changes: 94 additions & 0 deletions src/components/AuthProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
import { useAuth as useClerkAuth, useUser } from '@clerk/clerk-react';
import { AuthCookies } from '@/lib/auth-cookies';

interface AuthContextType {
isAuthenticated: boolean;
isLoading: boolean;
user: unknown;
token: string | null;
refreshAuth: () => Promise<void>;
}

const AuthContext = createContext<AuthContextType | undefined>(undefined);

export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { getToken, isSignedIn, isLoaded } = useClerkAuth();
const { user } = useUser();
const [token, setToken] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true);

const refreshAuth = useCallback(async () => {
setIsLoading(true);
try {
if (isSignedIn && isLoaded) {
const newToken = await getToken();
if (newToken) {
setToken(newToken);
AuthCookies.set(newToken);
}
} else {
setToken(null);
AuthCookies.remove();
}
} catch (error) {
console.error('Failed to refresh auth token:', error);
// Try to use cached token if available
const cachedToken = AuthCookies.get();
if (cachedToken && AuthCookies.isValid()) {
setToken(cachedToken);
} else {
setToken(null);
AuthCookies.remove();
}
} finally {
setIsLoading(false);
}
}, [isSignedIn, isLoaded, getToken]);

useEffect(() => {
refreshAuth();
}, [isSignedIn, isLoaded, user?.id, refreshAuth]);

// Periodic token refresh to prevent expiration
useEffect(() => {
if (isSignedIn) {
const interval = setInterval(() => {
refreshAuth();
}, 4 * 60 * 1000); // Refresh every 4 minutes

return () => clearInterval(interval);
}
}, [isSignedIn, refreshAuth]);

// Initialize token from cookie on app start
useEffect(() => {
const cachedToken = AuthCookies.get();
if (cachedToken && AuthCookies.isValid() && !token) {
setToken(cachedToken);
}
setIsLoading(false);
}, [token]);

const value: AuthContextType = {
isAuthenticated: isLoaded && isSignedIn && !!token,
isLoading: !isLoaded || isLoading,
user,
token,
refreshAuth,
};

return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
};

export const useAuthContext = () => {

Check warning

Code scanning / ESLint

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components. Warning

Fast refresh only works when a file only exports components. Use a new file to share constants or functions between components.
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuthContext must be used within an AuthProvider');
}
return context;
};
57 changes: 57 additions & 0 deletions src/components/AuthWrapper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import React, { useEffect } from 'react';
import { useConvexAuth } from 'convex/react';
import { useAuth as useClerkAuth } from '@clerk/clerk-react';
import { AuthCookies, useAuthCookies } from '@/lib/auth-cookies';

interface AuthWrapperProps {
children: React.ReactNode;
}

export const AuthWrapper: React.FC<AuthWrapperProps> = ({ children }) => {
const convexAuth = useConvexAuth();
const clerkAuth = useClerkAuth();
const { getStoredToken, clearToken } = useAuthCookies();

useEffect(() => {
// Handle authentication recovery on page load/refresh
const handleAuthRecovery = async () => {
// If Convex shows not authenticated but we have a valid cookie token
if (!convexAuth.isAuthenticated && !convexAuth.isLoading) {
const storedToken = getStoredToken();

if (storedToken && AuthCookies.isValid()) {
// Try to refresh Clerk session if needed
try {
if (clerkAuth.isSignedIn) {
const freshToken = await clerkAuth.getToken({ skipCache: true });
if (freshToken) {
AuthCookies.set(freshToken);
// Let Convex naturally re-authenticate without forcing reload
console.log('Auth token refreshed, waiting for Convex sync');
}
}
} catch (error) {
console.warn('Auth recovery failed:', error);
clearToken();
}
} else if (storedToken) {
// Remove invalid token
clearToken();
}
}
};

// Run recovery after initial auth check
const timeout = setTimeout(handleAuthRecovery, 1000);
return () => clearTimeout(timeout);
}, [convexAuth.isAuthenticated, convexAuth.isLoading, clerkAuth.isSignedIn, getStoredToken, clearToken, clerkAuth]);

// Handle sign out cleanup
useEffect(() => {
if (!clerkAuth.isSignedIn && clerkAuth.isLoaded) {
clearToken();
}
}, [clerkAuth.isSignedIn, clerkAuth.isLoaded, clearToken]);

return <>{children}</>;
};
22 changes: 21 additions & 1 deletion src/components/UserSync.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,34 @@
import { useEffect } from 'react';
import { useConvexAuth } from 'convex/react';
import { useUser } from '@clerk/clerk-react';
import { useUser, useAuth as useClerkAuth } from '@clerk/clerk-react';
import { useMutation } from 'convex/react';
import { api } from '../../convex/_generated/api';
import { AuthCookies } from '@/lib/auth-cookies';

export default function UserSync({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading } = useConvexAuth();
const { user: clerkUser } = useUser();
const { getToken, isSignedIn } = useClerkAuth();
const upsertUser = useMutation(api.users.upsertUser);

// Store auth token in cookies when user signs in
useEffect(() => {
const storeAuthToken = async () => {
if (isSignedIn && clerkUser) {
try {
const token = await getToken();
if (token) {
AuthCookies.set(token);
}
} catch (error) {
console.error('Failed to store auth token:', error);
}
}
};

storeAuthToken();
}, [isSignedIn, clerkUser?.id, getToken, clerkUser]);

useEffect(() => {
if (isAuthenticated && !isLoading && clerkUser) {
// Validate required user data
Expand Down
83 changes: 83 additions & 0 deletions src/lib/auth-cookies.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { useAuth as useClerkAuth } from '@clerk/clerk-react';
import { useEffect } from 'react';

// Cookie utilities for auth token management
export const AuthCookies = {
TOKEN_KEY: 'clerk_session_token',

set(token: string, expiresInDays = 7) {
const expires = new Date();
expires.setTime(expires.getTime() + (expiresInDays * 24 * 60 * 60 * 1000));
document.cookie = `${this.TOKEN_KEY}=${token}; expires=${expires.toUTCString()}; path=/; secure; samesite=strict`;
},

get(): string | null {
const cookies = document.cookie.split(';');
for (const cookie of cookies) {
const [name, value] = cookie.trim().split('=');
if (name === this.TOKEN_KEY) {
return decodeURIComponent(value);
}
}
return null;
},

remove() {
document.cookie = `${this.TOKEN_KEY}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;`;
},

isValid(): boolean {
const token = this.get();
if (!token) return false;

try {
// Basic JWT validation - check if it's properly formatted
const parts = token.split('.');
if (parts.length !== 3) return false;

// Decode payload to check expiration
const payload = JSON.parse(atob(parts[1]));
const now = Math.floor(Date.now() / 1000);

return payload.exp > now;
} catch (error) {
console.warn('Invalid token format:', error);
return false;
}
}
};

// Hook to manage auth cookies automatically
export const useAuthCookies = () => {
const { getToken, isSignedIn } = useClerkAuth();

useEffect(() => {
const syncToken = async () => {
if (isSignedIn) {
try {
const token = await getToken();
if (token) {
AuthCookies.set(token);
}
} catch (error) {
console.error('Failed to get or set auth token:', error);
}
} else {
AuthCookies.remove();
}
};

syncToken();

// Set up periodic token refresh
const interval = setInterval(syncToken, 5 * 60 * 1000); // Every 5 minutes

return () => clearInterval(interval);
}, [isSignedIn, getToken]);

return {
getStoredToken: () => AuthCookies.get(),
isTokenValid: () => AuthCookies.isValid(),
clearToken: () => AuthCookies.remove()
};
};
Loading
Loading