feat: secure auth storage - obfuscated sessionStorage instead of plaintext localStorage

This commit is contained in:
root 2026-05-25 15:32:17 +00:00
parent cf18ecb6ff
commit ca6f160073
2 changed files with 123 additions and 20 deletions

View File

@ -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;

123
src/supabaseClient.jsx Normal file
View File

@ -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<string> {
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<string> {
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<string, string>;
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;