import { Environment, User, DeviceKey, VerificationToken } from './types'; // Generate a cryptographically secure random token function generateToken(): string { const array = new Uint8Array(32); crypto.getRandomValues(array); return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join(''); } // Generate a UUID v4 function generateUUID(): string { return crypto.randomUUID(); } // Send email via Resend async function sendEmail( env: Environment, to: string, subject: string, htmlContent: string ): Promise { try { if (!env.RESEND_API_KEY) { console.error('RESEND_API_KEY not configured'); return false; } const response = await fetch('https://api.resend.com/emails', { method: 'POST', headers: { 'Authorization': `Bearer ${env.RESEND_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ from: env.CRYPTID_EMAIL_FROM || 'CryptID ', to: [to], subject, html: htmlContent, }), }); if (!response.ok) { const errorText = await response.text(); console.error('Resend error:', errorText); return false; } const result = await response.json() as { id?: string }; console.log('Email sent successfully, id:', result.id); return true; } catch (error) { console.error('Email send error:', error); return false; } } // Clean up expired tokens async function cleanupExpiredTokens(db: D1Database): Promise { try { await db.prepare( "DELETE FROM verification_tokens WHERE expires_at < datetime('now') OR used = 1" ).run(); } catch (error) { console.error('Token cleanup error:', error); } } /** * Link an email to an existing CryptID account (Device A) * POST /auth/link-email * Body: { email, cryptidUsername, publicKey, signature, challenge } */ export async function handleLinkEmail( request: Request, env: Environment ): Promise { try { const body = await request.json() as { email: string; cryptidUsername: string; publicKey: string; deviceName?: string; }; const { email, cryptidUsername, publicKey, deviceName } = body; if (!email || !cryptidUsername || !publicKey) { return new Response(JSON.stringify({ error: 'Missing required fields' }), { status: 400, headers: { 'Content-Type': 'application/json' }, }); } // Validate email format const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(email)) { return new Response(JSON.stringify({ error: 'Invalid email format' }), { status: 400, headers: { 'Content-Type': 'application/json' }, }); } const db = env.CRYPTID_DB; if (!db) { return new Response(JSON.stringify({ error: 'Database not configured' }), { status: 503, headers: { 'Content-Type': 'application/json' }, }); } // Check if email is already linked to a different account const existingUser = await db.prepare( 'SELECT * FROM users WHERE email = ?' ).bind(email).first(); if (existingUser && existingUser.cryptid_username !== cryptidUsername) { return new Response(JSON.stringify({ error: 'Email already linked to a different CryptID account' }), { status: 409, headers: { 'Content-Type': 'application/json' }, }); } // Check if this public key is already registered const existingKey = await db.prepare( 'SELECT * FROM device_keys WHERE public_key = ?' ).bind(publicKey).first(); if (existingKey) { // Key already registered, just need to verify email if not done if (existingUser && existingUser.email_verified) { return new Response(JSON.stringify({ success: true, message: 'Email already verified', emailVerified: true }), { headers: { 'Content-Type': 'application/json' }, }); } } const userId = existingUser?.id || generateUUID(); const userAgent = request.headers.get('User-Agent') || null; // Create or update user if (!existingUser) { await db.prepare( 'INSERT INTO users (id, email, cryptid_username, email_verified) VALUES (?, ?, ?, 0)' ).bind(userId, email, cryptidUsername).run(); } // Add device key if not exists if (!existingKey) { await db.prepare( 'INSERT INTO device_keys (id, user_id, public_key, device_name, user_agent) VALUES (?, ?, ?, ?, ?)' ).bind(generateUUID(), userId, publicKey, deviceName || 'Primary Device', userAgent).run(); } // If email not verified, send verification email if (!existingUser?.email_verified) { // Clean up old tokens await cleanupExpiredTokens(db); // Create verification token (24 hour expiry) const token = generateToken(); const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); await db.prepare( 'INSERT INTO verification_tokens (id, email, token, token_type, expires_at) VALUES (?, ?, ?, ?, ?)' ).bind(generateUUID(), email, token, 'email_verify', expiresAt).run(); // Send verification email const verifyUrl = `${env.APP_URL || 'https://jeffemmett.com'}/verify-email?token=${token}`; const emailSent = await sendEmail( env, email, 'Verify your CryptID email', `

