#!/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(`
${error}
${url.searchParams.get('error_description') || ''}
You may close this page.
Welcome, ${displayName}!
You may close this page.