#!/usr/bin/env node /** * ac-login - CLI authentication for Aesthetic Computer * * Uses OAuth 2.0 Authorization Code Flow with PKCE + local callback server * Opens browser → user logs in → callback returns to localhost → token saved * * Usage: * node ac-login.mjs - Login (opens browser) * node ac-login.mjs status - Check login status * node ac-login.mjs logout - Clear stored token * node ac-login.mjs token - Print access token (for scripts) */ import { promises as fs } from 'fs'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; import { exec } from 'child_process'; import http from 'http'; import crypto from 'crypto'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // Config - matches vscode-extension and kidlisp.com const AUTH0_DOMAIN = 'hi.aesthetic.computer'; const AUTH0_CLIENT_ID = 'LVdZaMbyXctkGfZDnpzDATB5nR0ZhmMt'; const CALLBACK_PORT = 44233; // Matches Auth0 allowed callback URL const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}/callback`; const TOKEN_FILE = join(process.env.HOME, '.ac-token'); const LOGOUT_RETURN_TO = process.env.AC_LOGOUT_RETURN_TO || 'https://aesthetic.computer'; // PKCE helpers function base64URLEncode(buffer) { return buffer.toString('base64') .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=/g, ''); } function generateCodeVerifier() { return base64URLEncode(crypto.randomBytes(32)); } function generateCodeChallenge(verifier) { const hash = crypto.createHash('sha256').update(verifier).digest(); return base64URLEncode(hash); } // Open browser (devcontainer-aware) function openBrowser(url) { const browserCmd = process.env.BROWSER; if (browserCmd) { exec(`${browserCmd} "${url}"`, (err) => { if (err) console.error('Failed to open browser:', err.message); }); } else { const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'; exec(`${cmd} "${url}"`, (err) => { if (err) console.error('Failed to open browser:', err.message); }); } } function buildAuthUrl(state, codeChallenge, forcePrompt = false) { const authUrl = new URL(`https://${AUTH0_DOMAIN}/authorize`); authUrl.searchParams.set('response_type', 'code'); authUrl.searchParams.set('client_id', AUTH0_CLIENT_ID); authUrl.searchParams.set('redirect_uri', REDIRECT_URI); authUrl.searchParams.set('scope', 'openid profile email offline_access'); authUrl.searchParams.set('state', state); authUrl.searchParams.set('code_challenge', codeChallenge); authUrl.searchParams.set('code_challenge_method', 'S256'); if (forcePrompt) authUrl.searchParams.set('prompt', 'login'); return authUrl; } function buildLogoutUrl() { const logoutUrl = new URL(`https://${AUTH0_DOMAIN}/v2/logout`); logoutUrl.searchParams.set('client_id', AUTH0_CLIENT_ID); logoutUrl.searchParams.set('returnTo', LOGOUT_RETURN_TO); return logoutUrl; } // Main login flow async function login({ forcePrompt = false } = {}) { console.log('╔══════════════════════════════════════════════════════════════╗'); console.log('║ 🔐 Aesthetic Computer CLI Login ║'); console.log('╚══════════════════════════════════════════════════════════════╝\n'); const codeVerifier = generateCodeVerifier(); const codeChallenge = generateCodeChallenge(codeVerifier); const state = crypto.randomBytes(16).toString('hex'); try { const { tokens, user } = await startLocalCallbackServer(state, codeVerifier, codeChallenge, { forcePrompt }); const displayName = user.handle ? `@${user.handle}` : user.email || user.name; console.log('\n✅ Authentication successful!\n'); console.log(`👤 User: ${displayName}`); console.log(`💾 Token stored in: ${TOKEN_FILE}`); console.log('\nYou can now use authenticated API calls.\n'); return tokens; } catch (err) { console.error('\n❌ Login failed:', err.message); process.exit(1); } } async function logout({ browser = true } = {}) { let removed = false; try { await fs.unlink(TOKEN_FILE); removed = true; } catch {} if (removed) console.log('✅ Logged out locally\n'); else console.log('ℹ️ Already logged out locally\n'); if (!browser) return; const logoutUrl = buildLogoutUrl(); console.log('🌐 Opening browser to clear Auth0 session...\n'); console.log(' If your browser doesn\'t open, visit:'); console.log(` ${logoutUrl.toString()}\n`); openBrowser(logoutUrl.toString()); } // Start local callback server async function startLocalCallbackServer(state, codeVerifier, codeChallenge, { forcePrompt = false } = {}) { return new Promise((resolve, reject) => { let done = false; const finish = (fn, value) => { if (done) return; done = true; clearTimeout(timeout); server.close(() => fn(value)); }; const server = http.createServer(async (req, res) => { const url = new URL(req.url, `http://localhost:${CALLBACK_PORT}`); if (!url.pathname.includes('callback')) { res.writeHead(404); res.end('Not found'); return; } const code = url.searchParams.get('code'); const error = url.searchParams.get('error'); if (error) { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(` Login Failed · Aesthetic Computer

❌ Authentication Failed

${error}

${url.searchParams.get('error_description') || ''}

You may close this page.

