Add passkey and magic link authentication

This commit is contained in:
JB
2026-07-25 01:54:33 -04:00
parent 3c9f443499
commit c3244b0c79
31 changed files with 1017 additions and 58 deletions
+31
View File
@@ -18,6 +18,18 @@ await mkdir(dirname(databasePath), { recursive: true });
const database = await connect(databasePath);
const schema = await readFile(schemaPath, 'utf8');
const usersTable = await (await database.prepare(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='users'"
)).get();
if (usersTable) {
const userColumns = await (await database.prepare("PRAGMA table_info('users')")).all();
if (!userColumns.some((column) => column.name === 'login_username')) {
console.log('Separating optional local login usernames from internal account identifiers.');
await database.exec('ALTER TABLE users ADD COLUMN login_username TEXT');
await database.exec('UPDATE users SET login_username=username WHERE password_hash IS NOT NULL');
}
}
// The project schema deliberately avoids semicolons inside string literals.
const statements = schema
.split(';')
@@ -28,6 +40,25 @@ for (const statement of statements) {
await database.exec(statement);
}
const loginEventsDefinition = await (await database.prepare(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='login_events'"
)).get();
if (typeof loginEventsDefinition?.sql === 'string' && !loginEventsDefinition.sql.includes('magic_link')) {
console.log('Expanding login event providers for passwordless authentication.');
await database.exec('ALTER TABLE login_events RENAME TO login_events_legacy');
await database.exec(`CREATE TABLE login_events (
id TEXT PRIMARY KEY,
user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
provider TEXT NOT NULL CHECK(provider IN ('password', 'discord', 'magic_link', 'passkey')),
succeeded INTEGER NOT NULL CHECK(succeeded IN (0, 1)),
created_at TEXT NOT NULL
)`);
await database.exec(`INSERT INTO login_events(id,user_id,provider,succeeded,created_at)
SELECT id,user_id,provider,succeeded,created_at FROM login_events_legacy`);
await database.exec('DROP TABLE login_events_legacy');
await database.exec('CREATE INDEX IF NOT EXISTS login_events_user_created_idx ON login_events(user_id, created_at DESC)');
}
console.log(`Applied ${statements.length} schema statements to ${databasePath}`);
if (!databaseAlreadyExisted) {
+6 -5
View File
@@ -35,18 +35,18 @@ async function secureHash(password) {
export async function initializeDefaultUsers(database, options = {}) {
const resetPasswords = options.resetPasswords === true;
const findUser = await database.prepare(
'SELECT id, username, role FROM users WHERE username = ? COLLATE NOCASE'
'SELECT id, username, role FROM users WHERE login_username = ? COLLATE NOCASE OR username = ? COLLATE NOCASE'
);
const insertUser = await database.prepare(
'INSERT INTO users (id, username, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?)'
'INSERT INTO users (id, username, login_username, password_hash, role, created_at) VALUES (?, ?, ?, ?, ?, ?)'
);
const updateCredential = await database.prepare(
'UPDATE users SET password_hash = ?, role = ? WHERE id = ?'
'UPDATE users SET login_username = ?, password_hash = ?, role = ? WHERE id = ?'
);
const credentials = [];
for (const definition of defaultUsers) {
const existing = await findUser.get(definition.username);
const existing = await findUser.get(definition.username, definition.username);
if (existing && !resetPasswords) {
console.log(`${definition.username}: already exists (${existing.role}); left unchanged`);
continue;
@@ -60,12 +60,13 @@ export async function initializeDefaultUsers(database, options = {}) {
const passwordHash = await secureHash(password);
if (existing) {
await updateCredential.run(passwordHash, String(existing.role), existing.id);
await updateCredential.run(definition.username, passwordHash, String(existing.role), existing.id);
console.log(`${definition.username}: credentials reset; role and related data preserved`);
} else {
await insertUser.run(
randomUUID(),
definition.username,
definition.username,
passwordHash,
definition.role,
new Date().toISOString()