/** * Device auth flow for team sync. * * Opens a browser for Supabase OAuth/magic link, polls for completion, * and saves tokens to ~/.gstack/auth.json. * * Two modes: * 1. Magic link: user enters email → receives link → CLI detects auth via polling * 2. Browser OAuth: opens Supabase auth page → callback to localhost → CLI captures token * * For CI: set GSTACK_SUPABASE_ACCESS_TOKEN env var to skip interactive auth. */ import * as http from 'http'; import { saveAuthTokens, type TeamConfig, type AuthTokens } from './sync-config'; const AUTH_CALLBACK_PORT = 54321; const AUTH_TIMEOUT_MS = 300_000; // 5 minutes /** * Run the interactive device auth flow. * * 1. Starts a local HTTP server on port 54321 * 2. Opens the Supabase auth page in the browser (with redirect to localhost) * 3. Waits for the auth callback with tokens * 4. Saves tokens and returns them */ export async function runDeviceAuth(team: TeamConfig): Promise { return new Promise((resolve, reject) => { const timeout = setTimeout(() => { server.close(); reject(new Error('Auth timed out after 5 minutes. Please try again.')); }, AUTH_TIMEOUT_MS); const server = http.createServer((req, res) => { const url = new URL(req.url || '/', `http://localhost:${AUTH_CALLBACK_PORT}`); // Handle the OAuth callback if (url.pathname === '/auth/callback') { const accessToken = url.searchParams.get('access_token') || url.hash?.match(/access_token=([^&]+)/)?.[1]; const refreshToken = url.searchParams.get('refresh_token') || ''; const expiresIn = parseInt(url.searchParams.get('expires_in') || '3600', 10); if (!accessToken) { // Serve a page that extracts tokens from the URL hash (Supabase puts them there) res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(authCallbackHTML(AUTH_CALLBACK_PORT)); return; } const tokens: AuthTokens = { access_token: accessToken, refresh_token: refreshToken, expires_at: Math.floor(Date.now() / 1000) + expiresIn, user_id: url.searchParams.get('user_id') || '', team_id: '', // filled in by sync.ts after first API call email: url.searchParams.get('email') || '', }; res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(authSuccessHTML()); clearTimeout(timeout); server.close(); // Save tokens try { saveAuthTokens(team.supabase_url, tokens); } catch (err: any) { reject(new Error(`Failed to save auth tokens: ${err.message}`)); return; } resolve(tokens); return; } // Handle token POST from the callback page if (url.pathname === '/auth/token' && req.method === 'POST') { let body = ''; req.on('data', (chunk: Buffer) => { body += chunk.toString(); }); req.on('end', () => { try { const data = JSON.parse(body); const tokens: AuthTokens = { access_token: data.access_token || '', refresh_token: data.refresh_token || '', expires_at: Math.floor(Date.now() / 1000) + (data.expires_in || 3600), user_id: data.user?.id || '', team_id: '', email: data.user?.email || '', }; res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true })); clearTimeout(timeout); server.close(); saveAuthTokens(team.supabase_url, tokens); resolve(tokens); } catch (err: any) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: err.message })); } }); return; } res.writeHead(404); res.end('Not found'); }); server.listen(AUTH_CALLBACK_PORT, '127.0.0.1', () => { const authUrl = buildAuthUrl(team.supabase_url, AUTH_CALLBACK_PORT); console.log(`\nOpening browser for authentication...`); console.log(`If the browser doesn't open, visit:\n ${authUrl}\n`); openBrowser(authUrl); }); server.on('error', (err: any) => { clearTimeout(timeout); if (err.code === 'EADDRINUSE') { reject(new Error(`Port ${AUTH_CALLBACK_PORT} is in use. Close the other process and try again.`)); } else { reject(err); } }); }); } /** Build the Supabase auth URL with localhost callback. */ function buildAuthUrl(supabaseUrl: string, port: number): string { const redirectTo = `http://localhost:${port}/auth/callback`; return `${supabaseUrl}/auth/v1/authorize?provider=github&redirect_to=${encodeURIComponent(redirectTo)}`; } /** Open a URL in the default browser. */ function openBrowser(url: string): void { const { spawnSync } = require('child_process'); // macOS if (process.platform === 'darwin') { spawnSync('open', [url], { stdio: 'ignore' }); return; } // Linux if (process.platform === 'linux') { spawnSync('xdg-open', [url], { stdio: 'ignore' }); return; } // Windows if (process.platform === 'win32') { spawnSync('cmd', ['/c', 'start', url], { stdio: 'ignore' }); } } /** HTML page that extracts tokens from URL hash and POSTs them to the local server. */ function authCallbackHTML(port: number): string { return ` gstack auth

Completing authentication...

Extracting tokens...

`; } /** HTML page shown after successful auth. */ function authSuccessHTML(): string { return ` gstack auth

Authenticated!

You can close this tab and return to your terminal.

`; } /** * Check if the current auth token is expired (or will expire within 5 minutes). */ export function isTokenExpired(tokens: AuthTokens): boolean { if (!tokens.expires_at) return false; // env-var tokens don't expire const buffer = 300; // 5-minute buffer return Math.floor(Date.now() / 1000) >= tokens.expires_at - buffer; }