canvas-website/src/lib/auth/cryptidEmailService.ts

439 lines
11 KiB
TypeScript

/**
* CryptID Email Service
* Handles communication with the backend for email linking and device verification
*/
import * as crypto from './crypto';
// Get the worker API URL based on environment
function getApiUrl(): string {
const hostname = window.location.hostname;
// In development (localhost, local IPs, Tailscale IPs), use the local worker on same host
const isLocalDev =
hostname === 'localhost' ||
hostname === '127.0.0.1' ||
hostname.startsWith('192.168.') ||
hostname.startsWith('10.') ||
hostname.startsWith('172.') ||
hostname.startsWith('100.'); // Tailscale
if (isLocalDev) {
return `http://${hostname}:5172`;
}
// In production, use the deployed worker
return 'https://jeffemmett-canvas.jeffemmett.workers.dev';
}
export interface LinkEmailResult {
success: boolean;
message?: string;
emailVerified?: boolean;
emailSent?: boolean;
error?: string;
}
export interface DeviceLinkResult {
success: boolean;
message?: string;
cryptidUsername?: string;
alreadyLinked?: boolean;
emailSent?: boolean;
error?: string;
}
export interface LookupResult {
found: boolean;
cryptidUsername?: string;
email?: string;
emailVerified?: boolean;
deviceName?: string;
}
export interface Device {
id: string;
deviceName: string;
userAgent: string | null;
createdAt: string;
lastUsed: string | null;
isCurrentDevice: boolean;
}
/**
* Link an email to the current CryptID account
* Called from Device A (existing device with account)
*/
export async function linkEmailToAccount(
email: string,
cryptidUsername: string,
deviceName?: string
): Promise<LinkEmailResult> {
try {
// Get the public key for this user
const publicKey = crypto.getPublicKey(cryptidUsername);
if (!publicKey) {
return {
success: false,
error: 'No public key found for this account'
};
}
const response = await fetch(`${getApiUrl()}/auth/link-email`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email,
cryptidUsername,
publicKey,
deviceName: deviceName || getDeviceName()
}),
});
const data = await response.json() as LinkEmailResult & { error?: string };
if (!response.ok) {
return {
success: false,
error: data.error || 'Failed to link email'
};
}
return data;
} catch (error) {
console.error('Link email error:', error);
return {
success: false,
error: String(error)
};
}
}
/**
* Check the status of email verification
*/
export async function checkEmailStatus(cryptidUsername: string): Promise<LookupResult> {
try {
const publicKey = crypto.getPublicKey(cryptidUsername);
if (!publicKey) {
return { found: false };
}
const response = await fetch(`${getApiUrl()}/auth/lookup`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ publicKey }),
});
const data = await response.json() as LookupResult;
return data;
} catch (error) {
console.error('Check email status error:', error);
return { found: false };
}
}
/**
* Request to link a new device using email
* Called from Device B (new device)
*
* Flow:
* 1. Generate new keypair on Device B
* 2. Send email + publicKey to server
* 3. Server sends verification email
* 4. User clicks link in email (on Device B)
* 5. Device B's key is linked to the account
*/
export async function requestDeviceLink(
email: string,
deviceName?: string
): Promise<DeviceLinkResult & { publicKey?: string }> {
try {
// Generate a new keypair for this device
const keyPair = await crypto.generateKeyPair();
if (!keyPair) {
return {
success: false,
error: 'Failed to generate cryptographic keys'
};
}
// Export the public key
const publicKey = await crypto.exportPublicKey(keyPair.publicKey);
if (!publicKey) {
return {
success: false,
error: 'Failed to export public key'
};
}
const response = await fetch(`${getApiUrl()}/auth/request-device-link`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
email,
publicKey,
deviceName: deviceName || getDeviceName()
}),
});
const data = await response.json() as DeviceLinkResult & { error?: string };
if (!response.ok) {
return {
success: false,
error: data.error || 'Failed to request device link'
};
}
// If successful, temporarily store the keypair for later
// The user will need to click the email link to complete the process
if (data.success && !data.alreadyLinked) {
// Store pending link data
sessionStorage.setItem('pendingDeviceLink', JSON.stringify({
email,
publicKey,
cryptidUsername: data.cryptidUsername,
timestamp: Date.now()
}));
}
return {
...data,
publicKey
};
} catch (error) {
console.error('Request device link error:', error);
return {
success: false,
error: String(error)
};
}
}
/**
* Complete the device link after email verification
* Called when user clicks the verification link and lands back on the app
*/
export async function completeDeviceLink(token: string): Promise<DeviceLinkResult> {
try {
const response = await fetch(`${getApiUrl()}/auth/link-device/${token}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
const data = await response.json() as DeviceLinkResult & { email?: string; error?: string };
if (!response.ok) {
return {
success: false,
error: data.error || 'Failed to complete device link'
};
}
// Use the typed data
const result = data;
// If successful, the pending device link data should match
const pendingLink = sessionStorage.getItem('pendingDeviceLink');
if (pendingLink && result.success) {
const pending = JSON.parse(pendingLink);
// Register this device locally with the CryptID username from the server
if (result.cryptidUsername) {
// Store the public key locally for this username
crypto.storePublicKey(result.cryptidUsername, pending.publicKey);
crypto.addRegisteredUser(result.cryptidUsername);
// Store auth data to match the existing flow
localStorage.setItem(`${result.cryptidUsername}_authData`, JSON.stringify({
challenge: `device-linked:${Date.now()}`,
signature: 'device-link-verified',
timestamp: Date.now(),
email: result.email
}));
}
// Clear pending link data
sessionStorage.removeItem('pendingDeviceLink');
}
return result;
} catch (error) {
console.error('Complete device link error:', error);
return {
success: false,
error: String(error)
};
}
}
/**
* Verify email via token (for initial email verification)
*/
export async function verifyEmail(token: string): Promise<{ success: boolean; email?: string; error?: string }> {
try {
const response = await fetch(`${getApiUrl()}/auth/verify-email/${token}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
const data = await response.json() as { success: boolean; email?: string; error?: string };
if (!response.ok) {
return {
success: false,
error: data.error || 'Failed to verify email'
};
}
return data;
} catch (error) {
console.error('Verify email error:', error);
return {
success: false,
error: String(error)
};
}
}
/**
* Get all devices linked to this account
*/
export async function getLinkedDevices(cryptidUsername: string): Promise<Device[]> {
try {
const publicKey = crypto.getPublicKey(cryptidUsername);
if (!publicKey) {
return [];
}
const response = await fetch(`${getApiUrl()}/auth/devices`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ publicKey }),
});
const data = await response.json() as { devices?: Device[] };
return data.devices || [];
} catch (error) {
console.error('Get linked devices error:', error);
return [];
}
}
/**
* Revoke a device from the account
*/
export async function revokeDevice(
cryptidUsername: string,
deviceId: string
): Promise<{ success: boolean; error?: string }> {
try {
const publicKey = crypto.getPublicKey(cryptidUsername);
if (!publicKey) {
return {
success: false,
error: 'No public key found'
};
}
const response = await fetch(`${getApiUrl()}/auth/devices/${deviceId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ publicKey }),
});
const data = await response.json() as { success: boolean; error?: string };
if (!response.ok) {
return {
success: false,
error: data.error || 'Failed to revoke device'
};
}
return data;
} catch (error) {
console.error('Revoke device error:', error);
return {
success: false,
error: String(error)
};
}
}
/**
* Get a friendly device name based on user agent
*/
function getDeviceName(): string {
const ua = navigator.userAgent;
// Detect OS
let os = 'Unknown';
if (ua.includes('Windows')) os = 'Windows';
else if (ua.includes('Mac')) os = 'macOS';
else if (ua.includes('Linux')) os = 'Linux';
else if (ua.includes('Android')) os = 'Android';
else if (ua.includes('iPhone') || ua.includes('iPad')) os = 'iOS';
// Detect browser
let browser = 'Browser';
if (ua.includes('Chrome') && !ua.includes('Edg')) browser = 'Chrome';
else if (ua.includes('Firefox')) browser = 'Firefox';
else if (ua.includes('Safari') && !ua.includes('Chrome')) browser = 'Safari';
else if (ua.includes('Edg')) browser = 'Edge';
return `${browser} on ${os}`;
}
/**
* Check if there's a pending device link to complete
*/
export function hasPendingDeviceLink(): boolean {
const pending = sessionStorage.getItem('pendingDeviceLink');
if (!pending) return false;
try {
const data = JSON.parse(pending);
// Check if it's less than 1 hour old
return Date.now() - data.timestamp < 60 * 60 * 1000;
} catch {
return false;
}
}
/**
* Get pending device link info
*/
export function getPendingDeviceLink(): { email: string; cryptidUsername: string } | null {
const pending = sessionStorage.getItem('pendingDeviceLink');
if (!pending) return null;
try {
const data = JSON.parse(pending);
if (Date.now() - data.timestamp < 60 * 60 * 1000) {
return {
email: data.email,
cryptidUsername: data.cryptidUsername
};
}
return null;
} catch {
return null;
}
}