feat: authentication integration

This commit is contained in:
2025-05-28 20:51:34 +02:00
parent a1ed1113d9
commit aa152a4127
26 changed files with 1371 additions and 662 deletions

View File

@ -1,5 +1,6 @@
import { useDbContext } from "@/context/db";
import { useDbStore } from "@/store/db";
import { deriveDeviceKey, getDeviceId } from "@/util/deviceId";
import type { IDBPDatabase } from "idb";
import { useCallback } from "react";
export interface RawLocalAccount {
@ -31,110 +32,220 @@ export interface CreateAccountRequest {
refresh: string;
}
export interface UpdateAccountTokensRequest {
accountId: string;
access: string;
refresh: string;
}
export interface UpdateAccountInfoRequest {
accountId: string;
label?: string;
profilePicture?: string;
}
export const getDeviceKey = async () => {
const deviceId = await getDeviceId();
const deviceKey = await deriveDeviceKey(deviceId);
return deviceKey;
};
export const encryptToken = async (token: string) => {
const encoder = new TextEncoder();
const iv = crypto.getRandomValues(new Uint8Array(12));
const deviceKey = await getDeviceKey();
const cipherText = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
deviceKey,
encoder.encode(token)
);
return {
data: Array.from(new Uint8Array(cipherText)),
iv: Array.from(iv),
};
};
export const decryptToken = async (encrypted: {
data: number[];
iv: number[];
}) => {
const decoder = new TextDecoder();
const deviceKey = await getDeviceKey();
const decrypted = await crypto.subtle.decrypt(
{
name: "AES-GCM",
iv: new Uint8Array(encrypted.iv),
},
deviceKey,
new Uint8Array(encrypted.data)
);
return decoder.decode(decrypted);
};
export const saveAccount = async (
db: IDBPDatabase,
req: CreateAccountRequest
): Promise<LocalAccount> => {
const access = await encryptToken(req.access);
const refresh = await encryptToken(req.refresh);
const tx = db.transaction("accounts", "readwrite");
const store = tx.objectStore("accounts");
await store.put({
accountId: req.accountId,
label: req.label,
email: req.email,
profilePicture: req.profilePicture,
access,
refresh,
updatedAt: new Date().toISOString(),
});
await tx.done;
const account = await getAccount(db, req.accountId);
return account as LocalAccount;
};
export const getAllAccounts = async (db: IDBPDatabase) => {
const tx = db.transaction("accounts", "readonly");
const store = tx.objectStore("accounts");
const accounts: RawLocalAccount[] = await store.getAll();
const results: LocalAccount[] = (
await Promise.all(
accounts.map(async (account) => {
try {
const accessToken = await decryptToken(account.access);
const refreshToken = await decryptToken(account.refresh);
return {
accountId: account.accountId,
label: account.label,
email: account.email,
profilePicture: account.profilePicture,
access: accessToken,
refresh: refreshToken,
updatedAt: account.updatedAt,
};
} catch (err) {
console.warn(`Failed to decrypt account ${account.label}:`, err);
}
})
)
).filter((acc) => acc !== undefined);
return results;
};
export const getAccount = async (db: IDBPDatabase, accountId: string) => {
const tx = db.transaction("accounts", "readonly");
const store = tx.objectStore("accounts");
const account: RawLocalAccount = await store.get(accountId);
await tx.done;
if (!account) return null;
const accessToken = await decryptToken(account.access);
const refreshToken = await decryptToken(account.refresh);
return {
accountId: account.accountId,
label: account.label,
email: account.email,
profilePicture: account.profilePicture,
access: accessToken,
refresh: refreshToken,
updatedAt: account.updatedAt,
};
};
export const getAccountRaw = async (db: IDBPDatabase, accountId: string) => {
const tx = db.transaction("accounts", "readonly");
const store = tx.objectStore("accounts");
const account: RawLocalAccount = await store.get(accountId);
await tx.done;
if (!account) return null;
return account;
};
export const updateAccountTokens = async (
db: IDBPDatabase,
req: UpdateAccountTokensRequest
) => {
const account = await getAccountRaw(db, req.accountId);
const access = await encryptToken(req.access);
const refresh = await encryptToken(req.refresh);
await db?.put?.("accounts", {
...account,
accountId: req.accountId,
access,
refresh,
updatedAt: new Date().toISOString(),
});
};
export const updateAccountInfo = async (
db: IDBPDatabase,
req: UpdateAccountInfoRequest
) => {
const account = await getAccountRaw(db, req.accountId);
await db?.put?.("accounts", {
...account,
...req,
});
};
export const deleteAccount = async (db: IDBPDatabase, accountId: string) => {
const tx = db.transaction("accounts", "readwrite");
const store = tx.objectStore("accounts");
await store.delete(accountId);
await tx.done;
};
export const useAccountRepo = () => {
const { db } = useDbContext();
const getDeviceKey = useCallback(async () => {
const deviceId = await getDeviceId();
const deviceKey = await deriveDeviceKey(deviceId);
return deviceKey;
}, []);
const encryptToken = useCallback(
async (token: string) => {
const encoder = new TextEncoder();
const iv = crypto.getRandomValues(new Uint8Array(12));
const deviceKey = await getDeviceKey();
const cipherText = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
deviceKey,
encoder.encode(token)
);
return {
data: Array.from(new Uint8Array(cipherText)),
iv: Array.from(iv),
};
},
[getDeviceKey]
);
const decryptToken = useCallback(
async (encrypted: { data: number[]; iv: number[] }) => {
const decoder = new TextDecoder();
const deviceKey = await getDeviceKey();
const decrypted = await crypto.subtle.decrypt(
{
name: "AES-GCM",
iv: new Uint8Array(encrypted.iv),
},
deviceKey,
new Uint8Array(encrypted.data)
);
return decoder.decode(decrypted);
},
[getDeviceKey]
);
const db = useDbStore((state) => state.db);
const save = useCallback(
async (req: CreateAccountRequest) => {
if (!db) throw new Error("No database connection");
const access = await encryptToken(req.access);
const refresh = await encryptToken(req.refresh);
await db?.put?.("accounts", {
accountId: req.accountId,
label: req.label,
email: req.email,
profilePicture: req.profilePicture,
access,
refresh,
updatedAt: new Date().toISOString(),
});
return saveAccount(db, req);
},
[db, encryptToken]
[db]
);
const loadAll = useCallback(async () => {
if (!db) throw new Error("No database connection");
const tx = db.transaction("accounts", "readonly");
const store = tx.objectStore("accounts");
return getAllAccounts(db);
}, [db]);
const accounts: RawLocalAccount[] = await store.getAll();
const getOne = useCallback(
async (accountId: string) => {
if (!db) throw new Error("No database connection");
const results: LocalAccount[] = (
await Promise.all(
accounts.map(async (account) => {
try {
const accessToken = await decryptToken(account.access);
const refreshToken = await decryptToken(account.refresh);
return {
accountId: account.accountId,
label: account.label,
email: account.email,
profilePicture: account.profilePicture,
access: accessToken,
refresh: refreshToken,
updatedAt: account.updatedAt,
};
} catch (err) {
console.warn(`Failed to decrypt account ${account.label}:`, err);
}
})
)
).filter((acc) => acc !== undefined);
return getAccount(db, accountId);
},
[db]
);
return results;
}, [db, decryptToken]);
return { save, loadAll };
return { save, loadAll, getOne };
};