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