my own indieAuth provider! indiko.dunkirk.sh/docs
indieauth oauth2-server

feat: allow re-registration to reset passkey

dunkirk.sh d334bdd6 f9313c87

verified
+302 -33
+240
scripts/reset-passkey.ts
··· 1 + #!/usr/bin/env bun 2 + /** 3 + * Passkey Reset Script 4 + * 5 + * Resets a user's passkey credentials and generates a one-time reset link. 6 + * The user can use this link to register a new passkey while preserving 7 + * their existing account, permissions, and app authorizations. 8 + * 9 + * Usage: bun scripts/reset-passkey.ts <username> 10 + * 11 + * Example: 12 + * bun scripts/reset-passkey.ts kieran 13 + * 14 + * The script will: 15 + * 1. Verify the user exists 16 + * 2. Delete all their existing passkey credentials 17 + * 3. Invalidate all active sessions (logs them out) 18 + * 4. Create a single-use reset invite locked to their username 19 + * 5. Output a reset link 20 + * 21 + * IMPORTANT: This preserves: 22 + * - User account and profile data 23 + * - All app permissions and authorizations 24 + * - Role assignments 25 + * - Admin status 26 + */ 27 + 28 + import { Database } from "bun:sqlite"; 29 + import crypto from "node:crypto"; 30 + import * as path from "node:path"; 31 + 32 + // Load database 33 + const dbPath = path.join(import.meta.dir, "..", "data", "indiko.db"); 34 + const db = new Database(dbPath); 35 + 36 + const ORIGIN = process.env.ORIGIN || "http://localhost:3000"; 37 + 38 + interface User { 39 + id: number; 40 + username: string; 41 + name: string; 42 + email: string | null; 43 + status: string; 44 + is_admin: number; 45 + } 46 + 47 + interface Credential { 48 + id: number; 49 + name: string | null; 50 + created_at: number; 51 + } 52 + 53 + function getUser(username: string): User | null { 54 + return db 55 + .query("SELECT id, username, name, email, status, is_admin FROM users WHERE username = ?") 56 + .get(username) as User | null; 57 + } 58 + 59 + function getCredentials(userId: number): Credential[] { 60 + return db 61 + .query("SELECT id, name, created_at FROM credentials WHERE user_id = ?") 62 + .all(userId) as Credential[]; 63 + } 64 + 65 + function deleteCredentials(userId: number): number { 66 + const result = db 67 + .query("DELETE FROM credentials WHERE user_id = ?") 68 + .run(userId); 69 + return result.changes; 70 + } 71 + 72 + function deleteSessions(userId: number): number { 73 + const result = db 74 + .query("DELETE FROM sessions WHERE user_id = ?") 75 + .run(userId); 76 + return result.changes; 77 + } 78 + 79 + function createResetInvite(adminUserId: number, targetUsername: string): string { 80 + const code = crypto.randomBytes(16).toString("base64url"); 81 + const now = Math.floor(Date.now() / 1000); 82 + const expiresAt = now + 86400; // 24 hours 83 + 84 + // Check if there's a reset_username column, if not we'll use the note field 85 + const hasResetColumn = db 86 + .query("SELECT name FROM pragma_table_info('invites') WHERE name = 'reset_username'") 87 + .get(); 88 + 89 + if (hasResetColumn) { 90 + db.query( 91 + "INSERT INTO invites (code, created_by, max_uses, current_uses, expires_at, note, reset_username) VALUES (?, ?, 1, 0, ?, ?, ?)", 92 + ).run(code, adminUserId, expiresAt, `Passkey reset for ${targetUsername}`, targetUsername); 93 + } else { 94 + // Use a special note format to indicate this is a reset invite 95 + // Format: PASSKEY_RESET:username 96 + db.query( 97 + "INSERT INTO invites (code, created_by, max_uses, current_uses, expires_at, note, message) VALUES (?, ?, 1, 0, ?, ?, ?)", 98 + ).run( 99 + code, 100 + adminUserId, 101 + expiresAt, 102 + `PASSKEY_RESET:${targetUsername}`, 103 + `Your passkey has been reset. Please register a new passkey to regain access to your account.`, 104 + ); 105 + } 106 + 107 + return code; 108 + } 109 + 110 + function getAdminUser(): User | null { 111 + return db 112 + .query("SELECT id, username, name, email, status, is_admin FROM users WHERE is_admin = 1 LIMIT 1") 113 + .get() as User | null; 114 + } 115 + 116 + async function main() { 117 + const args = process.argv.slice(2); 118 + 119 + if (args.length === 0 || args.includes("--help") || args.includes("-h")) { 120 + console.log(` 121 + Passkey Reset Script 122 + 123 + Usage: bun scripts/reset-passkey.ts <username> 124 + 125 + Options: 126 + --help, -h Show this help message 127 + --dry-run Show what would happen without making changes 128 + --force Skip confirmation prompt 129 + 130 + Example: 131 + bun scripts/reset-passkey.ts kieran 132 + bun scripts/reset-passkey.ts kieran --dry-run 133 + `); 134 + process.exit(0); 135 + } 136 + 137 + const username = args.find((arg) => !arg.startsWith("--")); 138 + const dryRun = args.includes("--dry-run"); 139 + const force = args.includes("--force"); 140 + 141 + if (!username) { 142 + console.error("❌ Error: Username is required"); 143 + process.exit(1); 144 + } 145 + 146 + console.log(`\n🔐 Passkey Reset for: ${username}`); 147 + console.log("─".repeat(50)); 148 + 149 + // Look up user 150 + const user = getUser(username); 151 + if (!user) { 152 + console.error(`\n❌ Error: User '${username}' not found`); 153 + process.exit(1); 154 + } 155 + 156 + console.log(`\n📋 User Details:`); 157 + console.log(` • ID: ${user.id}`); 158 + console.log(` • Name: ${user.name}`); 159 + console.log(` • Email: ${user.email || "(not set)"}`); 160 + console.log(` • Status: ${user.status}`); 161 + console.log(` • Admin: ${user.is_admin ? "Yes" : "No"}`); 162 + 163 + // Get existing credentials 164 + const credentials = getCredentials(user.id); 165 + console.log(`\n🔑 Existing Passkeys: ${credentials.length}`); 166 + credentials.forEach((cred, idx) => { 167 + const date = new Date(cred.created_at * 1000).toISOString().split("T")[0]; 168 + console.log(` ${idx + 1}. ${cred.name || "(unnamed)"} - created ${date}`); 169 + }); 170 + 171 + if (credentials.length === 0) { 172 + console.log("\n⚠️ User has no passkeys registered. Creating reset link anyway..."); 173 + } 174 + 175 + if (dryRun) { 176 + console.log("\n🔄 DRY RUN - No changes will be made"); 177 + console.log("\nWould perform:"); 178 + console.log(` • Delete ${credentials.length} passkey(s)`); 179 + console.log(" • Invalidate all active sessions"); 180 + console.log(" • Create single-use reset invite"); 181 + process.exit(0); 182 + } 183 + 184 + // Confirmation prompt (unless --force) 185 + if (!force) { 186 + console.log("\n⚠️ This will:"); 187 + console.log(` • Delete ALL ${credentials.length} passkey(s) for this user`); 188 + console.log(" • Log them out of all sessions"); 189 + console.log(" • Generate a 24-hour reset link\n"); 190 + 191 + process.stdout.write("Continue? [y/N] "); 192 + 193 + for await (const line of console) { 194 + const answer = line.trim().toLowerCase(); 195 + if (answer !== "y" && answer !== "yes") { 196 + console.log("Cancelled."); 197 + process.exit(0); 198 + } 199 + break; 200 + } 201 + } 202 + 203 + // Get admin user for creating invite 204 + const admin = getAdminUser(); 205 + if (!admin) { 206 + console.error("\n❌ Error: No admin user found to create invite"); 207 + process.exit(1); 208 + } 209 + 210 + // Perform reset 211 + console.log("\n🔄 Performing reset..."); 212 + 213 + // Delete credentials 214 + const deletedCreds = deleteCredentials(user.id); 215 + console.log(` ✅ Deleted ${deletedCreds} passkey(s)`); 216 + 217 + // Delete sessions 218 + const deletedSessions = deleteSessions(user.id); 219 + console.log(` ✅ Invalidated ${deletedSessions} session(s)`); 220 + 221 + // Create reset invite 222 + const inviteCode = createResetInvite(admin.id, username); 223 + console.log(" ✅ Created reset invite"); 224 + 225 + // Generate reset URL 226 + const resetUrl = `${ORIGIN}/login?invite=${inviteCode}&username=${encodeURIComponent(username)}`; 227 + 228 + console.log("\n" + "═".repeat(50)); 229 + console.log("✨ PASSKEY RESET COMPLETE"); 230 + console.log("═".repeat(50)); 231 + console.log(`\n📧 Send this link to ${user.name || username}:\n`); 232 + console.log(` ${resetUrl}`); 233 + console.log(`\n⏰ This link expires in 24 hours and can only be used once.`); 234 + console.log(`\n💡 The user must register with username: ${username}`); 235 + } 236 + 237 + main().catch((error) => { 238 + console.error("\n❌ Error:", error instanceof Error ? error.message : error); 239 + process.exit(1); 240 + });
+62 -33
src/routes/auth.ts
··· 39 39 // Check if username already exists 40 40 const existingUser = db 41 41 .query("SELECT id FROM users WHERE username = ?") 42 - .get(username); 42 + .get(username) as { id: number } | undefined; 43 43 44 + // Allow re-registration if user exists but has no credentials (passkey reset case) 45 + let isPasskeyReset = false; 44 46 if (existingUser) { 45 - return Response.json( 46 - { error: "Username already taken" }, 47 - { status: 400 }, 48 - ); 47 + const credCount = db 48 + .query("SELECT COUNT(*) as count FROM credentials WHERE user_id = ?") 49 + .get(existingUser.id) as { count: number }; 50 + 51 + if (credCount.count > 0) { 52 + return Response.json( 53 + { error: "Username already taken" }, 54 + { status: 400 }, 55 + ); 56 + } 57 + // User exists but has no credentials - this is a passkey reset 58 + isPasskeyReset = true; 49 59 } 50 60 51 61 // Check if this is bootstrap (first user) ··· 156 166 157 167 // Check if username already exists 158 168 const existingUser = db 159 - .query("SELECT id FROM users WHERE username = ?") 160 - .get(username); 169 + .query("SELECT id, is_admin FROM users WHERE username = ?") 170 + .get(username) as { id: number; is_admin: number } | undefined; 161 171 172 + // Allow re-registration if user exists but has no credentials (passkey reset case) 173 + let isPasskeyReset = false; 162 174 if (existingUser) { 163 - return Response.json( 164 - { error: "Username already taken" }, 165 - { status: 400 }, 166 - ); 175 + const credCount = db 176 + .query("SELECT COUNT(*) as count FROM credentials WHERE user_id = ?") 177 + .get(existingUser.id) as { count: number }; 178 + 179 + if (credCount.count > 0) { 180 + return Response.json( 181 + { error: "Username already taken" }, 182 + { status: 400 }, 183 + ); 184 + } 185 + // User exists but has no credentials - this is a passkey reset 186 + isPasskeyReset = true; 167 187 } 168 188 169 189 if (!expectedChallenge) { ··· 275 295 invite?.ldap_username !== null && invite?.ldap_username !== undefined; 276 296 } 277 297 278 - // Create user (bootstrap is always admin, invited users are regular users) 279 - const insertUser = db.query( 280 - "INSERT INTO users (username, name, is_admin, tier, role, provisioned_via_ldap) VALUES (?, ?, ?, ?, ?, ?) RETURNING id", 281 - ); 282 - const user = insertUser.get( 283 - username, 284 - username, 285 - isBootstrap ? 1 : 0, 286 - isBootstrap ? "admin" : "user", 287 - isBootstrap ? "admin" : "user", 288 - isLdapProvisioned ? 1 : 0, 289 - ) as { 290 - id: number; 291 - }; 298 + let userId: number; 299 + let userIsAdmin: boolean; 300 + 301 + if (isPasskeyReset && existingUser) { 302 + // Passkey reset: use existing user, just add credential 303 + userId = existingUser.id; 304 + userIsAdmin = existingUser.is_admin === 1; 305 + } else { 306 + // Create new user (bootstrap is always admin, invited users are regular users) 307 + const insertUser = db.query( 308 + "INSERT INTO users (username, name, is_admin, tier, role, provisioned_via_ldap) VALUES (?, ?, ?, ?, ?, ?) RETURNING id", 309 + ); 310 + const user = insertUser.get( 311 + username, 312 + username, 313 + isBootstrap ? 1 : 0, 314 + isBootstrap ? "admin" : "user", 315 + isBootstrap ? "admin" : "user", 316 + isLdapProvisioned ? 1 : 0, 317 + ) as { id: number }; 318 + userId = user.id; 319 + userIsAdmin = isBootstrap; 320 + } 292 321 293 322 // Store credential 294 323 // credential.id is a Uint8Array, convert to Buffer for storage 295 324 db.query( 296 325 "INSERT INTO credentials (user_id, credential_id, public_key, counter, name) VALUES (?, ?, ?, ?, ?)", 297 326 ).run( 298 - user.id, 327 + userId, 299 328 Buffer.from(credential.id), 300 329 Buffer.from(credential.publicKey), 301 330 credential.counter, 302 - "Primary Passkey", 331 + isPasskeyReset ? "Reset Passkey" : "Primary Passkey", 303 332 ); 304 333 305 334 // Mark invite as used if applicable ··· 324 353 // Record this invite use 325 354 db.query( 326 355 "INSERT INTO invite_uses (invite_id, user_id, used_at) VALUES (?, ?, ?)", 327 - ).run(inviteId, user.id, usedAt); 356 + ).run(inviteId, userId, usedAt); 328 357 329 - // Assign app roles to the new user 330 - if (inviteRoles.length > 0) { 358 + // Assign app roles to the new user (skip for passkey reset - they already have roles) 359 + if (inviteRoles.length > 0 && !isPasskeyReset) { 331 360 const insertPermission = db.query( 332 361 "INSERT INTO permissions (user_id, app_id, role) VALUES (?, ?, ?)", 333 362 ); 334 363 for (const { app_id, role } of inviteRoles) { 335 - insertPermission.run(user.id, app_id, role); 364 + insertPermission.run(userId, app_id, role); 336 365 } 337 366 } 338 367 } ··· 347 376 const expiresAt = Math.floor(Date.now() / 1000) + 86400; // 24 hours 348 377 db.query( 349 378 "INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)", 350 - ).run(token, user.id, expiresAt); 379 + ).run(token, userId, expiresAt); 351 380 352 381 const isProduction = process.env.NODE_ENV === "production"; 353 382 const secureCookie = isProduction ? "; Secure" : ""; ··· 356 385 { 357 386 token, 358 387 username, 359 - isAdmin: isBootstrap, 388 + isAdmin: userIsAdmin, 360 389 }, 361 390 { 362 391 headers: {