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 SendGrid async function sendEmail( env: Environment, to: string, subject: string, htmlContent: string ): Promise { try { const response = await fetch('https://api.sendgrid.com/v3/mail/send', { method: 'POST', headers: { 'Authorization': `Bearer ${env.SENDGRID_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ personalizations: [{ to: [{ email: to }] }], from: { email: env.CRYPTID_EMAIL_FROM || 'noreply@jeffemmett.com', name: 'CryptID' }, subject, content: [{ type: 'text/html', value: htmlContent }], }), }); if (!response.ok) { console.error('SendGrid error:', await response.text()); return false; } 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, cryptidUsername? } * * Combined flow: If email not verified yet, this will verify email AND link device * in one step when user clicks the link. Saves user time vs separate flows. */ export async function handleRequestDeviceLink( request: Request, env: Environment ): Promise { try { const body = await request.json() as { email: string; publicKey: string; deviceName?: string; cryptidUsername?: string; // Optional: for new accounts being set up }; const { email, publicKey, deviceName, cryptidUsername: providedUsername } = body; if (!email || !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 this public key is already registered const existingKey = await db.prepare( 'SELECT dk.*, u.cryptid_username FROM device_keys dk JOIN users u ON dk.user_id = u.id WHERE dk.public_key = ?' ).bind(publicKey).first(); if (existingKey) { return new Response(JSON.stringify({ success: true, message: 'Device already linked', cryptidUsername: existingKey.cryptid_username, alreadyLinked: true }), { headers: { 'Content-Type': 'application/json' }, }); } // Check if email exists in system (verified or not) let user = await db.prepare( 'SELECT * FROM users WHERE email = ?' ).bind(email).first(); const isNewAccount = !user; const needsEmailVerification = !user || !user.email_verified; // If no user exists and no username provided, we need a username if (!user && !providedUsername) { return new Response(JSON.stringify({ error: 'No account found for this email. Please provide a CryptID username to create an account.', needsUsername: true }), { status: 404, headers: { 'Content-Type': 'application/json' }, }); } // Create user if doesn't exist if (!user && providedUsername) { const userId = generateUUID(); await db.prepare( 'INSERT INTO users (id, email, cryptid_username, email_verified) VALUES (?, ?, ?, 0)' ).bind(userId, email, providedUsername).run(); user = { id: userId, email, cryptid_username: providedUsername, email_verified: 0, created_at: new Date().toISOString(), updated_at: new Date().toISOString() }; } const userAgent = request.headers.get('User-Agent') || null; // Clean up old tokens await cleanupExpiredTokens(db); // Create combined device link + email verification token // Uses 'device_link' type but will also verify email when clicked const token = generateToken(); const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString(); // 1 hour 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 combined verification email const linkUrl = `${env.APP_URL || 'https://jeffemmett.com'}/link-device?token=${token}`; // Different email content based on whether this is new account or existing const emailSubject = isNewAccount ? 'Complete your CryptID setup' : needsEmailVerification ? 'Verify email and link device to your CryptID' : 'Link new device to your CryptID'; const emailContent = isNewAccount ? `

Complete Your CryptID Setup

Click the link below to verify your email and set up your CryptID: ${user!.cryptid_username}

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

Complete Setup

Or copy this link: ${linkUrl}

This link expires in 1 hour.

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

` : needsEmailVerification ? `

Verify Email & Link Device

Click the link below to verify your email and link this device to your CryptID: ${user!.cryptid_username}

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

Verify & Link Device

Or copy this link: ${linkUrl}

This link expires in 1 hour.

If you didn't request this, do not click the link.

` : `

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.

`; const emailSent = await sendEmail(env, email, emailSubject, emailContent); return new Response(JSON.stringify({ success: true, message: emailSent ? 'Verification email sent to your address' : 'Failed to send email', emailSent, cryptidUsername: user!.cryptid_username, isNewAccount, needsEmailVerification }), { 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 * * Combined flow: This also verifies email if not already verified. * User clicks link from Device B, which links the device AND verifies the email * in one action - saving the user from needing to do two separate verifications. */ 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' }, }); } // Track if we're also verifying email const wasEmailUnverified = user.email_verified === 0; // 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(); // COMBINED FLOW: Also verify email if not already verified // This saves the user from needing to click a separate email verification link if (wasEmailUnverified) { await db.prepare( "UPDATE users SET email_verified = 1, updated_at = datetime('now') WHERE id = ?" ).bind(user.id).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: wasEmailUnverified ? 'Device linked and email verified successfully' : 'Device linked successfully', cryptidUsername: user.cryptid_username, email: user.email, emailVerified: true, // Now definitely verified emailWasJustVerified: wasEmailUnverified }), { 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' }, }); } } /** * 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' }, }); } }