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
+7 -3
View File
@@ -9,7 +9,7 @@ export async function softDeleteAccount(
reason: string | null
): Promise<boolean> {
const user = await (await database.prepare(`
SELECT users.username,user_profiles.display_name,deleted_accounts.deleted_at
SELECT users.username,users.login_username,user_profiles.display_name,deleted_accounts.deleted_at
FROM users LEFT JOIN user_profiles ON user_profiles.user_id=users.id
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
WHERE users.id=?
@@ -20,15 +20,19 @@ export async function softDeleteAccount(
await (await database.prepare(
'INSERT INTO deleted_accounts(user_id,original_username,deleted_at,deleted_by,reason) VALUES(?,?,?,?,?)'
)).run(userId, String(user.username), now, deletedBy, reason);
)).run(userId, String(user.login_username ?? user.username), now, deletedBy, reason);
await (await database.prepare(
`UPDATE users SET username=?,password_hash=NULL,discord_id=NULL,role='user' WHERE id=?`
`UPDATE users SET username=?,login_username=NULL,password_hash=NULL,discord_id=NULL,role='user' WHERE id=?`
)).run(deletedUsername, userId);
await (await database.prepare(
`INSERT INTO user_profiles(user_id,display_name,updated_at) VALUES(?,?,?)
ON CONFLICT(user_id) DO UPDATE SET display_name=excluded.display_name,updated_at=excluded.updated_at`
)).run(userId, 'Deleted Agent', now);
await (await database.prepare('DELETE FROM sessions WHERE user_id=?')).run(userId);
await (await database.prepare('DELETE FROM magic_link_tokens WHERE user_id=?')).run(userId);
await (await database.prepare('DELETE FROM webauthn_challenges WHERE user_id=?')).run(userId);
await (await database.prepare('DELETE FROM passkeys WHERE user_id=?')).run(userId);
await (await database.prepare('DELETE FROM user_emails WHERE user_id=?')).run(userId);
await (await database.prepare('DELETE FROM oauth_states WHERE user_id=?')).run(userId);
await (await database.prepare('DELETE FROM discord_profiles WHERE user_id=?')).run(userId);
await (await database.prepare('DELETE FROM user_roles WHERE user_id=?')).run(userId);
+29
View File
@@ -0,0 +1,29 @@
import type { Database } from '@tursodatabase/database';
import { createHash, randomBytes, randomUUID } from 'node:crypto';
const MAGIC_LINK_MINUTES = 15;
function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
export async function createMagicLinkToken(database: Database, userId: string, email: string, purpose: 'login' | 'verify_email'): Promise<string> {
const token = randomBytes(32).toString('base64url');
const now = new Date();
await (await database.prepare('DELETE FROM magic_link_tokens WHERE user_id=? AND purpose=?')).run(userId, purpose);
await (await database.prepare(`INSERT INTO magic_link_tokens(id,user_id,email,purpose,token_hash,expires_at,created_at)
VALUES(?,?,?,?,?,?,?)`)).run(
randomUUID(), userId, email, purpose, hashToken(token),
new Date(now.getTime() + MAGIC_LINK_MINUTES * 60_000).toISOString(), now.toISOString()
);
return token;
}
export async function consumeMagicLinkToken(database: Database, token: string) {
const now = new Date().toISOString();
const row = await (await database.prepare(`UPDATE magic_link_tokens SET consumed_at=?
WHERE token_hash=? AND consumed_at IS NULL AND expires_at>?
RETURNING id,user_id,email,purpose`)).get(now, hashToken(token), now);
if (!row) return null;
return { id: String(row.id), userId: String(row.user_id), email: String(row.email), purpose: String(row.purpose) as 'login' | 'verify_email' };
}
+43
View File
@@ -0,0 +1,43 @@
import { env } from '$env/dynamic/private';
import nodemailer from 'nodemailer';
let transporter: ReturnType<typeof nodemailer.createTransport> | undefined;
function mailer() {
if (!env.SMTP_HOST || !env.SMTP_FROM) {
throw new Error('Email delivery is not configured.');
}
if (!transporter) {
transporter = nodemailer.createTransport({
host: env.SMTP_HOST,
port: Number(env.SMTP_PORT || 587),
secure: env.SMTP_SECURE === 'true',
auth: env.SMTP_USER ? { user: env.SMTP_USER, pass: env.SMTP_PASSWORD } : undefined,
requireTLS: env.SMTP_SECURE !== 'true',
disableFileAccess: true,
disableUrlAccess: true
});
}
return transporter;
}
export function applicationOrigin(): string {
return (env.APP_ORIGIN || 'http://localhost:5173').replace(/\/+$/, '');
}
export function emailDeliveryConfigured(): boolean {
return Boolean(env.SMTP_HOST && env.SMTP_FROM);
}
export async function sendAuthenticationLink(email: string, token: string, purpose: 'login' | 'verify_email'): Promise<void> {
const url = `${applicationOrigin()}/auth/magic-link?token=${encodeURIComponent(token)}`;
const subject = purpose === 'login' ? 'Sign in to SHD.build' : 'Verify your SHD.build email';
const action = purpose === 'login' ? 'SIGN IN TO SHD.BUILD' : 'VERIFY EMAIL ADDRESS';
await mailer().sendMail({
from: env.SMTP_FROM,
to: email,
subject,
text: `${subject}\n\nOpen this one-time link within 15 minutes:\n${url}\n\nIf you did not request this, you can ignore this message.`,
html: `<div style="background:#0a0c0d;color:#ecebe5;padding:28px;font-family:Arial,sans-serif"><p style="color:#f6a821;font-size:12px;letter-spacing:2px">SHD IDENTITY NETWORK</p><h1 style="font-size:24px">${subject}</h1><p style="color:#a5aaab">This one-time link expires in 15 minutes.</p><p><a href="${url}" style="display:inline-block;background:#f6a821;color:#111;padding:14px 20px;text-decoration:none;font-weight:800">${action}</a></p><p style="color:#737a7c;font-size:12px">If you did not request this, you can ignore this message.</p></div>`
});
}
+26
View File
@@ -0,0 +1,26 @@
import type { Database } from '@tursodatabase/database';
export type AuthenticationMethods = {
local: boolean;
discord: boolean;
email: boolean;
passkeys: number;
total: number;
};
export async function getAuthenticationMethods(database: Database, userId: string): Promise<AuthenticationMethods> {
const row = await (await database.prepare(`SELECT users.login_username,users.password_hash,users.discord_id,
(SELECT COUNT(*) FROM user_emails WHERE user_id=users.id) AS email_count,
(SELECT COUNT(*) FROM passkeys WHERE user_id=users.id) AS passkey_count
FROM users WHERE id=?`)).get(userId);
const local = typeof row?.login_username === 'string' && typeof row?.password_hash === 'string';
const discord = typeof row?.discord_id === 'string';
const email = Number(row?.email_count) > 0;
const passkeys = Number(row?.passkey_count ?? 0);
return { local, discord, email, passkeys, total: Number(local) + Number(discord) + Number(email) + passkeys };
}
export function canRemoveAuthenticationMethod(methods: AuthenticationMethods, method: 'local' | 'discord' | 'email' | 'passkey'): boolean {
const removed = method === 'passkey' ? 1 : Number(methods[method]);
return removed > 0 && methods.total - removed >= 1;
}
+31
View File
@@ -0,0 +1,31 @@
import { env } from '$env/dynamic/private';
import type { Database } from '@tursodatabase/database';
import { randomUUID } from 'node:crypto';
export const PASSKEY_RP_NAME = 'SHD.build';
export function passkeyContext(url: URL): { rpID: string; origin: string } {
const configuredOrigin = env.APP_ORIGIN?.replace(/\/+$/, '');
const origin = configuredOrigin || url.origin;
const hostname = new URL(origin).hostname;
return {
rpID: env.PASSKEY_RP_ID || (hostname === 'localhost' || hostname === '127.0.0.1' ? hostname : 'shd.build'),
origin
};
}
export async function storeChallenge(database: Database, flow: 'registration' | 'authentication', challenge: string, context: { rpID: string; origin: string }, userId?: string): Promise<string> {
const id = randomUUID(), now = new Date();
await (await database.prepare('DELETE FROM webauthn_challenges WHERE expires_at<=?')).run(now.toISOString());
await (await database.prepare(`INSERT INTO webauthn_challenges(id,user_id,flow,challenge,rp_id,origin,expires_at,created_at)
VALUES(?,?,?,?,?,?,?,?)`)).run(id, userId ?? null, flow, challenge, context.rpID, context.origin, new Date(now.getTime() + 5 * 60_000).toISOString(), now.toISOString());
return id;
}
export async function consumeChallenge(database: Database, id: string, flow: 'registration' | 'authentication', userId?: string) {
const row = await (await database.prepare(`SELECT * FROM webauthn_challenges WHERE id=? AND flow=? AND expires_at>?
AND (? IS NULL OR user_id=?)`)).get(id, flow, new Date().toISOString(), userId ?? null, userId ?? null);
if (!row) return null;
await (await database.prepare('DELETE FROM webauthn_challenges WHERE id=?')).run(id);
return row;
}
+4 -2
View File
@@ -10,6 +10,7 @@ const SESSION_SECONDS = 60 * 60 * 24 * 30;
export type AuthUser = {
id: string;
username: string;
loginUsername: string | null;
displayName: string;
role: string;
discordLinked: boolean;
@@ -46,7 +47,7 @@ export async function createSession(cookies: Cookies, userId: string): Promise<v
});
}
export async function recordLogin(userId: string | null, provider: 'password' | 'discord', succeeded: boolean): Promise<void> {
export async function recordLogin(userId: string | null, provider: 'password' | 'discord' | 'magic_link' | 'passkey', succeeded: boolean): Promise<void> {
const database = await getDatabase();
await (await database.prepare('INSERT INTO login_events(id,user_id,provider,succeeded,created_at) VALUES(?,?,?,?,?)'))
.run(randomUUID(), userId, provider, succeeded ? 1 : 0, new Date().toISOString());
@@ -58,7 +59,7 @@ export async function getSessionUser(cookies: Cookies): Promise<AuthUser | null>
const database = await getDatabase();
const statement = await database.prepare(`
SELECT users.id, users.username, COALESCE(user_profiles.display_name,users.username) AS display_name,
SELECT users.id, users.username, users.login_username, COALESCE(user_profiles.display_name,users.username) AS display_name,
users.role, users.discord_id, user_avatars.source AS avatar_source,
user_avatars.value AS avatar_value, user_avatars.updated_at AS avatar_updated_at,
user_status.last_active_at,user_status.blocked_at,user_status.banned_until,
@@ -93,6 +94,7 @@ export async function getSessionUser(cookies: Cookies): Promise<AuthUser | null>
return {
id: String(user.id),
username: String(user.username),
loginUsername: typeof user.login_username === 'string' ? user.login_username : null,
displayName: String(user.display_name),
role: String(user.role),
discordLinked: typeof user.discord_id === 'string',