From e39bf4f977bba2952a7de4a307b14c5ec862a3a7 Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Mon, 16 Feb 2026 11:51:25 -0800 Subject: passkey auth --- bun.lock | 3 + package.json | 1 + src/components/Account.svelte | 2 +- src/lib/ark.ts | 47 +++++--- src/lib/passkey.ts | 14 +++ src/lib/utils.ts | 38 ++++--- src/routes/(app)/+layout.svelte | 15 ++- src/routes/(static)/login/+page.server.ts | 47 ++++++++ src/routes/(static)/login/+page.svelte | 44 ++++++++ src/routes/(static)/register/+page.server.ts | 4 +- src/routes/(static)/register/+page.svelte | 159 +++++++++++++++------------ src/routes/passkey/[...path]/+server.ts | 18 +++ 12 files changed, 285 insertions(+), 107 deletions(-) create mode 100644 src/lib/passkey.ts create mode 100644 src/routes/passkey/[...path]/+server.ts diff --git a/bun.lock b/bun.lock index feac3573..c9cdb331 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,7 @@ "dependencies": { "@arkade-os/sdk": "^0.3.12", "@capacitor/status-bar": "^8.0.0", + "@simplewebauthn/browser": "^13.2.2", "@tailwindcss/postcss": "^4.1.18", "@tailwindcss/vite": "^4.1.18", "canvas": "^3.2.1", @@ -347,6 +348,8 @@ "@scure/btc-signer": ["@scure/btc-signer@2.0.1", "", { "dependencies": { "@noble/curves": "~2.0.0", "@noble/hashes": "~2.0.0", "@scure/base": "~2.0.0", "micro-packed": "~0.8.0" } }, "sha512-vk5a/16BbSFZkhh1JIJ0+4H9nceZVo5WzKvJGGWiPp3sQOExeW+L53z3dI6u0adTPoE8ZbL+XEb6hEGzVZSvvQ=="], + "@simplewebauthn/browser": ["@simplewebauthn/browser@13.2.2", "", {}, "sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA=="], + "@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.5", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ=="], diff --git a/package.json b/package.json index 0e127764..de734b37 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "dependencies": { "@arkade-os/sdk": "^0.3.12", "@capacitor/status-bar": "^8.0.0", + "@simplewebauthn/browser": "^13.2.2", "@tailwindcss/postcss": "^4.1.18", "@tailwindcss/vite": "^4.1.18", "canvas": "^3.2.1", diff --git a/src/components/Account.svelte b/src/components/Account.svelte index adea5c70..064f153f 100644 --- a/src/components/Account.svelte +++ b/src/components/Account.svelte @@ -65,7 +65,7 @@ document.cookie = `aid=${id}; path=/; max-age=86400`; if (isArk) { pendingUrl = url; - if ($arkkey) { + if ($arkkey && $arkaid === id) { goto(pendingUrl!); } else if (await tryUnlockArk()) { goto(pendingUrl!); diff --git a/src/lib/ark.ts b/src/lib/ark.ts index a90236da..a3939a40 100644 --- a/src/lib/ark.ts +++ b/src/lib/ark.ts @@ -52,10 +52,13 @@ export const getVtxos = async () => { return wallet.getVtxos({ spendableOnly: false }); }; -export const syncTransactions = async (aid: string) => { +const _syncTransactions = async (aid: string) => { const wallet = await getWallet(); if (!wallet) return; + const walletAddr = await wallet.getAddress(); + console.log("arkSync: wallet address", walletAddr); + const transactions: any[] = []; const history = await wallet.getTransactionHistory(); @@ -91,9 +94,17 @@ export const syncTransactions = async (aid: string) => { }); } - if (!transactions.length) return; + const bal = await wallet.getBalance(); + const balance = (bal.available || 0) + (bal.preconfirmed || 0); - return post("/post/ark/sync", { transactions, aid }); + return post("/post/ark/sync", { transactions, aid, balance }); +}; + +export const syncTransactions = async (aid: string) => { + if (arkSending) return; + const p = _syncTransactions(aid); + syncPromise = p; + try { return await p; } finally { if (syncPromise === p) syncPromise = null; } }; export const vtxoKey = (v: { txid: string; vout: number }) => `${v.txid}:${v.vout}`; @@ -113,8 +124,9 @@ export const addProcessedVtxos = (keys: string[]) => { localStorage.setItem("processedArkVtxos", JSON.stringify(arr)); }; -// Flag to suppress notifyIncomingFunds during sends +// Flag to suppress sync during sends export let arkSending = false; +let syncPromise: Promise | null = null; const withTimeout = (promise: Promise, ms: number, label: string) => Promise.race([ @@ -128,23 +140,22 @@ export const sendArk = async (address: string, amount: number) => { const wallet = await getWallet(); if (!wallet) throw new Error("Ark wallet not available"); - // Refresh expired VTXOs before attempting to send - const balance = await wallet.getBalance(); - if (balance.available < amount && balance.recoverable > 0) { - console.log("ark send: refreshing expired VTXOs before send"); - await refresh(); - } - - const freshBalance = await wallet.getBalance(); - if (freshBalance.available < amount) { - throw new Error(`Insufficient funds: ${freshBalance.available} available, need ${amount}`); - } - - // Suppress incoming notifications during send to prevent - // change VTXOs from being credited as incoming payments + // Suppress sync polling and wait for any in-flight sync to finish arkSending = true; + if (syncPromise) await syncPromise.catch(() => {}); try { + // Refresh expired VTXOs before attempting to send + const balance = await wallet.getBalance(); + if (balance.available < amount && balance.recoverable > 0) { + console.log("ark send: refreshing expired VTXOs before send"); + await refresh(); + } + + const freshBalance = await wallet.getBalance(); + if (freshBalance.available < amount) { + throw new Error(`Insufficient funds: ${freshBalance.available} available, need ${amount}`); + } let txid; try { txid = await withTimeout( diff --git a/src/lib/passkey.ts b/src/lib/passkey.ts new file mode 100644 index 00000000..228b254e --- /dev/null +++ b/src/lib/passkey.ts @@ -0,0 +1,14 @@ +import { startRegistration, startAuthentication } from "@simplewebauthn/browser"; +import { post } from "$lib/utils"; + +export async function registerPasskey() { + const options = await post("/passkey/register/options", {}); + const registration = await startRegistration({ optionsJSON: options }); + await post("/passkey/register/verify", registration); +} + +export async function loginWithPasskey() { + const { challengeId, ...options } = await post("/passkey/login/options", {}); + const authentication = await startAuthentication({ optionsJSON: options }); + return { credential: authentication, challengeId }; +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index a96db03d..28b63038 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -508,29 +508,41 @@ export const register = async ( }; if (ip) headers["cf-connecting-ip"] = ip; - let sk; + let sk, token; try { - ({ sk } = await post("/register", { user }, headers)); + ({ sk, token } = await post("/register", { user }, headers)); } catch (e) { ({ message: error } = e as Error); } - try { - await login(user, cookies, ip, host, extraHeaders); + if (user.password) { + try { + await login(user, cookies, ip, host, extraHeaders); + error = null; + } catch (e) { + const { message } = e as Error; + error ||= message; + } + } else if (token) { + const maxAge = 380 * 24 * 60 * 60; + const expires = new Date(); + expires.setSeconds(expires.getSeconds() + maxAge); + const opts = { path: "/", expires }; + cookies.set("username", user.username, opts); + cookies.set("token", token, opts); error = null; - } catch (e) { - const { message } = e as Error; - error ||= message; } const expires = new Date(); expires.setSeconds(expires.getSeconds() + 21000000); - cookies.set("sk", sk, { - path: "/", - expires, - httpOnly: false, - sameSite: "lax", - }); + if (sk) { + cookies.set("sk", sk, { + path: "/", + expires, + httpOnly: false, + sameSite: "lax", + }); + } if (error) return svelteFail(400, { error }); redirect(303, loginRedirect || `/${user.username}`); diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index 2eda8072..732ac61e 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -1,5 +1,5 @@