my own indieAuth provider! indiko.dunkirk.sh/docs
indieauth oauth2-server
at main 257 lines 7.2 kB view raw
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 28import { Database } from "bun:sqlite"; 29import crypto from "node:crypto"; 30import * as path from "node:path"; 31 32// Load database 33const dbPath = path.join(import.meta.dir, "..", "data", "indiko.db"); 34const db = new Database(dbPath); 35 36const ORIGIN = process.env.ORIGIN || "http://localhost:3000"; 37 38interface User { 39 id: number; 40 username: string; 41 name: string; 42 email: string | null; 43 status: string; 44 is_admin: number; 45} 46 47interface Credential { 48 id: number; 49 name: string | null; 50 created_at: number; 51} 52 53function getUser(username: string): User | null { 54 return db 55 .query( 56 "SELECT id, username, name, email, status, is_admin FROM users WHERE username = ?", 57 ) 58 .get(username) as User | null; 59} 60 61function getCredentials(userId: number): Credential[] { 62 return db 63 .query("SELECT id, name, created_at FROM credentials WHERE user_id = ?") 64 .all(userId) as Credential[]; 65} 66 67function deleteCredentials(userId: number): number { 68 const result = db 69 .query("DELETE FROM credentials WHERE user_id = ?") 70 .run(userId); 71 return result.changes; 72} 73 74function deleteSessions(userId: number): number { 75 const result = db.query("DELETE FROM sessions WHERE user_id = ?").run(userId); 76 return result.changes; 77} 78 79function createResetInvite( 80 adminUserId: number, 81 targetUsername: string, 82): string { 83 const code = crypto.randomBytes(16).toString("base64url"); 84 const now = Math.floor(Date.now() / 1000); 85 const expiresAt = now + 86400; // 24 hours 86 87 // Check if there's a reset_username column, if not we'll use the note field 88 const hasResetColumn = db 89 .query( 90 "SELECT name FROM pragma_table_info('invites') WHERE name = 'reset_username'", 91 ) 92 .get(); 93 94 if (hasResetColumn) { 95 db.query( 96 "INSERT INTO invites (code, created_by, max_uses, current_uses, expires_at, note, reset_username) VALUES (?, ?, 1, 0, ?, ?, ?)", 97 ).run( 98 code, 99 adminUserId, 100 expiresAt, 101 `Passkey reset for ${targetUsername}`, 102 targetUsername, 103 ); 104 } else { 105 // Use a special note format to indicate this is a reset invite 106 // Format: PASSKEY_RESET:username 107 db.query( 108 "INSERT INTO invites (code, created_by, max_uses, current_uses, expires_at, note, message) VALUES (?, ?, 1, 0, ?, ?, ?)", 109 ).run( 110 code, 111 adminUserId, 112 expiresAt, 113 `PASSKEY_RESET:${targetUsername}`, 114 `Your passkey has been reset. Please register a new passkey to regain access to your account.`, 115 ); 116 } 117 118 return code; 119} 120 121function getAdminUser(): User | null { 122 return db 123 .query( 124 "SELECT id, username, name, email, status, is_admin FROM users WHERE is_admin = 1 LIMIT 1", 125 ) 126 .get() as User | null; 127} 128 129async function main() { 130 const args = process.argv.slice(2); 131 132 if (args.length === 0 || args.includes("--help") || args.includes("-h")) { 133 console.log(` 134Passkey Reset Script 135 136Usage: bun scripts/reset-passkey.ts <username> 137 138Options: 139 --help, -h Show this help message 140 --dry-run Show what would happen without making changes 141 --force Skip confirmation prompt 142 143Example: 144 bun scripts/reset-passkey.ts kieran 145 bun scripts/reset-passkey.ts kieran --dry-run 146`); 147 process.exit(0); 148 } 149 150 const username = args.find((arg) => !arg.startsWith("--")); 151 const dryRun = args.includes("--dry-run"); 152 const force = args.includes("--force"); 153 154 if (!username) { 155 console.error("❌ Error: Username is required"); 156 process.exit(1); 157 } 158 159 console.log(`\n🔐 Passkey Reset for: ${username}`); 160 console.log("─".repeat(50)); 161 162 // Look up user 163 const user = getUser(username); 164 if (!user) { 165 console.error(`\n❌ Error: User '${username}' not found`); 166 process.exit(1); 167 } 168 169 console.log(`\n📋 User Details:`); 170 console.log(` • ID: ${user.id}`); 171 console.log(` • Name: ${user.name}`); 172 console.log(` • Email: ${user.email || "(not set)"}`); 173 console.log(` • Status: ${user.status}`); 174 console.log(` • Admin: ${user.is_admin ? "Yes" : "No"}`); 175 176 // Get existing credentials 177 const credentials = getCredentials(user.id); 178 console.log(`\n🔑 Existing Passkeys: ${credentials.length}`); 179 credentials.forEach((cred, idx) => { 180 const date = new Date(cred.created_at * 1000).toISOString().split("T")[0]; 181 console.log(` ${idx + 1}. ${cred.name || "(unnamed)"} - created ${date}`); 182 }); 183 184 if (credentials.length === 0) { 185 console.log( 186 "\n⚠️ User has no passkeys registered. Creating reset link anyway...", 187 ); 188 } 189 190 if (dryRun) { 191 console.log("\n🔄 DRY RUN - No changes will be made"); 192 console.log("\nWould perform:"); 193 console.log(` • Delete ${credentials.length} passkey(s)`); 194 console.log(" • Invalidate all active sessions"); 195 console.log(" • Create single-use reset invite"); 196 process.exit(0); 197 } 198 199 // Confirmation prompt (unless --force) 200 if (!force) { 201 console.log("\n⚠️ This will:"); 202 console.log( 203 ` • Delete ALL ${credentials.length} passkey(s) for this user`, 204 ); 205 console.log(" • Log them out of all sessions"); 206 console.log(" • Generate a 24-hour reset link\n"); 207 208 process.stdout.write("Continue? [y/N] "); 209 210 for await (const line of console) { 211 const answer = line.trim().toLowerCase(); 212 if (answer !== "y" && answer !== "yes") { 213 console.log("Cancelled."); 214 process.exit(0); 215 } 216 break; 217 } 218 } 219 220 // Get admin user for creating invite 221 const admin = getAdminUser(); 222 if (!admin) { 223 console.error("\n❌ Error: No admin user found to create invite"); 224 process.exit(1); 225 } 226 227 // Perform reset 228 console.log("\n🔄 Performing reset..."); 229 230 // Delete credentials 231 const deletedCreds = deleteCredentials(user.id); 232 console.log(` ✅ Deleted ${deletedCreds} passkey(s)`); 233 234 // Delete sessions 235 const deletedSessions = deleteSessions(user.id); 236 console.log(` ✅ Invalidated ${deletedSessions} session(s)`); 237 238 // Create reset invite 239 const inviteCode = createResetInvite(admin.id, username); 240 console.log(" ✅ Created reset invite"); 241 242 // Generate reset URL 243 const resetUrl = `${ORIGIN}/login?invite=${inviteCode}&username=${encodeURIComponent(username)}`; 244 245 console.log("\n" + "═".repeat(50)); 246 console.log("✨ PASSKEY RESET COMPLETE"); 247 console.log("═".repeat(50)); 248 console.log(`\n📧 Send this link to ${user.name || username}:\n`); 249 console.log(` ${resetUrl}`); 250 console.log(`\n⏰ This link expires in 24 hours and can only be used once.`); 251 console.log(`\n💡 The user must register with username: ${username}`); 252} 253 254main().catch((error) => { 255 console.error("\n❌ Error:", error instanceof Error ? error.message : error); 256 process.exit(1); 257});