From 3cc12c8b48449a8669101d729034ee62f84fa34c Mon Sep 17 00:00:00 2001 From: ServerBob Date: Thu, 5 Mar 2026 08:01:27 +0000 Subject: [PATCH] fix(auth): route supabase auth via caddy and harden session setup --- Caddyfile | 2 + apps/client/src/features/server/actions.ts | 6 +++ apps/client/src/screens/connect/index.tsx | 63 +++++++++++++++++----- 3 files changed, 59 insertions(+), 12 deletions(-) diff --git a/Caddyfile b/Caddyfile index 9f5936a..8c4daaf 100644 --- a/Caddyfile +++ b/Caddyfile @@ -1,5 +1,7 @@ chat.serverbob.org { encode gzip + @supabaseAuth path /auth/v1/* + reverse_proxy @supabaseAuth pulse-kong:8000 reverse_proxy pulse:4991 tls zax@serverbob.org } diff --git a/apps/client/src/features/server/actions.ts b/apps/client/src/features/server/actions.ts index 5dfc913..9bf458e 100644 --- a/apps/client/src/features/server/actions.ts +++ b/apps/client/src/features/server/actions.ts @@ -12,6 +12,7 @@ import { logDebug } from '@/helpers/browser-logger'; import { getHostFromServer } from '@/helpers/get-file-url'; import { applyServerPreferences } from '@/lib/preferences-apply'; import { seedPreferencesFromLocalStorage } from '@/lib/preferences-seed'; +import { getAccessToken } from '@/lib/supabase'; import { cleanup, connectToTRPC, getHomeTRPCClient } from '@/lib/trpc'; import { type TPublicServerSettings, type TServerInfo } from '@pulse/shared'; import { store } from '../store'; @@ -71,6 +72,11 @@ export const connect = async () => { throw new Error('Failed to fetch server info'); } + const accessToken = await getAccessToken(); + if (!accessToken) { + throw new Error('Missing authentication token'); + } + const host = getHostFromServer(); const trpc = await connectToTRPC(host); diff --git a/apps/client/src/screens/connect/index.tsx b/apps/client/src/screens/connect/index.tsx index f5cd8ad..c49bac6 100644 --- a/apps/client/src/screens/connect/index.tsx +++ b/apps/client/src/screens/connect/index.tsx @@ -17,7 +17,7 @@ import { setLocalStorageItem } from '@/helpers/storage'; import { useForm } from '@/hooks/use-form'; -import { supabase } from '@/lib/supabase'; +import { getAccessToken, initSupabase, supabase } from '@/lib/supabase'; import type { Provider } from '@supabase/supabase-js'; import { memo, useCallback, useMemo, useState } from 'react'; import { toast } from 'sonner'; @@ -62,6 +62,38 @@ const Connect = memo(() => { return invite || undefined; }, []); + const ensureSupabaseReady = useCallback(() => { + if (supabase) return true; + if (info?.supabaseUrl && info?.supabaseAnonKey) { + initSupabase(info.supabaseUrl, info.supabaseAnonKey); + return !!supabase; + } + return false; + }, [info?.supabaseUrl, info?.supabaseAnonKey]); + + const establishSession = useCallback( + async (accessToken: string, refreshToken: string) => { + const { error } = await supabase.auth.setSession({ + access_token: accessToken, + refresh_token: refreshToken + }); + + if (error) { + throw new Error(`Failed to store session: ${error.message}`); + } + + // Ensure token is visible to the ws connectionParams path before connect(). + for (let i = 0; i < 5; i += 1) { + const token = await getAccessToken(); + if (token) return; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + + throw new Error('Failed to read authentication token after login'); + }, + [] + ); + const onRememberCredentialsChange = useCallback( (checked: boolean) => { loginForm.onChange('rememberCredentials', checked); @@ -81,6 +113,10 @@ const Connect = memo(() => { setLoading(true); try { + if (!ensureSupabaseReady()) { + throw new Error('Authentication client is not initialized'); + } + const url = getUrlFromServer(); const response = await fetch(`${url}/login`, { method: 'POST', @@ -102,10 +138,7 @@ const Connect = memo(() => { refreshToken: string; }; - await supabase.auth.setSession({ - access_token: data.accessToken, - refresh_token: data.refreshToken - }); + await establishSession(data.accessToken, data.refreshToken); if (loginForm.values.rememberCredentials) { setLocalStorageItem(LocalStorageKey.EMAIL, loginForm.values.email); @@ -137,12 +170,16 @@ const Connect = memo(() => { setLoading(false); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [loginForm.values, loginForm.setErrors, inviteCode]); + }, [loginForm.values, loginForm.setErrors, inviteCode, ensureSupabaseReady, establishSession]); const onRegisterClick = useCallback(async () => { setLoading(true); try { + if (!ensureSupabaseReady()) { + throw new Error('Authentication client is not initialized'); + } + const url = getUrlFromServer(); const response = await fetch(`${url}/register`, { method: 'POST', @@ -166,10 +203,7 @@ const Connect = memo(() => { refreshToken: string; }; - await supabase.auth.setSession({ - access_token: data.accessToken, - refresh_token: data.refreshToken - }); + await establishSession(data.accessToken, data.refreshToken); await connect(); @@ -197,10 +231,15 @@ const Connect = memo(() => { setLoading(false); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [registerForm.values, registerForm.setErrors, inviteCode]); + }, [registerForm.values, registerForm.setErrors, inviteCode, ensureSupabaseReady, establishSession]); const onOAuthClick = useCallback( async (provider: string) => { + if (!ensureSupabaseReady()) { + toast.error('Authentication client is not initialized'); + return; + } + const redirectTo = new URL(window.location.origin); if (inviteCode) { @@ -214,7 +253,7 @@ const Connect = memo(() => { } }); }, - [inviteCode] + [inviteCode, ensureSupabaseReady] ); const logoSrc = useMemo(() => {