diff options
| author | Adam Soltys <[email protected]> | 2026-02-16 11:51:25 -0800 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-02-16 11:51:25 -0800 |
| commit | e39bf4f977bba2952a7de4a307b14c5ec862a3a7 (patch) | |
| tree | 1eac038796d376ad073eae6246bf43f15454bd66 /src | |
| parent | fe104c2364de35bcdb2a10ca469cd25c12b04289 (diff) | |
| download | coinos-ui-e39bf4f977bba2952a7de4a307b14c5ec862a3a7.tar.gz coinos-ui-e39bf4f977bba2952a7de4a307b14c5ec862a3a7.zip | |
passkey auth
Diffstat (limited to 'src')
| -rw-r--r-- | src/components/Account.svelte | 2 | ||||
| -rw-r--r-- | src/lib/ark.ts | 47 | ||||
| -rw-r--r-- | src/lib/passkey.ts | 14 | ||||
| -rw-r--r-- | src/lib/utils.ts | 38 | ||||
| -rw-r--r-- | src/routes/(app)/+layout.svelte | 15 | ||||
| -rw-r--r-- | src/routes/(static)/login/+page.server.ts | 47 | ||||
| -rw-r--r-- | src/routes/(static)/login/+page.svelte | 44 | ||||
| -rw-r--r-- | src/routes/(static)/register/+page.server.ts | 4 | ||||
| -rw-r--r-- | src/routes/(static)/register/+page.svelte | 157 | ||||
| -rw-r--r-- | src/routes/passkey/[...path]/+server.ts | 18 |
10 files changed, 280 insertions, 106 deletions
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<any> | null = null; const withTimeout = <T>(promise: Promise<T>, 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 @@ <script lang="ts"> - import { getWallet, syncTransactions, settle, refresh, arkkey, arkaid, sendArk } from "$lib/ark"; + import { getWallet, syncTransactions, settle, refresh, arkkey, arkaid, sendArk, arkSending } from "$lib/ark"; import { SvelteToast } from "@zerodevx/svelte-toast"; import { onDestroy, onMount } from "svelte"; import { close, connect, send, socket } from "$lib/socket"; @@ -55,7 +55,7 @@ let arkForwarding = false; let arkSync = async () => { - if (arkSyncing) return; + if (arkSyncing || arkSending) return; arkSyncing = true; try { @@ -75,7 +75,10 @@ arkForwarding = false; } } else if (result?.received > 0) { - if ($fiat && $rateStore && user?.currency) { + const paidPayment = result.payments?.find((p: any) => p.iid && p.amount > 0); + if (paidPayment) { + goto(`/invoice/${paidPayment.iid}`); + } else if ($fiat && $rateStore && user?.currency) { success(`Received ${f(toFiat(result.received, $rateStore as number), user.currency)}!`); } else { success(`Received ${s(result.received)}!`); @@ -92,6 +95,7 @@ let arkInitializedKey: string | undefined; let arkRefreshTimer: ReturnType<typeof setInterval> | undefined; + let arkSyncTimer: ReturnType<typeof setInterval> | undefined; const initArk = async () => { const key = $arkkey; @@ -106,6 +110,10 @@ refresh().catch((e) => console.error("Ark refresh failed:", e)); settle().catch((e) => console.error("Ark settle failed:", e)); + // Poll for incoming ark transactions (fallback for SSE) + if (arkSyncTimer) clearInterval(arkSyncTimer); + arkSyncTimer = setInterval(() => arkSync(), 15_000); + // Periodically recover expired VTXOs if (arkRefreshTimer) clearInterval(arkRefreshTimer); arkRefreshTimer = setInterval(() => { @@ -183,6 +191,7 @@ if (browser) { close(); clearTimeout(checkTimer); + if (arkSyncTimer) clearInterval(arkSyncTimer); if (arkRefreshTimer) clearInterval(arkRefreshTimer); } }); diff --git a/src/routes/(static)/login/+page.server.ts b/src/routes/(static)/login/+page.server.ts index eb0ad0bf..18f9cb20 100644 --- a/src/routes/(static)/login/+page.server.ts +++ b/src/routes/(static)/login/+page.server.ts @@ -39,6 +39,53 @@ export const actions = { redirect(307, loginRedirect || `/${user.username}`); }, + passkey: async ({ cookies, fetch, request }) => { + const form = await fd(request); + let { credential, challengeId, loginRedirect } = form; + if (loginRedirect === "null" || loginRedirect === "undefined") + loginRedirect = undefined; + credential = JSON.parse(credential); + + const maxAge = 380 * 24 * 60 * 60; + + const origin = new URL(request.url).origin; + const res = await fetch(`${PUBLIC_COINOS_URL}/passkey/login/verify`, { + method: "POST", + body: JSON.stringify({ credential, challengeId, origin }), + headers: { + "content-type": "application/json", + accept: "application/json", + "cf-connecting-ip": request.headers.get("cf-connecting-ip") ?? "", + "x-forwarded-host": request.headers.get("host") ?? "", + }, + }); + + const text = await res.text(); + if (!res.ok) { + let message; + try { + ({ message } = JSON.parse(text)); + } catch { + message = text; + } + return fail(400, { error: message || "Passkey login failed" }); + } + + const { user, token } = JSON.parse(text); + if (!token) return fail(400, { error: "Login failed" }); + const { username, language } = user; + + const expires = new Date(); + expires.setSeconds(expires.getSeconds() + maxAge); + + const opts = { path: "/", expires }; + if (language) cookies.set("lang", language, opts); + cookies.set("username", username, opts); + cookies.set("token", token, opts); + + redirect(307, loginRedirect || `/${username}`); + }, + nostr: async ({ cookies, fetch, request }) => { const form = await fd(request); let { challenge, event, loginRedirect, recaptcha } = form; diff --git a/src/routes/(static)/login/+page.svelte b/src/routes/(static)/login/+page.svelte index 22e49e65..74699c05 100644 --- a/src/routes/(static)/login/+page.svelte +++ b/src/routes/(static)/login/+page.svelte @@ -10,6 +10,7 @@ import PasswordInput from "$comp/PasswordInput.svelte"; import Spinner from "$comp/Spinner.svelte"; import { focus, fail } from "$lib/utils"; + import { loginWithPasskey } from "$lib/passkey"; import { password, signer, pin, loginRedirect } from "$lib/store"; import { t } from "$lib/translations"; import { page } from "$app/stores"; @@ -105,6 +106,40 @@ let redirect = $derived($loginRedirect || $page.url.searchParams.get("redirect")); + let passkeyLoading = $state(false); + + let passkeyLogin = async () => { + passkeyLoading = true; + try { + const { credential, challengeId } = await loginWithPasskey(); + + const formData = new FormData(); + formData.append("credential", JSON.stringify(credential)); + formData.append("challengeId", challengeId); + formData.append("loginRedirect", (redirect ?? "") as string); + + const response = await fetch("/login?/passkey", { + method: "POST", + body: formData, + }); + + const result = deserialize(await response.text()); + + if (result.type === "success") { + await invalidateAll(); + } + + applyAction(result); + } catch (e: any) { + if (e.name === "NotAllowedError") { + // user cancelled the browser prompt + } else { + fail(e.message || "Passkey login failed"); + } + } + passkeyLoading = false; + }; + let nostrLogin = async () => { let event = { kind: 27235, @@ -191,6 +226,15 @@ {$t("login.signIn")} </button> + <button type="button" class="btn" onclick={passkeyLogin} disabled={passkeyLoading}> + {#if passkeyLoading} + <Spinner /> + {:else} + <iconify-icon noobserver icon="ph:fingerprint-bold" width="24"></iconify-icon> + {/if} + <div class="my-auto">Sign in with Passkey</div> + </button> + <button type="button" class="btn" onclick={nostrLogin}> {#if $signer?.ready} <div class="shrink"> diff --git a/src/routes/(static)/register/+page.server.ts b/src/routes/(static)/register/+page.server.ts index 7fde82aa..43a0df80 100644 --- a/src/routes/(static)/register/+page.server.ts +++ b/src/routes/(static)/register/+page.server.ts @@ -16,11 +16,11 @@ export const actions = { const ip = request.headers.get("cf-connecting-ip"); const host = request.headers.get("host"); const form = await fd(request); - const { picture, username, password, challenge, recaptcha } = form; + const { picture, username, challenge, recaptcha } = form; let { loginRedirect } = form; if (loginRedirect === "undefined") loginRedirect = undefined; - const user = { picture, username, password, challenge, recaptcha }; + const user = { picture, username, challenge, recaptcha }; return register(user, ip, cookies, loginRedirect, host); }, }; diff --git a/src/routes/(static)/register/+page.svelte b/src/routes/(static)/register/+page.svelte index 843fb129..e74ddde6 100644 --- a/src/routes/(static)/register/+page.svelte +++ b/src/routes/(static)/register/+page.svelte @@ -12,13 +12,13 @@ import Pin from "$comp/Pin.svelte"; import Spinner from "$comp/Spinner.svelte"; - import PasswordInput from "$comp/PasswordInput.svelte"; import { focus, fail } from "$lib/utils"; + import { registerPasskey } from "$lib/passkey"; import { avatar, signer, password, pin, loginRedirect } from "$lib/store"; import { t } from "$lib/translations"; import { page } from "$app/stores"; - import { NumberDictionary, uniqueNamesGenerator, colors, animals } from "unique-names-generator"; + import { NumberDictionary, uniqueNamesGenerator, animals } from "unique-names-generator"; import { sign } from "$lib/nostr"; let { form, data }: any = $props(); @@ -48,15 +48,13 @@ let username: string | undefined = $state(); let index = $state(data.index); - let revealPassword = $state(false); + let needsPasskey = $state(false); let cleared; let clear = () => { if (!cleared) { cleared = true; username = ""; - $password = ""; - revealPassword = false; } }; @@ -69,14 +67,6 @@ length: 2, separator: "", }); - - $password = uniqueNamesGenerator({ - dictionaries: [colors, NumberDictionary.generate({ min: 100, max: 999 })], - length: 2, - separator: "", - }); - - revealPassword = true; }; afterNavigate(async () => { @@ -160,7 +150,16 @@ const result = deserialize(await response.text()); - if (result.type === "success") { + if (result.type === "redirect") { + try { + await registerPasskey(); + } catch (e: any) { + console.log("passkey creation cancelled or failed", e); + needsPasskey = true; + loading = false; + return; + } + await invalidateAll(); } @@ -168,6 +167,19 @@ loading = false; } + let retryPasskey = async () => { + loading = true; + try { + await registerPasskey(); + needsPasskey = false; + await invalidateAll(); + applyAction({ type: "redirect", status: 303, location: `/${username}` }); + } catch (e: any) { + fail("Passkey creation failed. Please try again."); + } + loading = false; + } + let avatarInput: HTMLInputElement = $state() as any; let decr = () => (index = index <= 0 ? 63 : index - 1); let incr = () => (index = index >= 63 ? 0 : index + 1); @@ -301,65 +313,72 @@ <div class="text-red-600 text-center" in:fly>{form?.message}{form?.error}</div> {/if} - <form class="space-y-5" onsubmit={handleSubmit} method="POST"> - <input - type="hidden" - name="loginRedirect" - value={$loginRedirect || $page.url.searchParams.get("redirect")} - /> - <input type="hidden" name="token" value={token} /> - <input type="hidden" name="challenge" value={challenge} /> - - <label for="username" class="input flex items-center justify-center gap-2 w-full"> + {#if needsPasskey} + <div class="space-y-5 text-center"> + <p>Account created! You need to set up a passkey to continue.</p> + <button class="btn btn-accent" onclick={retryPasskey} disabled={loading}> + {#if loading} + <Spinner /> + {:else} + Set up Passkey + {/if} + </button> + </div> + {:else} + <form class="space-y-5" onsubmit={handleSubmit} method="POST"> <input - class="clean" - use:focus - name="username" - type="text" - required - bind:value={username} - onfocus={clear} - autocapitalize="none" - placeholder={$t("login.username")} + type="hidden" + name="loginRedirect" + value={$loginRedirect || $page.url.searchParams.get("redirect")} /> - <button type="button" tabindex="-1" onclick={refresh} aria-label="Randomize" class="contents"> - <iconify-icon noobserver icon="ph:dice-three-bold" width="32"></iconify-icon> - </button> - </label> - - <PasswordInput - bind:value={$password} - placeholder={$t("login.password")} - bind:show={revealPassword} - /> + <input type="hidden" name="token" value={token} /> + <input type="hidden" name="challenge" value={challenge} /> - <button type="submit" class="btn btn-accent" disabled={loading} bind:this={btn}> - {#if loading} - <Spinner /> - {:else} - {$t("login.register")} - {/if} - </button> + <label for="username" class="input flex items-center justify-center gap-2 w-full"> + <input + class="clean" + use:focus + name="username" + type="text" + required + bind:value={username} + onfocus={clear} + autocapitalize="none" + placeholder={$t("login.username")} + /> + <button type="button" tabindex="-1" onclick={refresh} aria-label="Randomize" class="contents"> + <iconify-icon noobserver icon="ph:dice-three-bold" width="32"></iconify-icon> + </button> + </label> - <button type="button" class="btn" onclick={nostrLogin}> - {#if $signer?.ready} - <div class="shrink"> + <button type="submit" class="btn btn-accent" disabled={loading} bind:this={btn}> + {#if loading} <Spinner /> - </div> - {:else} - <img src="/images/nostr.png" class="w-8" alt="Nostr" /> - {/if} - <div class="my-auto">{$t("login.nostr")}</div> - </button> + {:else} + {$t("login.register")} + {/if} + </button> - <p class="text-secondary text-center font-medium"> - {$t("login.haveAccount")} - <a - href={"/login" + $page.url.search} - class="block md:inline text-secondary underline underline-offset-4 hover:opacity-80" - > - {$t("login.signIn")} - </a> - </p> - </form> + <button type="button" class="btn" onclick={nostrLogin}> + {#if $signer?.ready} + <div class="shrink"> + <Spinner /> + </div> + {:else} + <img src="/images/nostr.png" class="w-8" alt="Nostr" /> + {/if} + <div class="my-auto">{$t("login.nostr")}</div> + </button> + + <p class="text-secondary text-center font-medium"> + {$t("login.haveAccount")} + <a + href={"/login" + $page.url.search} + class="block md:inline text-secondary underline underline-offset-4 hover:opacity-80" + > + {$t("login.signIn")} + </a> + </p> + </form> + {/if} </div> diff --git a/src/routes/passkey/[...path]/+server.ts b/src/routes/passkey/[...path]/+server.ts new file mode 100644 index 00000000..0f94c605 --- /dev/null +++ b/src/routes/passkey/[...path]/+server.ts @@ -0,0 +1,18 @@ +import { PUBLIC_COINOS_URL as base } from "$env/static/public"; +import { auth } from "$lib/utils"; + +export async function POST({ cookies, fetch, params, request, url }) { + const { path } = params; + const token = cookies.get("token"); + const headers: Record<string, string> = { "Content-Type": "application/json" }; + if (token) Object.assign(headers, auth(cookies)); + + const body = await request.json(); + body.origin = url.origin; + + return fetch(`${base}/passkey/${path}`, { + method: "POST", + body: JSON.stringify(body), + headers, + }); +} |
