Monorepo for Aesthetic.Computer aesthetic.computer
at main 237 lines 8.6 kB view raw
1#!/usr/bin/env node 2/** 3 * ac-login - CLI authentication for Aesthetic Computer 4 * 5 * Uses OAuth 2.0 Device Code Flow (no localhost needed!) 6 * User visits URL on any device, enters code, CLI gets token 7 * 8 * Usage: 9 * node ac-login.mjs - Login 10 * node ac-login.mjs status - Check login status 11 * node ac-login.mjs logout - Clear stored token 12 * node ac-login.mjs token - Show current token 13 */ 14 15import { promises as fs } from 'fs'; 16import { fileURLToPath } from 'url'; 17import { dirname, join } from 'path'; 18import { exec } from 'child_process'; 19 20const __filename = fileURLToPath(import.meta.url); 21const __dirname = dirname(__filename); 22 23// Config 24const AUTH0_DOMAIN = 'aesthetic.us.auth0.com'; 25const AUTH0_CLIENT_ID = 'LVdZaMbyXctkGfZDnpzDATB5nR0ZhmMt'; 26const TOKEN_FILE = join(process.env.HOME, '.ac-token'); 27const POLL_INTERVAL = 5000; // 5 seconds 28 29// Open browser (devcontainer-aware) 30function openBrowser(url) { 31 const browserCmd = process.env.BROWSER; 32 if (browserCmd) { 33 // In devcontainer with BROWSER env var 34 exec(`${browserCmd} "${url}"`, (err) => { 35 if (err) console.error('Failed to open browser:', err.message); 36 }); 37 } else { 38 // Standard environment 39 const cmd = process.platform === 'darwin' ? 'open' : 40 process.platform === 'win32' ? 'start' : 'xdg-open'; 41 exec(`${cmd} "${url}"`, (err) => { 42 if (err) console.error('Failed to open browser:', err.message); 43 }); 44 } 45} 46 47// Sleep helper 48const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); 49 50// Main login flow using Device Code Flow 51async function login() { 52 console.log('╔══════════════════════════════════════════════════════════════╗'); 53 console.log('║ 🔐 Aesthetic Computer CLI Login ║'); 54 console.log('╚══════════════════════════════════════════════════════════════╝\n'); 55 56 try { 57 // Step 1: Request device code 58 console.log('📱 Requesting device code...\n'); 59 60 const deviceResponse = await fetch(`https://${AUTH0_DOMAIN}/oauth/device/code`, { 61 method: 'POST', 62 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 63 body: new URLSearchParams({ 64 client_id: AUTH0_CLIENT_ID, 65 scope: 'openid profile email offline_access', 66 audience: 'https://aesthetic.us.auth0.com/api/v2/', 67 }), 68 }); 69 70 if (!deviceResponse.ok) { 71 const error = await deviceResponse.text(); 72 throw new Error(`Failed to get device code: ${error}`); 73 } 74 75 const deviceData = await deviceResponse.json(); 76 77 // Step 2: Display instructions to user 78 console.log('┌────────────────────────────────────────────────────────────┐'); 79 console.log('│ Please complete authentication in your browser: │'); 80 console.log('├────────────────────────────────────────────────────────────┤'); 81 console.log(`│ 1. Visit: ${deviceData.verification_uri_complete || deviceData.verification_uri}`.padEnd(61) + '│'); 82 console.log('│ │'); 83 console.log(`│ 2. Enter code: \x1b[1m${deviceData.user_code}\x1b[0m`.padEnd(71) + '│'); 84 console.log('└────────────────────────────────────────────────────────────┘\n'); 85 86 // Auto-open browser if complete URI is provided 87 if (deviceData.verification_uri_complete) { 88 console.log('🌐 Opening browser...\n'); 89 openBrowser(deviceData.verification_uri_complete); 90 } 91 92 console.log(`⏳ Waiting for authentication (expires in ${Math.floor(deviceData.expires_in / 60)} minutes)...`); 93 console.log(' Press Ctrl+C to cancel\n'); 94 95 // Step 3: Poll for tokens 96 const startTime = Date.now(); 97 const expiresAt = startTime + (deviceData.expires_in * 1000); 98 const interval = deviceData.interval ? deviceData.interval * 1000 : POLL_INTERVAL; 99 100 let dots = 0; 101 while (Date.now() < expiresAt) { 102 const tokenResponse = await fetch(`https://${AUTH0_DOMAIN}/oauth/token`, { 103 method: 'POST', 104 headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 105 body: new URLSearchParams({ 106 grant_type: 'urn:ietf:params:oauth:grant-type:device_code', 107 device_code: deviceData.device_code, 108 client_id: AUTH0_CLIENT_ID, 109 }), 110 }); 111 112 const tokenData = await tokenResponse.json(); 113 114 if (tokenResponse.ok) { 115 // Success! Got tokens 116 console.log('\n\n✅ Authentication successful!\n'); 117 118 // Get user info 119 const userResponse = await fetch(`https://${AUTH0_DOMAIN}/userinfo`, { 120 headers: { 'Authorization': `Bearer ${tokenData.access_token}` }, 121 }); 122 123 const user = await userResponse.json(); 124 125 // Save tokens 126 await fs.writeFile(TOKEN_FILE, JSON.stringify({ 127 ...tokenData, 128 user, 129 expires_at: Date.now() + (tokenData.expires_in * 1000), 130 }, null, 2)); 131 132 console.log(`👤 User: ${user.email || user.name || user.sub}`); 133 console.log(`💾 Token stored in: ${TOKEN_FILE}`); 134 console.log('\nYou can now use authenticated API calls.'); 135 136 return tokenData; 137 } 138 139 // Handle errors 140 if (tokenData.error === 'authorization_pending') { 141 // Still waiting for user to authorize 142 process.stdout.write('.'); 143 dots++; 144 if (dots % 10 === 0) process.stdout.write(` ${Math.floor((Date.now() - startTime) / 1000)}s\n`); 145 await sleep(interval); 146 continue; 147 } 148 149 if (tokenData.error === 'slow_down') { 150 // We're polling too fast, increase interval 151 await sleep(interval + 5000); 152 continue; 153 } 154 155 if (tokenData.error === 'expired_token') { 156 throw new Error('Device code expired. Please try again.'); 157 } 158 159 if (tokenData.error === 'access_denied') { 160 throw new Error('Access denied. You cancelled the authorization.'); 161 } 162 163 // Unknown error 164 throw new Error(`Authentication failed: ${tokenData.error} - ${tokenData.error_description || 'Unknown error'}`); 165 } 166 167 throw new Error('Authentication timeout. Please try again.'); 168 169 } catch (err) { 170 console.error('\n❌ Login failed:', err.message); 171 process.exit(1); 172 } 173} 174 175// Check if already logged in 176async function checkAuth() { 177 try { 178 const tokenData = await fs.readFile(TOKEN_FILE, 'utf8'); 179 const tokens = JSON.parse(tokenData); 180 181 if (tokens.expires_at && Date.now() > tokens.expires_at) { 182 console.log('⚠️ Token expired'); 183 return null; 184 } 185 186 console.log('✅ Logged in'); 187 console.log(`👤 User: ${tokens.user?.email || tokens.user?.name || tokens.user?.sub || 'Unknown'}`); 188 console.log(`🕐 Token expires: ${new Date(tokens.expires_at).toLocaleString()}`); 189 190 return tokens; 191 } catch (err) { 192 return null; 193 } 194} 195 196// Main 197(async () => { 198 const command = process.argv[2]; 199 200 if (command === 'logout') { 201 try { 202 await fs.unlink(TOKEN_FILE); 203 console.log('✅ Logged out successfully'); 204 } catch (err) { 205 console.log('Already logged out'); 206 } 207 return; 208 } 209 210 if (command === 'status') { 211 const tokens = await checkAuth(); 212 if (!tokens) { 213 console.log('❌ Not logged in'); 214 console.log('Run: node ac-login.mjs'); 215 process.exit(1); 216 } 217 return; 218 } 219 220 if (command === 'token') { 221 try { 222 const tokenData = await fs.readFile(TOKEN_FILE, 'utf8'); 223 const tokens = JSON.parse(tokenData); 224 console.log('Access Token:', tokens.access_token); 225 console.log('\nFull token data:'); 226 console.log(JSON.stringify(tokens, null, 2)); 227 } catch (err) { 228 console.log('❌ Not logged in'); 229 console.log('Run: node ac-login.mjs'); 230 process.exit(1); 231 } 232 return; 233 } 234 235 // Default: login 236 await login(); 237})();