···11+-- Add name column to credentials table for multiple passkey support
22+ALTER TABLE credentials ADD COLUMN name TEXT;
33+44+-- Update existing credentials with a default name
55+UPDATE credentials SET name = 'Passkey ' || id WHERE name IS NULL;
+2-1
src/routes/auth.ts
···270270 // Store credential
271271 // credential.id is a Uint8Array, convert to Buffer for storage
272272 db.query(
273273- "INSERT INTO credentials (user_id, credential_id, public_key, counter) VALUES (?, ?, ?, ?)",
273273+ "INSERT INTO credentials (user_id, credential_id, public_key, counter, name) VALUES (?, ?, ?, ?, ?)",
274274 ).run(
275275 user.id,
276276 Buffer.from(credential.id),
277277 Buffer.from(credential.publicKey),
278278 credential.counter,
279279+ "Primary Passkey",
279280 );
280281281282 // Mark invite as used if applicable
+327
src/routes/passkeys.ts
···11+import {
22+ type RegistrationResponseJSON,
33+ generateRegistrationOptions,
44+ type VerifiedRegistrationResponse,
55+ verifyRegistrationResponse,
66+} from "@simplewebauthn/server";
77+import { db } from "../db";
88+99+const RP_NAME = "Indiko";
1010+1111+// Get all passkeys for current user
1212+export function listPasskeys(req: Request): Response {
1313+ const sessionToken =
1414+ req.headers.get("Authorization")?.replace("Bearer ", "") ||
1515+ req.headers.get("Cookie")?.match(/indiko_session=([^;]+)/)?.[1];
1616+1717+ if (!sessionToken) {
1818+ return Response.json({ error: "Unauthorized" }, { status: 401 });
1919+ }
2020+2121+ const session = db
2222+ .query(
2323+ "SELECT user_id, expires_at FROM sessions WHERE token = ? AND expires_at > strftime('%s', 'now')",
2424+ )
2525+ .get(sessionToken) as { user_id: number; expires_at: number } | undefined;
2626+2727+ if (!session) {
2828+ return Response.json({ error: "Invalid session" }, { status: 401 });
2929+ }
3030+3131+ const passkeys = db
3232+ .query(
3333+ "SELECT id, name, created_at FROM credentials WHERE user_id = ? ORDER BY created_at DESC",
3434+ )
3535+ .all(session.user_id) as Array<{
3636+ id: number;
3737+ name: string;
3838+ created_at: number;
3939+ }>;
4040+4141+ return Response.json({ passkeys });
4242+}
4343+4444+// Generate options for adding a new passkey
4545+export async function addPasskeyOptions(req: Request): Promise<Response> {
4646+ const sessionToken =
4747+ req.headers.get("Authorization")?.replace("Bearer ", "") ||
4848+ req.headers.get("Cookie")?.match(/indiko_session=([^;]+)/)?.[1];
4949+5050+ if (!sessionToken) {
5151+ return Response.json({ error: "Unauthorized" }, { status: 401 });
5252+ }
5353+5454+ const session = db
5555+ .query(
5656+ "SELECT user_id, expires_at FROM sessions WHERE token = ? AND expires_at > strftime('%s', 'now')",
5757+ )
5858+ .get(sessionToken) as { user_id: number; expires_at: number } | undefined;
5959+6060+ if (!session) {
6161+ return Response.json({ error: "Invalid session" }, { status: 401 });
6262+ }
6363+6464+ const user = db
6565+ .query("SELECT username FROM users WHERE id = ?")
6666+ .get(session.user_id) as { username: string } | undefined;
6767+6868+ if (!user) {
6969+ return Response.json({ error: "User not found" }, { status: 404 });
7070+ }
7171+7272+ // Get existing credentials to exclude them
7373+ const existingCredentials = db
7474+ .query("SELECT credential_id FROM credentials WHERE user_id = ?")
7575+ .all(session.user_id) as Array<{ credential_id: Buffer }>;
7676+7777+ const excludeCredentials = existingCredentials.map((cred) => ({
7878+ id: cred.credential_id,
7979+ type: "public-key" as const,
8080+ }));
8181+8282+ // Generate WebAuthn registration options
8383+ const options = await generateRegistrationOptions({
8484+ rpName: RP_NAME,
8585+ rpID: process.env.RP_ID!,
8686+ userName: user.username,
8787+ userDisplayName: user.username,
8888+ attestationType: "none",
8989+ excludeCredentials,
9090+ authenticatorSelection: {
9191+ residentKey: "required",
9292+ userVerification: "required",
9393+ authenticatorAttachment: "platform",
9494+ },
9595+ });
9696+9797+ // Store challenge
9898+ const expiresAt = Math.floor(Date.now() / 1000) + 300; // 5 minutes
9999+ db.query(
100100+ "INSERT INTO challenges (challenge, username, type, expires_at) VALUES (?, ?, 'passkey_add', ?)",
101101+ ).run(options.challenge, user.username, expiresAt);
102102+103103+ return Response.json(options);
104104+}
105105+106106+// Verify and add new passkey
107107+export async function addPasskeyVerify(req: Request): Promise<Response> {
108108+ try {
109109+ const sessionToken =
110110+ req.headers.get("Authorization")?.replace("Bearer ", "") ||
111111+ req.headers.get("Cookie")?.match(/indiko_session=([^;]+)/)?.[1];
112112+113113+ if (!sessionToken) {
114114+ return Response.json({ error: "Unauthorized" }, { status: 401 });
115115+ }
116116+117117+ const session = db
118118+ .query(
119119+ "SELECT user_id, expires_at FROM sessions WHERE token = ? AND expires_at > strftime('%s', 'now')",
120120+ )
121121+ .get(sessionToken) as { user_id: number; expires_at: number } | undefined;
122122+123123+ if (!session) {
124124+ return Response.json({ error: "Invalid session" }, { status: 401 });
125125+ }
126126+127127+ const user = db
128128+ .query("SELECT username FROM users WHERE id = ?")
129129+ .get(session.user_id) as { username: string } | undefined;
130130+131131+ if (!user) {
132132+ return Response.json({ error: "User not found" }, { status: 404 });
133133+ }
134134+135135+ const body = await req.json();
136136+ const { response, challenge: expectedChallenge, name } = body as {
137137+ response: RegistrationResponseJSON;
138138+ challenge: string;
139139+ name?: string;
140140+ };
141141+142142+ if (!response) {
143143+ return Response.json({ error: "Response required" }, { status: 400 });
144144+ }
145145+146146+ // Verify challenge exists and is valid
147147+ const challenge = db
148148+ .query(
149149+ "SELECT challenge, expires_at FROM challenges WHERE challenge = ? AND username = ? AND type = 'passkey_add'",
150150+ )
151151+ .get(expectedChallenge, user.username) as
152152+ | { challenge: string; expires_at: number }
153153+ | undefined;
154154+155155+ if (!challenge) {
156156+ return Response.json({ error: "Invalid challenge" }, { status: 400 });
157157+ }
158158+159159+ const now = Math.floor(Date.now() / 1000);
160160+ if (challenge.expires_at < now) {
161161+ return Response.json({ error: "Challenge expired" }, { status: 400 });
162162+ }
163163+164164+ // Verify WebAuthn response
165165+ let verification: VerifiedRegistrationResponse;
166166+ try {
167167+ verification = await verifyRegistrationResponse({
168168+ response,
169169+ expectedChallenge: challenge.challenge,
170170+ expectedOrigin: process.env.ORIGIN!,
171171+ expectedRPID: process.env.RP_ID!,
172172+ });
173173+ } catch (error) {
174174+ console.error("WebAuthn verification failed:", error);
175175+ return Response.json({ error: "Verification failed" }, { status: 400 });
176176+ }
177177+178178+ if (!verification.verified || !verification.registrationInfo) {
179179+ return Response.json({ error: "Verification failed" }, { status: 400 });
180180+ }
181181+182182+ const { credential } = verification.registrationInfo;
183183+184184+ // Generate default name if not provided
185185+ const passkeyCount = db
186186+ .query("SELECT COUNT(*) as count FROM credentials WHERE user_id = ?")
187187+ .get(session.user_id) as { count: number };
188188+189189+ const passkeyName = name || `Passkey ${passkeyCount.count + 1}`;
190190+191191+ // Store credential
192192+ const result = db
193193+ .query(
194194+ "INSERT INTO credentials (user_id, credential_id, public_key, counter, name) VALUES (?, ?, ?, ?, ?) RETURNING id",
195195+ )
196196+ .get(
197197+ session.user_id,
198198+ Buffer.from(credential.id),
199199+ Buffer.from(credential.publicKey),
200200+ credential.counter,
201201+ passkeyName,
202202+ ) as { id: number };
203203+204204+ // Delete challenge
205205+ db.query("DELETE FROM challenges WHERE challenge = ?").run(
206206+ challenge.challenge,
207207+ );
208208+209209+ return Response.json({
210210+ success: true,
211211+ passkey: {
212212+ id: result.id,
213213+ name: passkeyName,
214214+ created_at: Math.floor(Date.now() / 1000),
215215+ },
216216+ });
217217+ } catch (error) {
218218+ console.error("Add passkey verify error:", error);
219219+ return Response.json({ error: "Internal server error" }, { status: 500 });
220220+ }
221221+}
222222+223223+// Delete a passkey
224224+export function deletePasskey(req: Request): Response {
225225+ const sessionToken =
226226+ req.headers.get("Authorization")?.replace("Bearer ", "") ||
227227+ req.headers.get("Cookie")?.match(/indiko_session=([^;]+)/)?.[1];
228228+229229+ if (!sessionToken) {
230230+ return Response.json({ error: "Unauthorized" }, { status: 401 });
231231+ }
232232+233233+ const session = db
234234+ .query(
235235+ "SELECT user_id, expires_at FROM sessions WHERE token = ? AND expires_at > strftime('%s', 'now')",
236236+ )
237237+ .get(sessionToken) as { user_id: number; expires_at: number } | undefined;
238238+239239+ if (!session) {
240240+ return Response.json({ error: "Invalid session" }, { status: 401 });
241241+ }
242242+243243+ const url = new URL(req.url);
244244+ const passkeyId = url.pathname.split("/").pop();
245245+246246+ if (!passkeyId) {
247247+ return Response.json({ error: "Passkey ID required" }, { status: 400 });
248248+ }
249249+250250+ // Check if this is the user's passkey
251251+ const passkey = db
252252+ .query("SELECT user_id FROM credentials WHERE id = ?")
253253+ .get(Number(passkeyId)) as { user_id: number } | undefined;
254254+255255+ if (!passkey || passkey.user_id !== session.user_id) {
256256+ return Response.json({ error: "Passkey not found" }, { status: 404 });
257257+ }
258258+259259+ // Check if this is the last passkey
260260+ const passkeyCount = db
261261+ .query("SELECT COUNT(*) as count FROM credentials WHERE user_id = ?")
262262+ .get(session.user_id) as { count: number };
263263+264264+ if (passkeyCount.count <= 1) {
265265+ return Response.json(
266266+ { error: "Cannot delete last passkey" },
267267+ { status: 400 },
268268+ );
269269+ }
270270+271271+ // Delete the passkey
272272+ db.query("DELETE FROM credentials WHERE id = ?").run(Number(passkeyId));
273273+274274+ return Response.json({ success: true });
275275+}
276276+277277+// Rename a passkey
278278+export async function renamePasskey(req: Request): Promise<Response> {
279279+ const sessionToken =
280280+ req.headers.get("Authorization")?.replace("Bearer ", "") ||
281281+ req.headers.get("Cookie")?.match(/indiko_session=([^;]+)/)?.[1];
282282+283283+ if (!sessionToken) {
284284+ return Response.json({ error: "Unauthorized" }, { status: 401 });
285285+ }
286286+287287+ const session = db
288288+ .query(
289289+ "SELECT user_id, expires_at FROM sessions WHERE token = ? AND expires_at > strftime('%s', 'now')",
290290+ )
291291+ .get(sessionToken) as { user_id: number; expires_at: number } | undefined;
292292+293293+ if (!session) {
294294+ return Response.json({ error: "Invalid session" }, { status: 401 });
295295+ }
296296+297297+ const url = new URL(req.url);
298298+ const passkeyId = url.pathname.split("/").pop();
299299+300300+ if (!passkeyId) {
301301+ return Response.json({ error: "Passkey ID required" }, { status: 400 });
302302+ }
303303+304304+ const body = await req.json();
305305+ const { name } = body as { name?: string };
306306+307307+ if (!name || typeof name !== "string" || name.trim().length === 0) {
308308+ return Response.json({ error: "Name required" }, { status: 400 });
309309+ }
310310+311311+ // Check if this is the user's passkey
312312+ const passkey = db
313313+ .query("SELECT user_id FROM credentials WHERE id = ?")
314314+ .get(Number(passkeyId)) as { user_id: number } | undefined;
315315+316316+ if (!passkey || passkey.user_id !== session.user_id) {
317317+ return Response.json({ error: "Passkey not found" }, { status: 404 });
318318+ }
319319+320320+ // Update the name
321321+ db.query("UPDATE credentials SET name = ? WHERE id = ?").run(
322322+ name.trim(),
323323+ Number(passkeyId),
324324+ );
325325+326326+ return Response.json({ success: true, name: name.trim() });
327327+}