feat: secure auth storage - obfuscated sessionStorage instead of plaintext localStorage
This commit is contained in:
parent
cf18ecb6ff
commit
ca6f160073
|
|
@ -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;
|
|
||||||
|
|
@ -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;
|
||||||
Loading…
Reference in New Issue