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(
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});