From 293e97fa6903d6c0a933e01bf1e9af41a0bece3a Mon Sep 17 00:00:00 2001 From: marko-kraemer Date: Fri, 25 Jul 2025 20:04:00 +0200 Subject: [PATCH] refresh token fix --- frontend/src/app/auth/callback/route.ts | 69 ++++++++++----- frontend/src/components/AuthProvider.tsx | 71 ++++++++++------ frontend/src/lib/supabase/client.ts | 30 ++----- frontend/src/lib/supabase/middleware.ts | 102 ----------------------- frontend/src/lib/supabase/server.ts | 61 ++++++-------- frontend/src/middleware.ts | 85 +++++++++++++++++++ 6 files changed, 212 insertions(+), 206 deletions(-) delete mode 100644 frontend/src/lib/supabase/middleware.ts create mode 100644 frontend/src/middleware.ts diff --git a/frontend/src/app/auth/callback/route.ts b/frontend/src/app/auth/callback/route.ts index 05e9ca7a..2e19d472 100644 --- a/frontend/src/app/auth/callback/route.ts +++ b/frontend/src/app/auth/callback/route.ts @@ -1,28 +1,51 @@ -import { createClient } from '@/lib/supabase/server'; -import { NextResponse } from 'next/server'; -import { checkAndInstallSunaAgent } from '@/lib/utils/install-suna-agent'; +import { createClient } from '@/lib/supabase/server' +import { NextResponse } from 'next/server' +import type { NextRequest } from 'next/server' -export async function GET(request: Request) { - // The `/auth/callback` route is required for the server-side auth flow implemented - // by the SSR package. It exchanges an auth code for the user's session. - // https://supabase.com/docs/guides/auth/server-side/nextjs - const requestUrl = new URL(request.url); - const code = requestUrl.searchParams.get('code'); - const returnUrl = requestUrl.searchParams.get('returnUrl'); - const origin = requestUrl.origin; +export async function GET(request: NextRequest) { + const { searchParams, origin } = new URL(request.url) + const code = searchParams.get('code') + const next = searchParams.get('next') ?? '/dashboard' + const error = searchParams.get('error') + const errorDescription = searchParams.get('error_description') - if (code) { - const supabase = await createClient(); - const { data, error } = await supabase.auth.exchangeCodeForSession(code); - await checkAndInstallSunaAgent(data.user.id, data.user.created_at); + console.log('🔵 Auth callback triggered:', { + hasCode: !!code, + next, + error, + errorDescription + }) + + if (error) { + console.error('❌ Auth callback error:', error, errorDescription) + return NextResponse.redirect(`${origin}/auth?error=${encodeURIComponent(error)}`) } - // URL to redirect to after sign up process completes - // Handle the case where returnUrl is 'null' (string) or actual null - const redirectPath = - returnUrl && returnUrl !== 'null' ? returnUrl : '/dashboard'; - // Make sure to include a slash between origin and path if needed - return NextResponse.redirect( - `${origin}${redirectPath.startsWith('/') ? '' : '/'}${redirectPath}`, - ); + if (code) { + const supabase = await createClient() + + try { + const { data, error } = await supabase.auth.exchangeCodeForSession(code) + + if (error) { + console.error('❌ Error exchanging code for session:', error) + return NextResponse.redirect(`${origin}/auth?error=${encodeURIComponent(error.message)}`) + } + + console.log('✅ Successfully exchanged code for session:', { + userId: data.user?.id, + email: data.user?.email + }) + + // URL to redirect to after sign in process completes + return NextResponse.redirect(`${origin}${next}`) + } catch (error) { + console.error('❌ Unexpected error in auth callback:', error) + return NextResponse.redirect(`${origin}/auth?error=unexpected_error`) + } + } + + // No code present, redirect to auth page + console.log('⚠️ No code present in auth callback, redirecting to auth') + return NextResponse.redirect(`${origin}/auth`) } diff --git a/frontend/src/components/AuthProvider.tsx b/frontend/src/components/AuthProvider.tsx index 270474a9..825475a0 100644 --- a/frontend/src/components/AuthProvider.tsx +++ b/frontend/src/components/AuthProvider.tsx @@ -30,37 +30,55 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { useEffect(() => { const getInitialSession = async () => { - const { - data: { session: currentSession }, - } = await supabase.auth.getSession(); - setSession(currentSession); - setUser(currentSession?.user ?? null); - setIsLoading(false); + try { + const { + data: { session: currentSession }, + } = await supabase.auth.getSession(); + console.log('🔵 Initial session check:', { hasSession: !!currentSession, user: !!currentSession?.user }); + setSession(currentSession); + setUser(currentSession?.user ?? null); + } catch (error) { + console.error('❌ Error getting initial session:', error); + } finally { + setIsLoading(false); + } }; getInitialSession(); const { data: authListener } = supabase.auth.onAuthStateChange( async (event, newSession) => { - console.log('🔵 Auth state change:', { event, session: !!newSession, user: !!newSession?.user }); + console.log('🔵 Auth state change:', { + event, + hasSession: !!newSession, + hasUser: !!newSession?.user, + expiresAt: newSession?.expires_at + }); setSession(newSession); - - // Only update user state on actual auth events, not token refresh - if (event === 'SIGNED_IN' || event === 'SIGNED_OUT') { - setUser(newSession?.user ?? null); - } - // For TOKEN_REFRESHED events, keep the existing user state + setUser(newSession?.user ?? null); if (isLoading) setIsLoading(false); - if (event === 'SIGNED_IN' && newSession?.user) { - await checkAndInstallSunaAgent(newSession.user.id, newSession.user.created_at); - } else if (event === 'MFA_CHALLENGE_VERIFIED') { - console.log('✅ MFA challenge verified, session updated'); - // Session is automatically updated by Supabase, just log for debugging - } else if (event === 'TOKEN_REFRESHED') { - console.log('🔄 Token refreshed, session updated'); + // Handle specific auth events + switch (event) { + case 'SIGNED_IN': + if (newSession?.user) { + console.log('✅ User signed in'); + await checkAndInstallSunaAgent(newSession.user.id, newSession.user.created_at); + } + break; + case 'SIGNED_OUT': + console.log('✅ User signed out'); + break; + case 'TOKEN_REFRESHED': + console.log('🔄 Token refreshed successfully'); + break; + case 'MFA_CHALLENGE_VERIFIED': + console.log('✅ MFA challenge verified'); + break; + default: + console.log(`🔵 Auth event: ${event}`); } }, ); @@ -68,11 +86,16 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { return () => { authListener?.subscription.unsubscribe(); }; - }, [supabase, isLoading]); // Added isLoading to dependencies to ensure it runs once after initial load completes + }, [supabase]); // Removed isLoading from dependencies to prevent infinite loops const signOut = async () => { - await supabase.auth.signOut(); - // State updates will be handled by onAuthStateChange + try { + console.log('🔵 Signing out...'); + await supabase.auth.signOut(); + // State updates will be handled by onAuthStateChange + } catch (error) { + console.error('❌ Error signing out:', error); + } }; const value = { @@ -86,7 +109,7 @@ export const AuthProvider = ({ children }: { children: ReactNode }) => { return {children}; }; -export const useAuth = (): AuthContextType => { +export const useAuth = () => { const context = useContext(AuthContext); if (context === undefined) { throw new Error('useAuth must be used within an AuthProvider'); diff --git a/frontend/src/lib/supabase/client.ts b/frontend/src/lib/supabase/client.ts index 1a39d2f9..792b4570 100644 --- a/frontend/src/lib/supabase/client.ts +++ b/frontend/src/lib/supabase/client.ts @@ -1,24 +1,8 @@ -import { createBrowserClient } from '@supabase/ssr'; +import { createBrowserClient } from '@supabase/ssr' -export const createClient = () => { - // Get URL and key from environment variables - let supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; - - // Ensure the URL is in the proper format with http/https protocol - if (supabaseUrl && !supabaseUrl.startsWith('http')) { - // If it's just a hostname without protocol, add http:// - supabaseUrl = `http://${supabaseUrl}`; - } - - // console.log('Supabase URL:', supabaseUrl); - // console.log('Supabase Anon Key:', supabaseAnonKey); - - return createBrowserClient(supabaseUrl, supabaseAnonKey, { - auth: { - autoRefreshToken: true, - persistSession: true, - detectSessionInUrl: true - } - }); -}; +export function createClient() { + return createBrowserClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! + ) +} diff --git a/frontend/src/lib/supabase/middleware.ts b/frontend/src/lib/supabase/middleware.ts deleted file mode 100644 index ae3c8c2e..00000000 --- a/frontend/src/lib/supabase/middleware.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { createServerClient, type CookieOptions } from '@supabase/ssr'; -import { type NextRequest, NextResponse } from 'next/server'; - -function forceLoginWithReturn(request: NextRequest) { - const originalUrl = new URL(request.url); - const path = originalUrl.pathname; - const query = originalUrl.searchParams.toString(); - return NextResponse.redirect( - new URL( - `/auth?returnUrl=${encodeURIComponent(path + (query ? `?${query}` : ''))}`, - request.url, - ), - ); -} - -export const validateSession = async (request: NextRequest) => { - // This `try/catch` block is only here for the interactive tutorial. - // Feel free to remove once you have Supabase connected. - try { - // Create an unmodified response - let response = NextResponse.next({ - request: { - headers: request.headers, - }, - }); - - const supabase = createServerClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, - { - cookies: { - get(name: string) { - return request.cookies.get(name)?.value; - }, - set(name: string, value: string, options: CookieOptions) { - // If the cookie is updated, update the cookies for the request and response - request.cookies.set({ - name, - value, - ...options, - }); - response = NextResponse.next({ - request: { - headers: request.headers, - }, - }); - response.cookies.set({ - name, - value, - ...options, - }); - }, - remove(name: string, options: CookieOptions) { - // If the cookie is removed, update the cookies for the request and response - request.cookies.set({ - name, - value: '', - ...options, - }); - response = NextResponse.next({ - request: { - headers: request.headers, - }, - }); - response.cookies.set({ - name, - value: '', - ...options, - }); - }, - }, - }, - ); - - // This will refresh session if expired - required for Server Components - // https://supabase.com/docs/guides/auth/server-side/nextjs - const { - data: { user }, - } = await supabase.auth.getUser(); - - const protectedRoutes = ['/dashboard', '/invitation']; - - if ( - !user && - protectedRoutes.some((path) => request.nextUrl.pathname.startsWith(path)) - ) { - // redirect to /auth - return forceLoginWithReturn(request); - } - - return response; - } catch (e) { - // If you are here, a Supabase client could not be created! - // This is likely because you have not set up environment variables. - // Check out http://localhost:3000 for Next Steps. - return NextResponse.next({ - request: { - headers: request.headers, - }, - }); - } -}; diff --git a/frontend/src/lib/supabase/server.ts b/frontend/src/lib/supabase/server.ts index 806e3021..d5904bf8 100644 --- a/frontend/src/lib/supabase/server.ts +++ b/frontend/src/lib/supabase/server.ts @@ -1,37 +1,30 @@ -'use server'; -import { createServerClient, type CookieOptions } from '@supabase/ssr'; -import { cookies } from 'next/headers'; +'use server' +import { createServerClient } from '@supabase/ssr' +import { cookies } from 'next/headers' -export const createClient = async () => { - const cookieStore = await cookies(); - let supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!; - const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!; +export async function createClient() { + const cookieStore = await cookies() - // Ensure the URL is in the proper format with http/https protocol - if (supabaseUrl && !supabaseUrl.startsWith('http')) { - // If it's just a hostname without protocol, add http:// - supabaseUrl = `http://${supabaseUrl}`; - } - - // console.log('[SERVER] Supabase URL:', supabaseUrl); - // console.log('[SERVER] Supabase Anon Key:', supabaseAnonKey); - - return createServerClient(supabaseUrl, supabaseAnonKey, { - cookies: { - getAll() { - return cookieStore.getAll(); + return createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return cookieStore.getAll() + }, + setAll(cookiesToSet) { + try { + cookiesToSet.forEach(({ name, value, options }) => + cookieStore.set(name, value, options) + ) + } catch { + // The `setAll` method was called from a Server Component. + // This can be ignored if you have middleware refreshing + // user sessions. + } + }, }, - setAll(cookiesToSet) { - try { - cookiesToSet.forEach(({ name, value, options }) => - cookieStore.set({ name, value, ...options }), - ); - } catch (error) { - // The `set` method was called from a Server Component. - // This can be ignored if you have middleware refreshing - // user sessions. - } - }, - }, - }); -}; + } + ) +} diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts new file mode 100644 index 00000000..aba29bef --- /dev/null +++ b/frontend/src/middleware.ts @@ -0,0 +1,85 @@ +import { createServerClient } from '@supabase/ssr' +import { NextResponse, type NextRequest } from 'next/server' + +export async function middleware(request: NextRequest) { + let supabaseResponse = NextResponse.next({ + request, + }) + + const supabase = createServerClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + { + cookies: { + getAll() { + return request.cookies.getAll() + }, + setAll(cookiesToSet) { + cookiesToSet.forEach(({ name, value }) => request.cookies.set(name, value)) + supabaseResponse = NextResponse.next({ + request, + }) + cookiesToSet.forEach(({ name, value, options }) => + supabaseResponse.cookies.set(name, value, options) + ) + }, + }, + } + ) + + // IMPORTANT: DO NOT REMOVE auth.getUser() + // This refreshes the Auth token and is required for server-side auth + const { + data: { user }, + } = await supabase.auth.getUser() + + // Define protected routes + const protectedRoutes = ['/dashboard', '/agents', '/projects', '/settings', '/invitation'] + const authRoutes = ['/auth', '/login', '/signup'] + + const isProtectedRoute = protectedRoutes.some(route => + request.nextUrl.pathname.startsWith(route) + ) + const isAuthRoute = authRoutes.some(route => + request.nextUrl.pathname.startsWith(route) + ) + + // Redirect unauthenticated users from protected routes + if (isProtectedRoute && !user) { + const redirectUrl = new URL('/auth', request.url) + redirectUrl.searchParams.set('returnUrl', request.nextUrl.pathname) + return NextResponse.redirect(redirectUrl) + } + + // Redirect authenticated users away from auth routes + if (isAuthRoute && user) { + return NextResponse.redirect(new URL('/dashboard', request.url)) + } + + // IMPORTANT: You *must* return the supabaseResponse object as it is. + // If you're creating a new response object with NextResponse.next() make sure to: + // 1. Pass the request in it, like so: + // const myNewResponse = NextResponse.next({ request }) + // 2. Copy over the cookies, like so: + // myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll()) + // 3. Change the myNewResponse object to fit your needs, but avoid changing + // the cookies! + // 4. Finally: + // return myNewResponse + // If this is not done, you may be causing the browser and server to go out + // of sync and terminate the user's session prematurely! + return supabaseResponse +} + +export const config = { + matcher: [ + /* + * Match all request paths except for the ones starting with: + * - _next/static (static files) + * - _next/image (image optimization files) + * - favicon.ico (favicon file) + * Feel free to modify this pattern to include more paths. + */ + '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)', + ], +} \ No newline at end of file