🪻 distributed transcription service thistle.dunkirk.sh

feat: add passkey support

dunkirk.sh e8bd6888 1752bf80

verified
+17 -4
.env.example
···
# See README for setup instructions
WHISPER_SERVICE_URL=http://localhost:8000
-
# Gemini API Key (optional)
-
# For cleaning transcripts - removes tags and improves grammar
-
# Get your key from: https://aistudio.google.com/app/apikey
-
# GEMINI_API_KEY=your_api_key_here
+
# LLM API Configuration (Required for VTT cleaning)
+
# Configure your LLM service endpoint and credentials
+
LLM_API_KEY=your_api_key_here
+
LLM_API_BASE_URL=https://api.openai.com/v1
+
LLM_MODEL=gpt-4o-mini
+
+
# WebAuthn/Passkey Configuration (Production Only)
+
# In development, these default to localhost values
+
# Only needed when deploying to production
+
+
# Relying Party ID - your domain name
+
# Must match the domain where your app is hosted
+
# RP_ID=thistle.app
+
+
# Origin - full URL of your app
+
# Must match exactly where users access your app
+
# ORIGIN=https://thistle.app
+52
bun.lock
···
{
"lockfileVersion": 1,
+
"configVersion": 0,
"workspaces": {
"": {
"name": "inky",
"dependencies": {
+
"@simplewebauthn/browser": "^13.2.2",
+
"@simplewebauthn/server": "^13.2.2",
"eventsource-client": "^1.2.0",
"lit": "^3.3.1",
"ua-parser-js": "^2.0.6",
},
"devDependencies": {
"@biomejs/biome": "^2.3.2",
+
"@simplewebauthn/types": "^12.0.0",
"@types/bun": "latest",
},
"peerDependencies": {
···
"@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.2", "", { "os": "win32", "cpu": "x64" }, "sha512-6Ee9P26DTb4D8sN9nXxgbi9Dw5vSOfH98M7UlmkjKB2vtUbrRqCbZiNfryGiwnPIpd6YUoTl7rLVD2/x1CyEHQ=="],
+
"@hexagon/base64": ["@hexagon/base64@1.1.28", "", {}, "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="],
+
+
"@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="],
+
"@lit-labs/ssr-dom-shim": ["@lit-labs/ssr-dom-shim@1.4.0", "", {}, "sha512-ficsEARKnmmW5njugNYKipTm4SFnbik7CXtoencDZzmzo/dQ+2Q0bgkzJuoJP20Aj0F+izzJjOqsnkd6F/o1bw=="],
"@lit/reactive-element": ["@lit/reactive-element@2.1.1", "", { "dependencies": { "@lit-labs/ssr-dom-shim": "^1.4.0" } }, "sha512-N+dm5PAYdQ8e6UlywyyrgI2t++wFGXfHx+dSJ1oBrg6FAxUj40jId++EaRm80MKX5JnlH1sBsyZ5h0bcZKemCg=="],
+
"@peculiar/asn1-android": ["@peculiar/asn1-android@2.5.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.5.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-t8A83hgghWQkcneRsgGs2ebAlRe54ns88p7ouv8PW2tzF1nAW4yHcL4uZKrFpIU+uszIRzTkcCuie37gpkId0A=="],
+
+
"@peculiar/asn1-cms": ["@peculiar/asn1-cms@2.5.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.5.0", "@peculiar/asn1-x509": "^2.5.0", "@peculiar/asn1-x509-attr": "^2.5.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-p0SjJ3TuuleIvjPM4aYfvYw8Fk1Hn/zAVyPJZTtZ2eE9/MIer6/18ROxX6N/e6edVSfvuZBqhxAj3YgsmSjQ/A=="],
+
+
"@peculiar/asn1-csr": ["@peculiar/asn1-csr@2.5.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.5.0", "@peculiar/asn1-x509": "^2.5.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-ioigvA6WSYN9h/YssMmmoIwgl3RvZlAYx4A/9jD2qaqXZwGcNlAxaw54eSx2QG1Yu7YyBC5Rku3nNoHrQ16YsQ=="],
+
+
"@peculiar/asn1-ecc": ["@peculiar/asn1-ecc@2.5.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.5.0", "@peculiar/asn1-x509": "^2.5.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-t4eYGNhXtLRxaP50h3sfO6aJebUCDGQACoeexcelL4roMFRRVgB20yBIu2LxsPh/tdW9I282gNgMOyg3ywg/mg=="],
+
+
"@peculiar/asn1-pfx": ["@peculiar/asn1-pfx@2.5.0", "", { "dependencies": { "@peculiar/asn1-cms": "^2.5.0", "@peculiar/asn1-pkcs8": "^2.5.0", "@peculiar/asn1-rsa": "^2.5.0", "@peculiar/asn1-schema": "^2.5.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-Vj0d0wxJZA+Ztqfb7W+/iu8Uasw6hhKtCdLKXLG/P3kEPIQpqGI4P4YXlROfl7gOCqFIbgsj1HzFIFwQ5s20ug=="],
+
+
"@peculiar/asn1-pkcs8": ["@peculiar/asn1-pkcs8@2.5.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.5.0", "@peculiar/asn1-x509": "^2.5.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-L7599HTI2SLlitlpEP8oAPaJgYssByI4eCwQq2C9eC90otFpm8MRn66PpbKviweAlhinWQ3ZjDD2KIVtx7PaVw=="],
+
+
"@peculiar/asn1-pkcs9": ["@peculiar/asn1-pkcs9@2.5.0", "", { "dependencies": { "@peculiar/asn1-cms": "^2.5.0", "@peculiar/asn1-pfx": "^2.5.0", "@peculiar/asn1-pkcs8": "^2.5.0", "@peculiar/asn1-schema": "^2.5.0", "@peculiar/asn1-x509": "^2.5.0", "@peculiar/asn1-x509-attr": "^2.5.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-UgqSMBLNLR5TzEZ5ZzxR45Nk6VJrammxd60WMSkofyNzd3DQLSNycGWSK5Xg3UTYbXcDFyG8pA/7/y/ztVCa6A=="],
+
+
"@peculiar/asn1-rsa": ["@peculiar/asn1-rsa@2.5.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.5.0", "@peculiar/asn1-x509": "^2.5.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-qMZ/vweiTHy9syrkkqWFvbT3eLoedvamcUdnnvwyyUNv5FgFXA3KP8td+ATibnlZ0EANW5PYRm8E6MJzEB/72Q=="],
+
+
"@peculiar/asn1-schema": ["@peculiar/asn1-schema@2.5.0", "", { "dependencies": { "asn1js": "^3.0.6", "pvtsutils": "^1.3.6", "tslib": "^2.8.1" } }, "sha512-YM/nFfskFJSlHqv59ed6dZlLZqtZQwjRVJ4bBAiWV08Oc+1rSd5lDZcBEx0lGDHfSoH3UziI2pXt2UM33KerPQ=="],
+
+
"@peculiar/asn1-x509": ["@peculiar/asn1-x509@2.5.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.5.0", "asn1js": "^3.0.6", "pvtsutils": "^1.3.6", "tslib": "^2.8.1" } }, "sha512-CpwtMCTJvfvYTFMuiME5IH+8qmDe3yEWzKHe7OOADbGfq7ohxeLaXwQo0q4du3qs0AII3UbLCvb9NF/6q0oTKQ=="],
+
+
"@peculiar/asn1-x509-attr": ["@peculiar/asn1-x509-attr@2.5.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.5.0", "@peculiar/asn1-x509": "^2.5.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-9f0hPOxiJDoG/bfNLAFven+Bd4gwz/VzrCIIWc1025LEI4BXO0U5fOCTNDPbbp2ll+UzqKsZ3g61mpBp74gk9A=="],
+
+
"@peculiar/x509": ["@peculiar/x509@1.14.0", "", { "dependencies": { "@peculiar/asn1-cms": "^2.5.0", "@peculiar/asn1-csr": "^2.5.0", "@peculiar/asn1-ecc": "^2.5.0", "@peculiar/asn1-pkcs9": "^2.5.0", "@peculiar/asn1-rsa": "^2.5.0", "@peculiar/asn1-schema": "^2.5.0", "@peculiar/asn1-x509": "^2.5.0", "pvtsutils": "^1.3.6", "reflect-metadata": "^0.2.2", "tslib": "^2.8.1", "tsyringe": "^4.10.0" } }, "sha512-Yc4PDxN3OrxUPiXgU63c+ZRXKGE8YKF2McTciYhUHFtHVB0KMnjeFSU0qpztGhsp4P0uKix4+J2xEpIEDu8oXg=="],
+
+
"@simplewebauthn/browser": ["@simplewebauthn/browser@13.2.2", "", {}, "sha512-FNW1oLQpTJyqG5kkDg5ZsotvWgmBaC6jCHR7Ej0qUNep36Wl9tj2eZu7J5rP+uhXgHaLk+QQ3lqcw2vS5MX1IA=="],
+
+
"@simplewebauthn/server": ["@simplewebauthn/server@13.2.2", "", { "dependencies": { "@hexagon/base64": "^1.1.27", "@levischuck/tiny-cbor": "^0.2.2", "@peculiar/asn1-android": "^2.3.10", "@peculiar/asn1-ecc": "^2.3.8", "@peculiar/asn1-rsa": "^2.3.8", "@peculiar/asn1-schema": "^2.3.8", "@peculiar/asn1-x509": "^2.3.8", "@peculiar/x509": "^1.13.0" } }, "sha512-HcWLW28yTMGXpwE9VLx9J+N2KEUaELadLrkPEEI9tpI5la70xNEVEsu/C+m3u7uoq4FulLqZQhgBCzR9IZhFpA=="],
+
+
"@simplewebauthn/types": ["@simplewebauthn/types@12.0.0", "", {}, "sha512-q6y8MkoV8V8jB4zzp18Uyj2I7oFp2/ONL8c3j8uT06AOWu3cIChc1au71QYHrP2b+xDapkGTiv+9lX7xkTlAsA=="],
+
"@types/bun": ["@types/bun@1.3.1", "", { "dependencies": { "bun-types": "1.3.1" } }, "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ=="],
"@types/node": ["@types/node@24.9.2", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA=="],
···
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
+
"asn1js": ["asn1js@3.0.6", "", { "dependencies": { "pvtsutils": "^1.3.6", "pvutils": "^1.1.3", "tslib": "^2.8.1" } }, "sha512-UOCGPYbl0tv8+006qks/dTgV9ajs97X2p0FAbyS2iyCRrmLSRolDaHdp+v/CLgnzHc3fVB+CwYiUmei7ndFcgA=="],
+
"bun-types": ["bun-types@1.3.1", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-NMrcy7smratanWJ2mMXdpatalovtxVggkj11bScuWuiOoXTiKIu2eVS1/7qbyI/4yHedtsn175n4Sm4JcdHLXw=="],
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
···
"lit-html": ["lit-html@3.3.1", "", { "dependencies": { "@types/trusted-types": "^2.0.2" } }, "sha512-S9hbyDu/vs1qNrithiNyeyv64c9yqiW9l+DBgI18fL+MTvOtWoFR0FWiyq1TxaYef5wNlpEmzlXoBlZEO+WjoA=="],
+
"pvtsutils": ["pvtsutils@1.3.6", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg=="],
+
+
"pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="],
+
+
"reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="],
+
+
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
+
"tsyringe": ["tsyringe@4.10.0", "", { "dependencies": { "tslib": "^1.9.3" } }, "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw=="],
+
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"ua-is-frozen": ["ua-is-frozen@0.1.2", "", {}, "sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw=="],
···
"ua-parser-js": ["ua-parser-js@2.0.6", "", { "dependencies": { "detect-europe-js": "^0.1.2", "is-standalone-pwa": "^0.1.1", "ua-is-frozen": "^0.1.2" }, "bin": { "ua-parser-js": "script/cli.js" } }, "sha512-EmaxXfltJaDW75SokrY4/lXMrVyXomE/0FpIIqP2Ctic93gK7rlme55Cwkz8l3YZ6gqf94fCU7AnIkidd/KXPg=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
+
+
"tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="],
}
}
+3
package.json
···
},
"devDependencies": {
"@biomejs/biome": "^2.3.2",
+
"@simplewebauthn/types": "^12.0.0",
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
},
"dependencies": {
+
"@simplewebauthn/browser": "^13.2.2",
+
"@simplewebauthn/server": "^13.2.2",
"eventsource-client": "^1.2.0",
"lit": "^3.3.1",
"ua-parser-js": "^2.0.6"
+80
src/components/auth.ts
···
import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators.js";
import { hashPasswordClient } from "../lib/client-auth";
+
import {
+
authenticateWithPasskey,
+
isPasskeySupported,
+
} from "../lib/client-passkey";
import type { PasswordStrength } from "./password-strength";
import "./password-strength";
import type { PasswordStrengthResult } from "./password-strength";
···
@state() isSubmitting = false;
@state() needsRegistration = false;
@state() passwordStrength: PasswordStrengthResult | null = null;
+
@state() passkeySupported = false;
static override styles = css`
:host {
···
font-size: 0.875rem;
margin: 0;
}
+
+
.divider {
+
display: flex;
+
align-items: center;
+
text-align: center;
+
margin: 1.5rem 0;
+
color: var(--secondary);
+
font-size: 0.875rem;
+
}
+
+
.divider::before,
+
.divider::after {
+
content: "";
+
flex: 1;
+
border-bottom: 1px solid var(--secondary);
+
}
+
+
.divider::before {
+
margin-right: 0.5rem;
+
}
+
+
.divider::after {
+
margin-left: 0.5rem;
+
}
+
+
.btn-passkey {
+
background: transparent;
+
color: var(--primary);
+
border-color: var(--primary);
+
width: 100%;
+
margin-bottom: 0;
+
}
+
+
.btn-passkey:hover:not(:disabled) {
+
background: var(--primary);
+
color: white;
+
}
`;
override async connectedCallback() {
super.connectedCallback();
+
this.passkeySupported = isPasskeySupported();
await this.checkAuth();
}
···
this.passwordStrength = e.detail;
}
+
private async handlePasskeyLogin() {
+
this.error = "";
+
this.isSubmitting = true;
+
+
try {
+
const result = await authenticateWithPasskey(this.email || undefined);
+
+
if (!result.success) {
+
this.error = result.error || "Passkey authentication failed";
+
return;
+
}
+
+
// Success - reload to get user info
+
await this.checkAuth();
+
this.closeModal();
+
window.dispatchEvent(new CustomEvent("auth-changed"));
+
} finally {
+
this.isSubmitting = false;
+
}
+
}
+
override render() {
if (this.loading) {
return html`<div class="loading">Loading...</div>`;
···
`
: ""
}
+
+
${
+
!this.needsRegistration && this.passkeySupported
+
? html`
+
<button
+
type="button"
+
class="btn-passkey"
+
@click=${this.handlePasskeyLogin}
+
?disabled=${this.isSubmitting}
+
>
+
🔑 ${this.isSubmitting ? "Loading..." : "Sign in with Passkey"}
+
</button>
+
<div class="divider">or sign in with password</div>
+
`
+
: ""
+
}
<form @submit=${this.handleSubmit}>
<div class="form-group">
+151 -2
src/components/user-settings.ts
···
import { customElement, state } from "lit/decorators.js";
import { UAParser } from "ua-parser-js";
import { hashPasswordClient } from "../lib/client-auth";
+
import {
+
isPasskeySupported,
+
registerPasskey,
+
} from "../lib/client-passkey";
interface User {
email: string;
···
is_current: boolean;
}
-
type SettingsPage = "account" | "sessions" | "danger";
+
interface Passkey {
+
id: string;
+
name: string | null;
+
created_at: number;
+
last_used_at: number | null;
+
}
+
+
type SettingsPage = "account" | "sessions" | "passkeys" | "danger";
@customElement("user-settings")
export class UserSettings extends LitElement {
@state() user: User | null = null;
@state() sessions: Session[] = [];
+
@state() passkeys: Passkey[] = [];
@state() loading = true;
@state() loadingSessions = true;
+
@state() loadingPasskeys = true;
@state() error = "";
@state() showDeleteConfirm = false;
@state() currentPage: SettingsPage = "account";
···
@state() newPassword = "";
@state() newName = "";
@state() newAvatar = "";
+
@state() passkeySupported = false;
+
@state() addingPasskey = false;
static override styles = css`
:host {
···
position: relative;
}
-
+
.field-description {
+
font-size: 0.875rem;
+
color: var(--secondary);
+
margin: 0.5rem 0;
+
}
.danger-section {
border-color: var(--accent);
···
override async connectedCallback() {
super.connectedCallback();
+
this.passkeySupported = isPasskeySupported();
await this.loadUser();
await this.loadSessions();
+
if (this.passkeySupported) {
+
await this.loadPasskeys();
+
}
}
async loadUser() {
···
}
}
+
async loadPasskeys() {
+
try {
+
const response = await fetch("/api/passkeys");
+
+
if (response.ok) {
+
const data = await response.json();
+
this.passkeys = data.passkeys;
+
}
+
} finally {
+
this.loadingPasskeys = false;
+
}
+
}
+
+
async handleAddPasskey() {
+
this.addingPasskey = true;
+
this.error = "";
+
+
try {
+
const name = prompt("Name this passkey (optional):");
+
if (name === null) {
+
// User cancelled
+
return;
+
}
+
+
const result = await registerPasskey(name || undefined);
+
+
if (!result.success) {
+
this.error = result.error || "Failed to register passkey";
+
return;
+
}
+
+
// Reload passkeys
+
await this.loadPasskeys();
+
} finally {
+
this.addingPasskey = false;
+
}
+
}
+
+
async handleDeletePasskey(passkeyId: string) {
+
if (!confirm("Are you sure you want to delete this passkey?")) {
+
return;
+
}
+
+
try {
+
const response = await fetch(`/api/passkeys/${passkeyId}`, {
+
method: "DELETE",
+
});
+
+
if (!response.ok) {
+
const error = await response.json();
+
this.error = error.error || "Failed to delete passkey";
+
return;
+
}
+
+
// Reload passkeys
+
await this.loadPasskeys();
+
} catch {
+
this.error = "Failed to delete passkey";
+
}
+
}
+
async handleLogout() {
try {
await fetch("/api/auth/logout", { method: "POST" });
···
`
}
</div>
+
+
${
+
this.passkeySupported
+
? html`
+
<div class="field-group">
+
<label class="field-label">Passkeys</label>
+
<p class="field-description">
+
Passkeys provide a more secure and convenient way to sign in without passwords.
+
They use biometric authentication or your device's security features.
+
</p>
+
${
+
this.loadingPasskeys
+
? html`<div class="field-value">Loading passkeys...</div>`
+
: this.passkeys.length === 0
+
? html`<div class="field-value" style="color: var(--secondary);">No passkeys registered yet</div>`
+
: html`
+
<div style="display: flex; flex-direction: column; gap: 1rem; margin-top: 1rem;">
+
${this.passkeys.map(
+
(passkey) => html`
+
<div class="session-card">
+
<div class="session-details">
+
<div class="session-row">
+
<span class="session-label">Name</span>
+
<span class="session-value">${passkey.name || "Unnamed passkey"}</span>
+
</div>
+
<div class="session-row">
+
<span class="session-label">Created</span>
+
<span class="session-value">${new Date(passkey.created_at * 1000).toLocaleDateString()}</span>
+
</div>
+
${
+
passkey.last_used_at
+
? html`
+
<div class="session-row">
+
<span class="session-label">Last used</span>
+
<span class="session-value">${new Date(passkey.last_used_at * 1000).toLocaleDateString()}</span>
+
</div>
+
`
+
: ""
+
}
+
</div>
+
<button
+
class="btn btn-rejection btn-small"
+
@click=${() => this.handleDeletePasskey(passkey.id)}
+
style="margin-top: 0.75rem;"
+
>
+
Delete
+
</button>
+
</div>
+
`,
+
)}
+
</div>
+
`
+
}
+
<button
+
class="btn btn-affirmative"
+
style="margin-top: 1rem;"
+
@click=${this.handleAddPasskey}
+
?disabled=${this.addingPasskey}
+
>
+
${this.addingPasskey ? "Adding..." : "Add Passkey"}
+
</button>
+
</div>
+
`
+
: ""
+
}
<div class="field-group">
<label class="field-label">Member Since</label>
+38
src/db/schema.ts
···
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
`,
},
+
{
+
version: 7,
+
name: "Add WebAuthn passkey support",
+
sql: `
+
CREATE TABLE IF NOT EXISTS passkeys (
+
id TEXT PRIMARY KEY,
+
user_id INTEGER NOT NULL,
+
credential_id TEXT NOT NULL UNIQUE,
+
public_key TEXT NOT NULL,
+
counter INTEGER NOT NULL DEFAULT 0,
+
transports TEXT,
+
name TEXT,
+
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
+
last_used_at INTEGER,
+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+
);
+
+
CREATE INDEX IF NOT EXISTS idx_passkeys_user_id ON passkeys(user_id);
+
CREATE INDEX IF NOT EXISTS idx_passkeys_credential_id ON passkeys(credential_id);
+
+
-- Make password optional for users who only use passkeys
+
CREATE TABLE users_new (
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
+
email TEXT UNIQUE NOT NULL,
+
password_hash TEXT,
+
name TEXT,
+
avatar TEXT DEFAULT 'd',
+
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')),
+
role TEXT NOT NULL DEFAULT 'user'
+
);
+
+
INSERT INTO users_new SELECT * FROM users;
+
DROP TABLE users;
+
ALTER TABLE users_new RENAME TO users;
+
+
CREATE INDEX IF NOT EXISTS idx_users_role ON users(role);
+
`,
+
},
];
function getCurrentVersion(): number {
+144
src/index.ts
···
updateUserRole,
type UserRole,
} from "./lib/auth";
+
import {
+
createAuthenticationOptions,
+
createRegistrationOptions,
+
deletePasskey,
+
getPasskeysForUser,
+
updatePasskeyName,
+
verifyAndAuthenticatePasskey,
+
verifyAndCreatePasskey,
+
} from "./lib/passkey";
import { handleError, ValidationErrors } from "./lib/errors";
import { requireAdmin, requireAuth } from "./lib/middleware";
import { enforceRateLimit } from "./lib/rate-limit";
···
created_at: user.created_at,
role: user.role,
});
+
},
+
},
+
"/api/passkeys/register/options": {
+
POST: async (req) => {
+
try {
+
const user = requireAuth(req);
+
const options = await createRegistrationOptions(user);
+
return Response.json(options);
+
} catch (err) {
+
return handleError(err);
+
}
+
},
+
},
+
"/api/passkeys/register/verify": {
+
POST: async (req) => {
+
try {
+
const user = requireAuth(req);
+
const body = await req.json();
+
const { response: credentialResponse, challenge, name } = body;
+
+
const passkey = await verifyAndCreatePasskey(
+
credentialResponse,
+
challenge,
+
name,
+
);
+
+
return Response.json({
+
success: true,
+
passkey: {
+
id: passkey.id,
+
name: passkey.name,
+
created_at: passkey.created_at,
+
},
+
});
+
} catch (err) {
+
return handleError(err);
+
}
+
},
+
},
+
"/api/passkeys/authenticate/options": {
+
POST: async (req) => {
+
try {
+
const body = await req.json();
+
const { email } = body;
+
+
const options = await createAuthenticationOptions(email);
+
return Response.json(options);
+
} catch (err) {
+
return handleError(err);
+
}
+
},
+
},
+
"/api/passkeys/authenticate/verify": {
+
POST: async (req) => {
+
try {
+
const body = await req.json();
+
const { response: credentialResponse, challenge } = body;
+
+
const { user } = await verifyAndAuthenticatePasskey(
+
credentialResponse,
+
challenge,
+
);
+
+
// Create session
+
const ipAddress =
+
req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
+
req.headers.get("x-real-ip") ||
+
"unknown";
+
const userAgent = req.headers.get("user-agent") || "unknown";
+
const sessionId = createSession(user.id, ipAddress, userAgent);
+
+
return Response.json(
+
{
+
email: user.email,
+
name: user.name,
+
avatar: user.avatar,
+
created_at: user.created_at,
+
role: user.role,
+
},
+
{
+
headers: {
+
"Set-Cookie": `session=${sessionId}; HttpOnly; Secure; Path=/; Max-Age=${7 * 24 * 60 * 60}; SameSite=Lax`,
+
},
+
},
+
);
+
} catch (err) {
+
return handleError(err);
+
}
+
},
+
},
+
"/api/passkeys": {
+
GET: async (req) => {
+
try {
+
const user = requireAuth(req);
+
const passkeys = getPasskeysForUser(user.id);
+
return Response.json({
+
passkeys: passkeys.map((p) => ({
+
id: p.id,
+
name: p.name,
+
created_at: p.created_at,
+
last_used_at: p.last_used_at,
+
})),
+
});
+
} catch (err) {
+
return handleError(err);
+
}
+
},
+
},
+
"/api/passkeys/:id": {
+
PUT: async (req) => {
+
try {
+
const user = requireAuth(req);
+
const body = await req.json();
+
const { name } = body;
+
const passkeyId = req.params.id;
+
+
if (!name) {
+
return Response.json({ error: "Name required" }, { status: 400 });
+
}
+
+
updatePasskeyName(passkeyId, user.id, name);
+
return Response.json({ success: true });
+
} catch (err) {
+
return handleError(err);
+
}
+
},
+
DELETE: async (req) => {
+
try {
+
const user = requireAuth(req);
+
const passkeyId = req.params.id;
+
deletePasskey(passkeyId, user.id);
+
return Response.json({ success: true });
+
} catch (err) {
+
return handleError(err);
+
}
},
},
"/api/sessions": {
+153
src/lib/client-passkey.ts
···
+
import {
+
startAuthentication,
+
startRegistration,
+
} from "@simplewebauthn/browser";
+
import type {
+
PublicKeyCredentialCreationOptionsJSON,
+
PublicKeyCredentialRequestOptionsJSON,
+
} from "@simplewebauthn/types";
+
+
/**
+
* Register a new passkey for the current user
+
*/
+
export async function registerPasskey(
+
name?: string,
+
): Promise<{ success: boolean; error?: string }> {
+
try {
+
// Get registration options from server
+
const optionsResponse = await fetch("/api/passkeys/register/options", {
+
method: "POST",
+
});
+
+
if (!optionsResponse.ok) {
+
const error = await optionsResponse.json();
+
return {
+
success: false,
+
error: error.error || "Failed to get registration options",
+
};
+
}
+
+
const options: PublicKeyCredentialCreationOptionsJSON =
+
await optionsResponse.json();
+
+
// Start browser passkey creation
+
let credential: Awaited<ReturnType<typeof startRegistration>>;
+
try {
+
credential = await startRegistration({ optionsJSON: options });
+
} catch (err) {
+
// User cancelled or browser doesn't support passkeys
+
return {
+
success: false,
+
error:
+
err instanceof Error
+
? err.message
+
: "Passkey registration was cancelled",
+
};
+
}
+
+
// Verify with server
+
const verifyResponse = await fetch("/api/passkeys/register/verify", {
+
method: "POST",
+
headers: { "Content-Type": "application/json" },
+
body: JSON.stringify({
+
response: credential,
+
challenge: options.challenge,
+
name,
+
}),
+
});
+
+
if (!verifyResponse.ok) {
+
const error = await verifyResponse.json();
+
return {
+
success: false,
+
error: error.error || "Failed to verify passkey",
+
};
+
}
+
+
return { success: true };
+
} catch (err) {
+
return {
+
success: false,
+
error:
+
err instanceof Error ? err.message : "Failed to register passkey",
+
};
+
}
+
}
+
+
/**
+
* Authenticate with a passkey
+
*/
+
export async function authenticateWithPasskey(
+
email?: string,
+
): Promise<{ success: boolean; error?: string }> {
+
try {
+
// Get authentication options from server
+
const optionsResponse = await fetch("/api/passkeys/authenticate/options", {
+
method: "POST",
+
headers: { "Content-Type": "application/json" },
+
body: JSON.stringify({ email }),
+
});
+
+
if (!optionsResponse.ok) {
+
const error = await optionsResponse.json();
+
return {
+
success: false,
+
error: error.error || "Failed to get authentication options",
+
};
+
}
+
+
const options: PublicKeyCredentialRequestOptionsJSON =
+
await optionsResponse.json();
+
+
// Start browser passkey authentication
+
let credential: Awaited<ReturnType<typeof startAuthentication>>;
+
try {
+
credential = await startAuthentication({ optionsJSON: options });
+
} catch (err) {
+
// User cancelled or no passkey available
+
return {
+
success: false,
+
error:
+
err instanceof Error
+
? err.message
+
: "Passkey authentication was cancelled",
+
};
+
}
+
+
// Verify with server
+
const verifyResponse = await fetch("/api/passkeys/authenticate/verify", {
+
method: "POST",
+
headers: { "Content-Type": "application/json" },
+
body: JSON.stringify({
+
response: credential,
+
challenge: options.challenge,
+
}),
+
});
+
+
if (!verifyResponse.ok) {
+
const error = await verifyResponse.json();
+
return {
+
success: false,
+
error: error.error || "Failed to verify passkey",
+
};
+
}
+
+
return { success: true };
+
} catch (err) {
+
return {
+
success: false,
+
error:
+
err instanceof Error ? err.message : "Failed to authenticate with passkey",
+
};
+
}
+
}
+
+
/**
+
* Check if passkeys are supported in this browser
+
*/
+
export function isPasskeySupported(): boolean {
+
return (
+
window.PublicKeyCredential !== undefined &&
+
typeof window.PublicKeyCredential === "function"
+
);
+
}
+356
src/lib/passkey.ts
···
+
import {
+
generateAuthenticationOptions,
+
generateRegistrationOptions,
+
verifyAuthenticationResponse,
+
verifyRegistrationResponse,
+
type VerifiedAuthenticationResponse,
+
type VerifiedRegistrationResponse,
+
} from "@simplewebauthn/server";
+
import type {
+
AuthenticationResponseJSON,
+
RegistrationResponseJSON,
+
} from "@simplewebauthn/types";
+
import db from "../db/schema";
+
import type { User } from "./auth";
+
+
export interface Passkey {
+
id: string;
+
user_id: number;
+
credential_id: string;
+
public_key: string;
+
counter: number;
+
transports: string | null;
+
name: string | null;
+
created_at: number;
+
last_used_at: number | null;
+
}
+
+
export interface RegistrationChallenge {
+
challenge: string;
+
user_id: number;
+
expires_at: number;
+
}
+
+
export interface AuthenticationChallenge {
+
challenge: string;
+
expires_at: number;
+
}
+
+
// In-memory challenge storage
+
const registrationChallenges = new Map<string, RegistrationChallenge>();
+
const authenticationChallenges = new Map<string, AuthenticationChallenge>();
+
+
// Challenge TTL: 5 minutes
+
const CHALLENGE_TTL = 5 * 60 * 1000;
+
+
// Cleanup expired challenges every minute
+
setInterval(() => {
+
const now = Date.now();
+
for (const [challenge, data] of registrationChallenges.entries()) {
+
if (data.expires_at < now) {
+
registrationChallenges.delete(challenge);
+
}
+
}
+
for (const [challenge, data] of authenticationChallenges.entries()) {
+
if (data.expires_at < now) {
+
authenticationChallenges.delete(challenge);
+
}
+
}
+
}, 60 * 1000);
+
+
/**
+
* Get RP ID and origin based on environment
+
*/
+
function getRPConfig(): { rpID: string; rpName: string; origin: string } {
+
if (process.env.NODE_ENV === "production") {
+
return {
+
rpID: process.env.RP_ID || "thistle.app",
+
rpName: "Thistle",
+
origin: process.env.ORIGIN || "https://thistle.app",
+
};
+
}
+
return {
+
rpID: "localhost",
+
rpName: "Thistle (Dev)",
+
origin: "http://localhost:3000",
+
};
+
}
+
+
/**
+
* Generate registration options for a user
+
*/
+
export async function createRegistrationOptions(user: User) {
+
const { rpID, rpName } = getRPConfig();
+
+
// Get existing credentials to exclude
+
const existingCredentials = getPasskeysForUser(user.id);
+
+
const options = await generateRegistrationOptions({
+
rpName,
+
rpID,
+
userName: user.email,
+
userDisplayName: user.name || user.email,
+
attestationType: "none",
+
excludeCredentials: existingCredentials.map((cred) => ({
+
id: cred.credential_id,
+
transports: cred.transports?.split(",") as
+
| ("usb" | "nfc" | "ble" | "internal" | "hybrid")[]
+
| undefined,
+
})),
+
authenticatorSelection: {
+
residentKey: "preferred",
+
userVerification: "preferred",
+
},
+
});
+
+
// Store challenge
+
registrationChallenges.set(options.challenge, {
+
challenge: options.challenge,
+
user_id: user.id,
+
expires_at: Date.now() + CHALLENGE_TTL,
+
});
+
+
return options;
+
}
+
+
/**
+
* Verify registration response and create passkey
+
*/
+
export async function verifyAndCreatePasskey(
+
response: RegistrationResponseJSON,
+
expectedChallenge: string,
+
name?: string,
+
): Promise<Passkey> {
+
// Validate challenge exists
+
const challengeData = registrationChallenges.get(expectedChallenge);
+
if (!challengeData) {
+
throw new Error("Invalid or expired challenge");
+
}
+
+
if (challengeData.expires_at < Date.now()) {
+
registrationChallenges.delete(expectedChallenge);
+
throw new Error("Challenge expired");
+
}
+
+
const { origin, rpID } = getRPConfig();
+
+
// Verify the registration
+
let verification: VerifiedRegistrationResponse;
+
try {
+
verification = await verifyRegistrationResponse({
+
response,
+
expectedChallenge,
+
expectedOrigin: origin,
+
expectedRPID: rpID,
+
});
+
} catch (error) {
+
throw new Error(
+
`Registration verification failed: ${error instanceof Error ? error.message : "Unknown error"}`,
+
);
+
}
+
+
if (!verification.verified || !verification.registrationInfo) {
+
throw new Error("Registration verification failed");
+
}
+
+
// Remove used challenge
+
registrationChallenges.delete(expectedChallenge);
+
+
const { credential } = verification.registrationInfo;
+
+
// Create passkey
+
// credential.id is a base64url string in SimpleWebAuthn v13
+
// credential.publicKey is a Uint8Array that needs conversion
+
const passkeyId = crypto.randomUUID();
+
const credentialIdBase64 = credential.id;
+
const publicKeyBase64 = Buffer.from(credential.publicKey).toString("base64url");
+
const transports = response.response.transports?.join(",") || null;
+
+
db.run(
+
`INSERT INTO passkeys (id, user_id, credential_id, public_key, counter, transports, name)
+
VALUES (?, ?, ?, ?, ?, ?, ?)`,
+
[
+
passkeyId,
+
challengeData.user_id,
+
credentialIdBase64,
+
publicKeyBase64,
+
credential.counter,
+
transports,
+
name || null,
+
],
+
);
+
+
const passkey = db
+
.query<Passkey, [string]>(
+
`SELECT id, user_id, credential_id, public_key, counter, transports, name, created_at, last_used_at
+
FROM passkeys WHERE id = ?`,
+
)
+
.get(passkeyId);
+
+
if (!passkey) {
+
throw new Error("Failed to create passkey");
+
}
+
+
return passkey;
+
}
+
+
/**
+
* Generate authentication options
+
*/
+
export async function createAuthenticationOptions(email?: string) {
+
const { rpID } = getRPConfig();
+
+
let allowCredentials: Array<{
+
id: string;
+
transports?: ("usb" | "nfc" | "ble" | "internal" | "hybrid")[];
+
}> = [];
+
+
// If email provided, only allow that user's credentials
+
if (email) {
+
const user = db
+
.query<{ id: number }, [string]>("SELECT id FROM users WHERE email = ?")
+
.get(email);
+
+
if (user) {
+
const credentials = getPasskeysForUser(user.id);
+
allowCredentials = credentials.map((cred) => ({
+
id: cred.credential_id,
+
transports: cred.transports?.split(",") as
+
| ("usb" | "nfc" | "ble" | "internal" | "hybrid")[]
+
| undefined,
+
}));
+
}
+
}
+
+
const options = await generateAuthenticationOptions({
+
rpID,
+
allowCredentials: allowCredentials.length > 0 ? allowCredentials : undefined,
+
userVerification: "preferred",
+
});
+
+
// Store challenge
+
authenticationChallenges.set(options.challenge, {
+
challenge: options.challenge,
+
expires_at: Date.now() + CHALLENGE_TTL,
+
});
+
+
return options;
+
}
+
+
/**
+
* Verify authentication response
+
*/
+
export async function verifyAndAuthenticatePasskey(
+
response: AuthenticationResponseJSON,
+
expectedChallenge: string,
+
): Promise<{ passkey: Passkey; user: User }> {
+
// Validate challenge
+
const challengeData = authenticationChallenges.get(expectedChallenge);
+
if (!challengeData) {
+
throw new Error("Invalid or expired challenge");
+
}
+
+
if (challengeData.expires_at < Date.now()) {
+
authenticationChallenges.delete(expectedChallenge);
+
throw new Error("Challenge expired");
+
}
+
+
// Get passkey by credential ID
+
// response.id is already base64url encoded string from SimpleWebAuthn
+
const passkey = db
+
.query<Passkey, [string]>(
+
`SELECT id, user_id, credential_id, public_key, counter, transports, name, created_at, last_used_at
+
FROM passkeys WHERE credential_id = ?`,
+
)
+
.get(response.id);
+
+
if (!passkey) {
+
throw new Error("Passkey not found");
+
}
+
+
const { origin, rpID } = getRPConfig();
+
+
// Verify the authentication
+
let verification: VerifiedAuthenticationResponse;
+
try {
+
verification = await verifyAuthenticationResponse({
+
response,
+
expectedChallenge,
+
expectedOrigin: origin,
+
expectedRPID: rpID,
+
credential: {
+
id: passkey.credential_id,
+
publicKey: Buffer.from(passkey.public_key, "base64url"),
+
counter: passkey.counter,
+
},
+
});
+
} catch (error) {
+
throw new Error(
+
`Authentication verification failed: ${error instanceof Error ? error.message : "Unknown error"}`,
+
);
+
}
+
+
if (!verification.verified) {
+
throw new Error("Authentication verification failed");
+
}
+
+
// Remove used challenge
+
authenticationChallenges.delete(expectedChallenge);
+
+
// Update last used timestamp and counter
+
const now = Math.floor(Date.now() / 1000);
+
db.run(
+
"UPDATE passkeys SET last_used_at = ?, counter = ? WHERE id = ?",
+
[now, verification.authenticationInfo.newCounter, passkey.id],
+
);
+
+
// Get user
+
const user = db
+
.query<User, [number]>(
+
"SELECT id, email, name, avatar, created_at, role FROM users WHERE id = ?",
+
)
+
.get(passkey.user_id);
+
+
if (!user) {
+
throw new Error("User not found");
+
}
+
+
return { passkey, user };
+
}
+
+
/**
+
* Get all passkeys for a user
+
*/
+
export function getPasskeysForUser(userId: number): Passkey[] {
+
return db
+
.query<Passkey, [number]>(
+
`SELECT id, user_id, credential_id, public_key, counter, transports, name, created_at, last_used_at
+
FROM passkeys WHERE user_id = ? ORDER BY created_at DESC`,
+
)
+
.all(userId);
+
}
+
+
/**
+
* Delete a passkey
+
*/
+
export function deletePasskey(passkeyId: string, userId: number): void {
+
db.run("DELETE FROM passkeys WHERE id = ? AND user_id = ?", [
+
passkeyId,
+
userId,
+
]);
+
}
+
+
/**
+
* Update passkey name
+
*/
+
export function updatePasskeyName(
+
passkeyId: string,
+
userId: number,
+
name: string,
+
): void {
+
db.run("UPDATE passkeys SET name = ? WHERE id = ? AND user_id = ?", [
+
name,
+
passkeyId,
+
userId,
+
]);
+
}
+7 -7
src/pages/admin.html
···
<link rel="stylesheet" href="../styles/main.css">
<style>
main {
-
max-width: 80rem;
+
max-width: 80rem !important;
margin: 0 auto;
padding: 2rem;
}
···
btn.addEventListener('click', async (e) => {
const button = e.target;
const id = button.dataset.id;
-
+
if (!confirm('Are you sure you want to delete this transcription? This cannot be undone.')) {
return;
}
···
try {
const res = await fetch(`/api/admin/users/${userId}/role`, {
method: 'PUT',
-
headers: { 'Content-Type': 'application/json' },
-
body: JSON.stringify({ role: newRole })
+
headers: {'Content-Type': 'application/json'},
+
body: JSON.stringify({role: newRole})
});
if (!res.ok) {
···
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
const tabName = tab.dataset.tab;
-
+
document.querySelectorAll('.tab').forEach(t => {
t.classList.remove('active');
});
document.querySelectorAll('.tab-content').forEach(c => {
c.classList.remove('active');
});
-
+
tab.classList.add('active');
document.getElementById(`${tabName}-tab`).classList.add('active');
});
···
</script>
</body>
-
</html>
+
</html>