aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-02-16 11:51:25 -0800
committerAdam Soltys <[email protected]>2026-02-16 11:51:25 -0800
commite39bf4f977bba2952a7de4a307b14c5ec862a3a7 (patch)
tree1eac038796d376ad073eae6246bf43f15454bd66 /src
parentfe104c2364de35bcdb2a10ca469cd25c12b04289 (diff)
downloadcoinos-ui-e39bf4f977bba2952a7de4a307b14c5ec862a3a7.tar.gz
coinos-ui-e39bf4f977bba2952a7de4a307b14c5ec862a3a7.zip
passkey auth
Diffstat (limited to 'src')
-rw-r--r--src/components/Account.svelte2
-rw-r--r--src/lib/ark.ts47
-rw-r--r--src/lib/passkey.ts14
-rw-r--r--src/lib/utils.ts38
-rw-r--r--src/routes/(app)/+layout.svelte15
-rw-r--r--src/routes/(static)/login/+page.server.ts47
-rw-r--r--src/routes/(static)/login/+page.svelte44
-rw-r--r--src/routes/(static)/register/+page.server.ts4
-rw-r--r--src/routes/(static)/register/+page.svelte157
-rw-r--r--src/routes/passkey/[...path]/+server.ts18
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,
+ });
+}