`); finish(reject, new Error(error)); return; } if (!code) { res.writeHead(400); res.end('Missing authorization code'); return; } try { const tokenResponse = await fetch(`https://${AUTH0_DOMAIN}/oauth/token`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ grant_type: 'authorization_code', client_id: AUTH0_CLIENT_ID, code_verifier: codeVerifier, code: code, redirect_uri: REDIRECT_URI, }), }); if (!tokenResponse.ok) { const errText = await tokenResponse.text(); throw new Error(`Token exchange failed: ${errText}`); } const tokens = await tokenResponse.json(); const userResponse = await fetch(`https://${AUTH0_DOMAIN}/userinfo`, { headers: { 'Authorization': `Bearer ${tokens.access_token}` }, }); const user = await userResponse.json(); // Fetch AC handle via /handle?for=sub let acHandle = null; try { const handleRes = await fetch( `https://aesthetic.computer/handle?for=${encodeURIComponent(user.sub)}` ); const handleData = await handleRes.json(); if (handleData.handle) acHandle = handleData.handle; } catch (e) {} await fs.writeFile(TOKEN_FILE, JSON.stringify({ access_token: tokens.access_token, refresh_token: tokens.refresh_token, id_token: tokens.id_token, expires_at: Date.now() + (tokens.expires_in * 1000), user: { email: user.email, name: user.name, sub: user.sub, picture: user.picture, handle: acHandle }, }, null, 2)); const displayName = acHandle ? `@${acHandle}` : user.email || user.name; res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(` Logged In · Aesthetic Computer

✅ Login Successful

Welcome, ${displayName}!

You may close this page.

`); finish(resolve, { tokens, user: { ...user, handle: acHandle } }); } catch (err) { res.writeHead(500, { 'Content-Type': 'text/plain' }); res.end(`Error: ${err.message}`); finish(reject, err); } }); server.listen(CALLBACK_PORT, () => { const authUrl = buildAuthUrl(state, codeChallenge, forcePrompt); console.log('🌐 Opening browser for authentication...\n'); console.log(' If your browser doesn\'t open, visit:'); console.log(` ${authUrl.toString()}\n`); openBrowser(authUrl.toString()); console.log('⏳ Waiting for authentication...\n'); }); server.on('error', (err) => { if (err.code === 'EADDRINUSE') { finish(reject, new Error(`Port ${CALLBACK_PORT} is already in use.`)); } else { finish(reject, err); } }); const timeout = setTimeout( () => finish(reject, new Error('Login timeout')), 5 * 60 * 1000, ); timeout.unref?.(); }); } async function checkAuth() { try { const tokenData = await fs.readFile(TOKEN_FILE, 'utf8'); const tokens = JSON.parse(tokenData); const expired = tokens.expires_at && Date.now() > tokens.expires_at; const expiresIn = Math.floor((tokens.expires_at - Date.now()) / 1000 / 60); console.log('╔══════════════════════════════════════════════════════════════╗'); console.log('║ 🔐 AC Login Status ║'); console.log('╚══════════════════════════════════════════════════════════════╝\n'); if (expired) { console.log('⚠️ Token expired - run `ac-login` to refresh\n'); } else { console.log('✅ Logged in\n'); } const displayName = tokens.user?.handle ? `@${tokens.user.handle}` : tokens.user?.email || tokens.user?.name || 'unknown'; console.log(`👤 User: ${displayName}`); console.log(`📧 Email: ${tokens.user?.email || 'n/a'}`); console.log(`⏰ Expires: ${expired ? 'EXPIRED' : `in ${expiresIn} minutes`}`); console.log(`📁 Token: ${TOKEN_FILE}\n`); return expired ? null : tokens; } catch (err) { console.log('❌ Not logged in\n'); console.log(' Run `ac-login` to authenticate.\n'); return null; } } export async function getToken() { try { const tokenData = await fs.readFile(TOKEN_FILE, 'utf8'); const tokens = JSON.parse(tokenData); if (tokens.expires_at && Date.now() > tokens.expires_at) return null; return tokens; } catch (err) { return null; } } (async () => { const command = process.argv[2]; const localOnly = process.argv.includes('--local-only'); const forcePrompt = command === 'fresh' || command === 'switch' || process.argv.includes('--fresh'); if (command === 'logout') { await logout({ browser: !localOnly }); return; } if (command === 'status') { await checkAuth(); return; } if (command === 'token') { try { const tokenData = await fs.readFile(TOKEN_FILE, 'utf8'); const tokens = JSON.parse(tokenData); if (tokens.expires_at && Date.now() > tokens.expires_at) { console.error('Token expired'); process.exit(1); } console.log(tokens.access_token); } catch { console.error('Not logged in'); process.exit(1); } return; } if (command === 'help' || command === '-h' || command === '--help') { console.log(` 🔐 ac-login - Aesthetic Computer CLI Authentication Usage: ac-login Login (opens browser) ac-login fresh Login with forced account prompt ac-login status Check login status ac-login logout Clear local token + browser Auth0 session ac-login logout --local-only Clear local token only ac-login token Print access token (for scripts) ac-login help Show this help `); return; } await login({ forcePrompt }); })();