Verify your CryptID email

Click the link below to verify your email address for CryptID: ${cryptidUsername}

Verify Email

Or copy this link: ${verifyUrl}

This link expires in 24 hours.

If you didn't request this, you can safely ignore this email.

` ); return new Response(JSON.stringify({ success: true, message: emailSent ? 'Verification email sent' : 'Account created but email failed to send', emailVerified: false, emailSent }), { headers: { 'Content-Type': 'application/json' }, }); } return new Response(JSON.stringify({ success: true, message: 'Email already verified', emailVerified: true }), { headers: { 'Content-Type': 'application/json' }, }); } catch (error) { console.error('Link email error:', error); return new Response(JSON.stringify({ error: 'Internal server error' }), { status: 500, headers: { 'Content-Type': 'application/json' }, }); } } /** * Verify email via token (clicked from email) * GET /auth/verify-email/:token */ export async function handleVerifyEmail( token: string, env: Environment ): Promise { try { const db = env.CRYPTID_DB; if (!db) { return new Response(JSON.stringify({ error: 'Database not configured' }), { status: 503, headers: { 'Content-Type': 'application/json' }, }); } // Find token const tokenRecord = await db.prepare( "SELECT * FROM verification_tokens WHERE token = ? AND token_type = 'email_verify' AND used = 0 AND expires_at > datetime('now')" ).bind(token).first(); if (!tokenRecord) { return new Response(JSON.stringify({ error: 'Invalid or expired token' }), { status: 400, headers: { 'Content-Type': 'application/json' }, }); } // Mark email as verified await db.prepare( "UPDATE users SET email_verified = 1, updated_at = datetime('now') WHERE email = ?" ).bind(tokenRecord.email).run(); // Mark token as used await db.prepare( 'UPDATE verification_tokens SET used = 1 WHERE id = ?' ).bind(tokenRecord.id).run(); // Return success - frontend will redirect return new Response(JSON.stringify({ success: true, message: 'Email verified successfully', email: tokenRecord.email }), { headers: { 'Content-Type': 'application/json' }, }); } catch (error) { console.error('Verify email error:', error); return new Response(JSON.stringify({ error: 'Internal server error' }), { status: 500, headers: { 'Content-Type': 'application/json' }, }); } } /** * Request to link a new device (Device B enters email) * POST /auth/request-device-link * Body: { email, publicKey, deviceName } */ export async function handleRequestDeviceLink( request: Request, env: Environment ): Promise { try { const body = await request.json() as { email: string; publicKey: string; deviceName?: string; }; const { email, publicKey, deviceName } = body; if (!email || !publicKey) { return new Response(JSON.stringify({ error: 'Missing required fields' }), { status: 400, headers: { 'Content-Type': 'application/json' }, }); } const db = env.CRYPTID_DB; if (!db) { return new Response(JSON.stringify({ error: 'Database not configured' }), { status: 503, headers: { 'Content-Type': 'application/json' }, }); } // Check if email exists and is verified const user = await db.prepare( 'SELECT * FROM users WHERE email = ? AND email_verified = 1' ).bind(email).first(); if (!user) { return new Response(JSON.stringify({ error: 'No verified CryptID account found for this email' }), { status: 404, headers: { 'Content-Type': 'application/json' }, }); } // Check if this public key is already registered const existingKey = await db.prepare( 'SELECT * FROM device_keys WHERE public_key = ?' ).bind(publicKey).first(); if (existingKey) { return new Response(JSON.stringify({ success: true, message: 'Device already linked', cryptidUsername: user.cryptid_username, alreadyLinked: true }), { headers: { 'Content-Type': 'application/json' }, }); } const userAgent = request.headers.get('User-Agent') || null; // Clean up old tokens await cleanupExpiredTokens(db); // Create device link token (1 hour expiry for security) const token = generateToken(); const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString(); await db.prepare( 'INSERT INTO verification_tokens (id, email, token, token_type, public_key, device_name, user_agent, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)' ).bind(generateUUID(), email, token, 'device_link', publicKey, deviceName || 'New Device', userAgent, expiresAt).run(); // Send device link email const linkUrl = `${env.APP_URL || 'https://jeffemmett.com'}/link-device?token=${token}`; const emailSent = await sendEmail( env, email, 'Link new device to your CryptID', `

