canvas-website/worker/cryptidAuth.ts

755 lines
24 KiB
TypeScript

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<boolean> {
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<void> {
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<Response> {
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<User>();
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<DeviceKey>();
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',
`
<h2>Verify your CryptID email</h2>
<p>Click the link below to verify your email address for CryptID: <strong>${cryptidUsername}</strong></p>
<p><a href="${verifyUrl}" style="display: inline-block; padding: 12px 24px; background: #4f46e5; color: white; text-decoration: none; border-radius: 6px;">Verify Email</a></p>
<p>Or copy this link: ${verifyUrl}</p>
<p>This link expires in 24 hours.</p>
<p style="color: #666; font-size: 12px;">If you didn't request this, you can safely ignore this email.</p>
`
);
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<Response> {
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<VerificationToken>();
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<Response> {
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<DeviceKey & { cryptid_username: string }>();
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<User>();
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
? `
<h2>Complete Your CryptID Setup</h2>
<p>Click the link below to verify your email and set up your CryptID: <strong>${user!.cryptid_username}</strong></p>
<p><strong>Device:</strong> ${deviceName || 'New Device'}</p>
<p><a href="${linkUrl}" style="display: inline-block; padding: 12px 24px; background: #4f46e5; color: white; text-decoration: none; border-radius: 6px;">Complete Setup</a></p>
<p>Or copy this link: ${linkUrl}</p>
<p>This link expires in 1 hour.</p>
<p style="color: #666; font-size: 12px;">If you didn't request this, you can safely ignore this email.</p>
`
: needsEmailVerification
? `
<h2>Verify Email & Link Device</h2>
<p>Click the link below to verify your email and link this device to your CryptID: <strong>${user!.cryptid_username}</strong></p>
<p><strong>Device:</strong> ${deviceName || 'New Device'}</p>
<p><a href="${linkUrl}" style="display: inline-block; padding: 12px 24px; background: #4f46e5; color: white; text-decoration: none; border-radius: 6px;">Verify & Link Device</a></p>
<p>Or copy this link: ${linkUrl}</p>
<p>This link expires in 1 hour.</p>
<p style="color: #c00; font-size: 12px;"><strong>If you didn't request this, do not click the link.</strong></p>
`
: `
<h2>New Device Link Request</h2>
<p>Someone is trying to link a new device to your CryptID: <strong>${user!.cryptid_username}</strong></p>
<p><strong>Device:</strong> ${deviceName || 'New Device'}</p>
<p>If this was you, click the button below to approve:</p>
<p><a href="${linkUrl}" style="display: inline-block; padding: 12px 24px; background: #4f46e5; color: white; text-decoration: none; border-radius: 6px;">Approve Device</a></p>
<p>Or copy this link: ${linkUrl}</p>
<p>This link expires in 1 hour.</p>
<p style="color: #c00; font-size: 12px;"><strong>If you didn't request this, do not click the link.</strong> Someone may be trying to access your account.</p>
`;
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<Response> {
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<VerificationToken>();
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<User>();
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<Response> {
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<Response> {
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<DeviceKey>();
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<Response> {
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' },
});
}
}