feat(web): JSON-file passkey store with atomic writes + write lock
This commit is contained in:
parent
a8751b6731
commit
7a6569800e
99
web/lib/auth-store.ts
Normal file
99
web/lib/auth-store.ts
Normal file
@ -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<string, PasskeyRecord[]>;
|
||||||
|
|
||||||
|
let writeLock: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
|
async function readAll(): Promise<StoreShape> {
|
||||||
|
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<void> {
|
||||||
|
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<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
|
const next = writeLock.then(fn, fn);
|
||||||
|
writeLock = next.then(
|
||||||
|
() => undefined,
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readPasskeys(username: string): Promise<PasskeyRecord[]> {
|
||||||
|
const all = await readAll();
|
||||||
|
return all[username] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function appendPasskey(username: string, rec: PasskeyRecord): Promise<void> {
|
||||||
|
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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user