Files
gstack/lib/auth.ts
Garry Tan 3713c3b9b9 feat: add team sync infrastructure (config, auth, push/pull, CLI)
- lib/sync-config.ts: reads .gstack-sync.json + ~/.gstack/auth.json
- lib/auth.ts: device auth flow (browser OAuth, local HTTP callback)
- lib/sync.ts: Supabase push/pull via raw fetch(), offline queue, cache
- lib/cli-sync.ts: CLI handler for gstack-sync commands
- bin/gstack-sync: bash wrapper (setup, status, push-*, pull, drain)
- .gstack-sync.json.example: template for team setup

Zero new dependencies — uses raw fetch() against PostgREST API.
All sync is non-fatal with 5s timeout and offline queue fallback.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 02:02:40 -05:00

212 lines
7.0 KiB
TypeScript

/**
* 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<AuthTokens> {
return new Promise<AuthTokens>((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 `<!DOCTYPE html>
<html>
<head><title>gstack auth</title></head>
<body>
<h2>Completing authentication...</h2>
<p id="status">Extracting tokens...</p>
<script>
const hash = window.location.hash.substring(1);
const params = new URLSearchParams(hash);
const data = {
access_token: params.get('access_token'),
refresh_token: params.get('refresh_token'),
expires_in: parseInt(params.get('expires_in') || '3600'),
user: { id: '', email: '' }
};
if (data.access_token) {
fetch('http://localhost:${port}/auth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
}).then(() => {
document.getElementById('status').textContent = 'Authenticated! You can close this tab.';
}).catch(err => {
document.getElementById('status').textContent = 'Error: ' + err.message;
});
} else {
document.getElementById('status').textContent = 'No tokens found in URL. Please try again.';
}
</script>
</body>
</html>`;
}
/** HTML page shown after successful auth. */
function authSuccessHTML(): string {
return `<!DOCTYPE html>
<html>
<head><title>gstack auth</title></head>
<body>
<h2>Authenticated!</h2>
<p>You can close this tab and return to your terminal.</p>
</body>
</html>`;
}
/**
* 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;
}