New Device Link Request

Someone is trying to link a new device to your CryptID: ${user.cryptid_username}

Device: ${deviceName || 'New Device'}

If this was you, click the button below to approve:

Approve Device

Or copy this link: ${linkUrl}

This link expires in 1 hour.

If you didn't request this, do not click the link. Someone may be trying to access your account.

` ); return new Response(JSON.stringify({ success: true, message: emailSent ? 'Verification email sent to your address' : 'Failed to send email', emailSent, cryptidUsername: user.cryptid_username }), { headers: { 'Content-Type': 'application/json' }, }); } catch (error) { console.error('Request device link error:', error); return new Response(JSON.stringify({ error: 'Internal server error' }), { status: 500, headers: { 'Content-Type': 'application/json' }, }); } } /** * Complete device link (clicked from email on Device B) * GET /auth/link-device/:token */ export async function handleLinkDevice( token: string, env: Environment ): Promise { try { const db = env.CRYPTID_DB; if (!db) { return new Response(JSON.stringify({ error: 'Database not configured' }), { status: 503, headers: { 'Content-Type': 'application/json' }, }); } // Find token const tokenRecord = await db.prepare( "SELECT * FROM verification_tokens WHERE token = ? AND token_type = 'device_link' AND used = 0 AND expires_at > datetime('now')" ).bind(token).first(); if (!tokenRecord) { return new Response(JSON.stringify({ error: 'Invalid or expired token' }), { status: 400, headers: { 'Content-Type': 'application/json' }, }); } // Get user const user = await db.prepare( 'SELECT * FROM users WHERE email = ?' ).bind(tokenRecord.email).first(); if (!user) { return new Response(JSON.stringify({ error: 'User not found' }), { status: 404, headers: { 'Content-Type': 'application/json' }, }); } // Add the new device key await db.prepare( 'INSERT INTO device_keys (id, user_id, public_key, device_name, user_agent) VALUES (?, ?, ?, ?, ?)' ).bind( generateUUID(), user.id, tokenRecord.public_key, tokenRecord.device_name, tokenRecord.user_agent ).run(); // Mark token as used await db.prepare( 'UPDATE verification_tokens SET used = 1 WHERE id = ?' ).bind(tokenRecord.id).run(); return new Response(JSON.stringify({ success: true, message: 'Device linked successfully', cryptidUsername: user.cryptid_username, email: user.email }), { headers: { 'Content-Type': 'application/json' }, }); } catch (error) { console.error('Link device error:', error); return new Response(JSON.stringify({ error: 'Internal server error' }), { status: 500, headers: { 'Content-Type': 'application/json' }, }); } } /** * Send a backup email to set up account on another device * POST /api/auth/send-backup-email * Body: { email, username } * * This is called during registration when the user provides an email. * It sends an email with a link to set up their account on another device. */ export async function handleSendBackupEmail( request: Request, env: Environment ): Promise { try { const body = await request.json() as { email: string; username: string; }; const { email, username } = body; if (!email || !username) { return new Response(JSON.stringify({ error: 'Missing required fields' }), { status: 400, headers: { 'Content-Type': 'application/json' }, }); } // Validate email format const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; if (!emailRegex.test(email)) { return new Response(JSON.stringify({ error: 'Invalid email format' }), { status: 400, headers: { 'Content-Type': 'application/json' }, }); } const db = env.CRYPTID_DB; if (!db) { // If no DB, still try to send email (graceful degradation) console.log('No database configured, attempting to send email directly'); } // If DB exists, create/update user record if (db) { // Check if user already exists const existingUser = await db.prepare( 'SELECT * FROM users WHERE cryptid_username = ?' ).bind(username).first(); if (existingUser) { // Update email if user exists await db.prepare( "UPDATE users SET email = ?, updated_at = datetime('now') WHERE cryptid_username = ?" ).bind(email, username).run(); } else { // Create new user record const userId = crypto.randomUUID(); await db.prepare( 'INSERT INTO users (id, email, cryptid_username, email_verified) VALUES (?, ?, ?, 0)' ).bind(userId, email, username).run(); } } // Send the backup email const appUrl = env.APP_URL || 'https://jeffemmett.com'; const setupUrl = `${appUrl}/setup-device?username=${encodeURIComponent(username)}`; const emailSent = await sendEmail( env, email, `Set up CryptID "${username}" on another device`, `
🔐

