my own indieAuth provider!
indiko.dunkirk.sh/docs
indieauth
oauth2-server
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("SELECT id, username, name, email, status, is_admin FROM users WHERE username = ?")
56 .get(username) as User | null;
57}
58
59function 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
65function 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
72function 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
79function 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
110function 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
116async function main() {
117 const args = process.argv.slice(2);
118
119 if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
120 console.log(`
121Passkey Reset Script
122
123Usage: bun scripts/reset-passkey.ts <username>
124
125Options:
126 --help, -h Show this help message
127 --dry-run Show what would happen without making changes
128 --force Skip confirmation prompt
129
130Example:
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
237main().catch((error) => {
238 console.error("\n❌ Error:", error instanceof Error ? error.message : error);
239 process.exit(1);
240});