From 7a6569800e5e0269d1a972a1d272379e1d386358 Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sun, 3 May 2026 08:27:21 +0800 Subject: [PATCH] feat(web): JSON-file passkey store with atomic writes + write lock --- web/lib/auth-store.ts | 99 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 web/lib/auth-store.ts diff --git a/web/lib/auth-store.ts b/web/lib/auth-store.ts new file mode 100644 index 0000000..d21f1f1 --- /dev/null +++ b/web/lib/auth-store.ts @@ -0,0 +1,99 @@ +import "server-only"; +import { promises as fs } from "node:fs"; +import path from "node:path"; +import type { AuthenticatorTransportFuture } from "@simplewebauthn/server"; + +const FILE_PATH = process.env.CM_AUTH_STORE_PATH ?? "/data/auth/passkeys.json"; + +export type PasskeyRecord = { + id: string; + publicKey: string; + counter: number; + transports: AuthenticatorTransportFuture[]; + name: string; + createdAt: string; +}; + +type StoreShape = Record; + +let writeLock: Promise = Promise.resolve(); + +async function readAll(): Promise { + try { + const raw = await fs.readFile(FILE_PATH, "utf8"); + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as StoreShape; + } + return {}; + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "ENOENT") return {}; + throw err; + } +} + +async function writeAtomic(data: StoreShape): Promise { + const dir = path.dirname(FILE_PATH); + await fs.mkdir(dir, { recursive: true }); + const tmp = `${FILE_PATH}.tmp`; + const handle = await fs.open(tmp, "w"); + try { + await handle.writeFile(JSON.stringify(data, null, 2)); + await handle.sync(); + } finally { + await handle.close(); + } + await fs.rename(tmp, FILE_PATH); +} + +function withLock(fn: () => Promise): Promise { + const next = writeLock.then(fn, fn); + writeLock = next.then( + () => undefined, + () => undefined, + ); + return next; +} + +export async function readPasskeys(username: string): Promise { + const all = await readAll(); + return all[username] ?? []; +} + +export async function appendPasskey(username: string, rec: PasskeyRecord): Promise { + await withLock(async () => { + const all = await readAll(); + const list = all[username] ?? []; + list.push(rec); + all[username] = list; + await writeAtomic(all); + }); +} + +export async function removePasskey(username: string, credentialId: string): Promise { + return withLock(async () => { + const all = await readAll(); + const list = all[username] ?? []; + const next = list.filter((p) => p.id !== credentialId); + if (next.length === list.length) return false; + all[username] = next; + await writeAtomic(all); + return true; + }); +} + +export async function bumpCounter( + username: string, + credentialId: string, + counter: number, +): Promise { + await withLock(async () => { + const all = await readAll(); + const list = all[username] ?? []; + const idx = list.findIndex((p) => p.id === credentialId); + if (idx === -1) return; + list[idx] = { ...list[idx], counter }; + all[username] = list; + await writeAtomic(all); + }); +}