Welcome to CryptID

Your passwordless account is ready!

Hi ${username},

Your CryptID account has been created on your current device. To access your account from another device (like your phone), follow these steps:

1
Open the link below on your other device

Use your phone, tablet, or another computer

2
A new cryptographic key will be generated

This key is unique to that device and stored securely in the browser

3
You're set! Both devices can now access your account

No passwords to remember - your browser handles authentication

⚠️ Security Note: Only open this link on devices you own. Each device that accesses your account stores a unique cryptographic key.
` ); if (emailSent) { return new Response(JSON.stringify({ success: true, message: 'Backup email sent successfully', }), { headers: { 'Content-Type': 'application/json' }, }); } else { return new Response(JSON.stringify({ error: 'Failed to send email', message: 'Please check your email address and try again', }), { status: 500, headers: { 'Content-Type': 'application/json' }, }); } } catch (error) { console.error('Send backup email error:', error); return new Response(JSON.stringify({ error: 'Internal server error' }), { status: 500, headers: { 'Content-Type': 'application/json' }, }); } } /** * Check if a public key is linked to an account * POST /auth/lookup * Body: { publicKey } */ export async function handleLookup( request: Request, env: Environment ): Promise { try { const body = await request.json() as { publicKey: string }; const { publicKey } = body; if (!publicKey) { return new Response(JSON.stringify({ error: 'Missing publicKey' }), { status: 400, headers: { 'Content-Type': 'application/json' }, }); } const db = env.CRYPTID_DB; if (!db) { return new Response(JSON.stringify({ error: 'Database not configured' }), { status: 503, headers: { 'Content-Type': 'application/json' }, }); } // Find device key and associated user const result = await db.prepare(` SELECT u.cryptid_username, u.email, u.email_verified, dk.device_name FROM device_keys dk JOIN users u ON dk.user_id = u.id WHERE dk.public_key = ? `).bind(publicKey).first<{ cryptid_username: string; email: string; email_verified: number; device_name: string; }>(); if (!result) { return new Response(JSON.stringify({ found: false }), { headers: { 'Content-Type': 'application/json' }, }); } // Update last_used timestamp await db.prepare( "UPDATE device_keys SET last_used = datetime('now') WHERE public_key = ?" ).bind(publicKey).run(); return new Response(JSON.stringify({ found: true, cryptidUsername: result.cryptid_username, email: result.email, emailVerified: result.email_verified === 1, deviceName: result.device_name }), { headers: { 'Content-Type': 'application/json' }, }); } catch (error) { console.error('Lookup error:', error); return new Response(JSON.stringify({ error: 'Internal server error' }), { status: 500, headers: { 'Content-Type': 'application/json' }, }); } } /** * Get linked devices for an account * POST /auth/devices * Body: { publicKey } - authenticates via device's public key */ export async function handleGetDevices( request: Request, env: Environment ): Promise { try { const body = await request.json() as { publicKey: string }; const { publicKey } = body; if (!publicKey) { return new Response(JSON.stringify({ error: 'Missing publicKey' }), { status: 400, headers: { 'Content-Type': 'application/json' }, }); } const db = env.CRYPTID_DB; if (!db) { return new Response(JSON.stringify({ error: 'Database not configured' }), { status: 503, headers: { 'Content-Type': 'application/json' }, }); } // Find user by public key const deviceKey = await db.prepare(` SELECT user_id FROM device_keys WHERE public_key = ? `).bind(publicKey).first<{ user_id: string }>(); if (!deviceKey) { return new Response(JSON.stringify({ error: 'Device not found' }), { status: 404, headers: { 'Content-Type': 'application/json' }, }); } // Get all devices for this user const devices = await db.prepare(` SELECT id, device_name, user_agent, created_at, last_used, public_key FROM device_keys WHERE user_id = ? ORDER BY created_at DESC `).bind(deviceKey.user_id).all(); return new Response(JSON.stringify({ devices: devices.results?.map((d: DeviceKey) => ({ id: d.id, deviceName: d.device_name, userAgent: d.user_agent, createdAt: d.created_at, lastUsed: d.last_used, isCurrentDevice: d.public_key === publicKey })) || [] }), { headers: { 'Content-Type': 'application/json' }, }); } catch (error) { console.error('Get devices error:', error); return new Response(JSON.stringify({ error: 'Internal server error' }), { status: 500, headers: { 'Content-Type': 'application/json' }, }); } } /** * Revoke a device * DELETE /auth/devices/:deviceId * Body: { publicKey } - authenticates via device's public key */ export async function handleRevokeDevice( deviceId: string, request: Request, env: Environment ): Promise { try { const body = await request.json() as { publicKey: string }; const { publicKey } = body; if (!publicKey) { return new Response(JSON.stringify({ error: 'Missing publicKey' }), { status: 400, headers: { 'Content-Type': 'application/json' }, }); } const db = env.CRYPTID_DB; if (!db) { return new Response(JSON.stringify({ error: 'Database not configured' }), { status: 503, headers: { 'Content-Type': 'application/json' }, }); } // Find user by public key const currentDevice = await db.prepare(` SELECT user_id FROM device_keys WHERE public_key = ? `).bind(publicKey).first<{ user_id: string }>(); if (!currentDevice) { return new Response(JSON.stringify({ error: 'Unauthorized' }), { status: 401, headers: { 'Content-Type': 'application/json' }, }); } // Verify the device to revoke belongs to the same user const targetDevice = await db.prepare(` SELECT user_id, public_key FROM device_keys WHERE id = ? `).bind(deviceId).first<{ user_id: string; public_key: string }>(); if (!targetDevice || targetDevice.user_id !== currentDevice.user_id) { return new Response(JSON.stringify({ error: 'Device not found' }), { status: 404, headers: { 'Content-Type': 'application/json' }, }); } // Don't allow revoking the current device if (targetDevice.public_key === publicKey) { return new Response(JSON.stringify({ error: 'Cannot revoke current device' }), { status: 400, headers: { 'Content-Type': 'application/json' }, }); } // Delete the device await db.prepare('DELETE FROM device_keys WHERE id = ?').bind(deviceId).run(); return new Response(JSON.stringify({ success: true, message: 'Device revoked' }), { headers: { 'Content-Type': 'application/json' }, }); } catch (error) { console.error('Revoke device error:', error); return new Response(JSON.stringify({ error: 'Internal server error' }), { status: 500, headers: { 'Content-Type': 'application/json' }, }); } } /** * Check if a username is available * GET /auth/check-username?username= * * Returns whether a username is available for registration. * Used during the CryptID registration flow. */ export async function handleCheckUsername( request: Request, env: Environment ): Promise { try { const url = new URL(request.url); const username = url.searchParams.get('username'); if (!username) { return new Response(JSON.stringify({ error: 'Username is required' }), { status: 400, headers: { 'Content-Type': 'application/json' }, }); } // Normalize username (lowercase, no special chars) const normalizedUsername = username.toLowerCase().replace(/[^a-z0-9_-]/g, ''); if (normalizedUsername.length < 3) { return new Response(JSON.stringify({ available: false, error: 'Username must be at least 3 characters' }), { headers: { 'Content-Type': 'application/json' }, }); } if (normalizedUsername.length > 20) { return new Response(JSON.stringify({ available: false, error: 'Username must be 20 characters or less' }), { headers: { 'Content-Type': 'application/json' }, }); } const db = env.CRYPTID_DB; if (!db) { // If no database, assume username is available (graceful degradation) return new Response(JSON.stringify({ available: true }), { headers: { 'Content-Type': 'application/json' }, }); } // Check if username exists in database const existingUser = await db.prepare( 'SELECT id FROM users WHERE cryptid_username = ?' ).bind(normalizedUsername).first<{ id: string }>(); return new Response(JSON.stringify({ available: !existingUser, username: normalizedUsername, error: existingUser ? 'Username is already taken' : null }), { headers: { 'Content-Type': 'application/json' }, }); } catch (error) { console.error('Check username error:', error); return new Response(JSON.stringify({ error: 'Internal server error' }), { status: 500, headers: { 'Content-Type': 'application/json' }, }); } } /** * Search for CryptID users by username * GET /auth/users/search?q= * * This endpoint allows searching for users by CryptID username. * Used for granting permissions to users by username. */ export async function handleSearchUsers( request: Request, env: Environment ): Promise { try { const url = new URL(request.url); const query = url.searchParams.get('q'); if (!query || query.length < 2) { return new Response(JSON.stringify({ error: 'Query must be at least 2 characters' }), { status: 400, headers: { 'Content-Type': 'application/json' }, }); } const db = env.CRYPTID_DB; if (!db) { return new Response(JSON.stringify({ error: 'Database not configured' }), { status: 503, headers: { 'Content-Type': 'application/json' }, }); } // Search users by username (case-insensitive) const users = await db.prepare(` SELECT id, cryptid_username, email, email_verified, created_at FROM users WHERE cryptid_username LIKE ? ORDER BY cryptid_username LIMIT 20 `).bind(`%${query}%`).all<{ id: string; cryptid_username: string; email: string; email_verified: number; created_at: string; }>(); return new Response(JSON.stringify({ users: (users.results || []).map(u => ({ id: u.id, username: u.cryptid_username, emailVerified: u.email_verified === 1, createdAt: u.created_at })) }), { headers: { 'Content-Type': 'application/json' }, }); } catch (error) { console.error('Search users error:', error); return new Response(JSON.stringify({ error: 'Internal server error' }), { status: 500, headers: { 'Content-Type': 'application/json' }, }); } } /** * List all CryptID users (admin endpoint) * GET /admin/users * Query params: ?limit=&offset= * * This endpoint requires admin authentication. * For now, we'll require a simple admin secret header. */ export async function handleListAllUsers( request: Request, env: Environment ): Promise { try { // Check for admin secret (simple auth for now) const adminSecret = request.headers.get('X-Admin-Secret'); if (!adminSecret || adminSecret !== env.ADMIN_SECRET) { return new Response(JSON.stringify({ error: 'Unauthorized - admin access required' }), { status: 401, headers: { 'Content-Type': 'application/json' }, }); } const db = env.CRYPTID_DB; if (!db) { return new Response(JSON.stringify({ error: 'Database not configured' }), { status: 503, headers: { 'Content-Type': 'application/json' }, }); } const url = new URL(request.url); const limit = Math.min(parseInt(url.searchParams.get('limit') || '100'), 1000); const offset = parseInt(url.searchParams.get('offset') || '0'); // Get total count const countResult = await db.prepare('SELECT COUNT(*) as count FROM users').first<{ count: number }>(); const totalCount = countResult?.count || 0; // Get users with device count const users = await db.prepare(` SELECT u.id, u.cryptid_username, u.email, u.email_verified, u.created_at, u.updated_at, (SELECT COUNT(*) FROM device_keys dk WHERE dk.user_id = u.id) as device_count FROM users u ORDER BY u.created_at DESC LIMIT ? OFFSET ? `).bind(limit, offset).all<{ id: string; cryptid_username: string; email: string; email_verified: number; created_at: string; updated_at: string; device_count: number; }>(); return new Response(JSON.stringify({ users: (users.results || []).map(u => ({ id: u.id, username: u.cryptid_username, email: u.email, emailVerified: u.email_verified === 1, deviceCount: u.device_count, createdAt: u.created_at, updatedAt: u.updated_at })), pagination: { total: totalCount, limit, offset, hasMore: offset + limit < totalCount } }), { headers: { 'Content-Type': 'application/json' }, }); } catch (error) { console.error('List all users error:', error); return new Response(JSON.stringify({ error: 'Internal server error' }), { status: 500, headers: { 'Content-Type': 'application/json' }, }); } }