123 lines
3.6 KiB
JavaScript
123 lines
3.6 KiB
JavaScript
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() {
|
|
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) {
|
|
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) {
|
|
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) => {
|
|
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, value) => {
|
|
let data;
|
|
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) => {
|
|
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; |