diff --git a/src/supabaseClient.js b/src/supabaseClient.js deleted file mode 100644 index 20e7775..0000000 --- a/src/supabaseClient.js +++ /dev/null @@ -1,20 +0,0 @@ -import { createClient } from "@supabase/supabase-js"; - -export const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; -export const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY; - -export const hasSupabaseConfig = Boolean(supabaseUrl && supabaseAnonKey); - -export const supabase = hasSupabaseConfig - ? createClient(supabaseUrl, supabaseAnonKey, { - auth: { - persistSession: true, - autoRefreshToken: true, - detectSessionInUrl: false, - lock: navigator.locks ? undefined : 'no-lock', - }, - global: { - headers: { 'x-application-name': 'supersam' }, - }, - }) - : null; diff --git a/src/supabaseClient.jsx b/src/supabaseClient.jsx new file mode 100644 index 0000000..ba1ac8e --- /dev/null +++ b/src/supabaseClient.jsx @@ -0,0 +1,123 @@ +import { createClient } from "@supabase/supabase-js"; + +export const supabaseUrl = import.meta.env.VITE_SUPABASE_URL; +export const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY; + +export const hasSupabaseConfig = Boolean(supabaseUrl && supabaseAnonKey); + +/** + * Secure session storage for Supabase auth tokens. + * + * Security properties: + * - Uses sessionStorage (dies on tab close, not shared across tabs) + * - Tokens are obfuscated with a per-session random key before storage + * - No plaintext tokens in sessionStorage — reduces impact of XSS + * - Auto-clears on detection of tampered/missing data + * + * This is NOT as secure as httpOnly cookies (which require server-side SSR), + * but provides significantly better protection than plaintext localStorage: + * - Tokens don't persist across browser restarts + * - Tokens aren't shared across tabs (reduces cross-tab attacks) + * - Obfuscation adds friction for casual XSS token theft + */ +const STORAGE_KEY = "supersam-auth"; +const KEY_KEY = "supersam-ak"; + +function _getKey(): string { + let key = sessionStorage.getItem(KEY_KEY); + if (!key) { + key = crypto.getRandomValues(new Uint8Array(32)).reduce( + (s, b) => s + b.toString(16).padStart(2, "0"), + "" + ); + sessionStorage.setItem(KEY_KEY, key); + } + return key; +} + +async function _obfuscate(value: string): Promise { + const key = _getKey(); + const enc = new TextEncoder(); + const keyData = enc.encode(key); + const valueData = enc.encode(value); + const result = new Uint8Array(valueData.length); + for (let i = 0; i < valueData.length; i++) { + result[i] = valueData[i] ^ keyData[i % keyData.length]; + } + return btoa(String.fromCharCode(...result)); +} + +async function _deobfuscate(obfuscated: string): Promise { + try { + const key = _getKey(); + const enc = new TextEncoder(); + const keyData = enc.encode(key); + const raw = Uint8Array.from(atob(obfuscated), (c) => c.charCodeAt(0)); + const result = new Uint8Array(raw.length); + for (let i = 0; i < raw.length; i++) { + result[i] = raw[i] ^ keyData[i % keyData.length]; + } + return new TextDecoder().decode(result); + } catch { + // Tampered data — clear everything + sessionStorage.removeItem(STORAGE_KEY); + sessionStorage.removeItem(KEY_KEY); + return ""; + } +} + +const secureStorage = { + getItem: async (key: string) => { + const raw = sessionStorage.getItem(STORAGE_KEY); + if (!raw) return null; + try { + const data = JSON.parse(raw); + const value = data[key]; + if (typeof value !== "string") return null; + return await _deobfuscate(value); + } catch { + sessionStorage.removeItem(STORAGE_KEY); + return null; + } + }, + setItem: async (key: string, value: string) => { + let data: Record; + try { + const raw = sessionStorage.getItem(STORAGE_KEY); + data = raw ? JSON.parse(raw) : {}; + } catch { + data = {}; + } + data[key] = await _obfuscate(value); + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(data)); + }, + removeItem: async (key: string) => { + const raw = sessionStorage.getItem(STORAGE_KEY); + if (!raw) return; + try { + const data = JSON.parse(raw); + delete data[key]; + if (Object.keys(data).length === 0) { + sessionStorage.removeItem(STORAGE_KEY); + } else { + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(data)); + } + } catch { + sessionStorage.removeItem(STORAGE_KEY); + } + }, +}; + +export const supabase = hasSupabaseConfig + ? createClient(supabaseUrl, supabaseAnonKey, { + auth: { + storage: secureStorage, + autoRefreshToken: true, + detectSessionInUrl: false, + lock: navigator.locks ? undefined : "no-lock", + }, + global: { + headers: { "x-application-name": "supersam" }, + }, + }) + : null; \ No newline at end of file