import type { IncomingMessage, ServerResponse } from 'node:http'; import http from 'node:http'; import type { Context } from '@yaakapp/api'; export const HOSTED_CALLBACK_URL = 'https://oauth.yaak.app/redirect'; export const DEFAULT_LOCALHOST_PORT = 8765; const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes /** Singleton: only one callback server runs at a time across all OAuth flows. */ let activeServer: CallbackServerResult | null = null; export interface CallbackServerResult { /** The port the server is listening on */ port: number; /** The full redirect URI to register with the OAuth provider */ redirectUri: string; /** Promise that resolves with the callback URL when received */ waitForCallback: () => Promise; /** Stop the server */ stop: () => void; } /** * Start a local HTTP server to receive OAuth callbacks. * Only one server runs at a time — if a previous server is still active, * it is stopped before starting the new one. * Returns the port, redirect URI, and a promise that resolves when the callback is received. */ export function startCallbackServer(options: { /** Specific port to use, or 0 for random available port */ port?: number; /** Path for the callback endpoint */ path?: string; /** Timeout in milliseconds (default 5 minutes) */ timeoutMs?: number; }): Promise { // Stop any previously active server before starting a new one if (activeServer) { console.log('[oauth2] Stopping previous callback server before starting new one'); activeServer.stop(); activeServer = null; } const { port = 0, path = '/callback', timeoutMs = CALLBACK_TIMEOUT_MS } = options; return new Promise((resolve, reject) => { let callbackResolve: ((url: string) => void) | null = null; let callbackReject: ((err: Error) => void) | null = null; let timeoutHandle: ReturnType | null = null; let stopped = false; const server = http.createServer((req: IncomingMessage, res: ServerResponse) => { const reqUrl = new URL(req.url ?? '/', `http://${req.headers.host}`); // Only handle the callback path if (reqUrl.pathname !== path && reqUrl.pathname !== `${path}/`) { res.writeHead(404, { 'Content-Type': 'text/plain' }); res.end('Not Found'); return; } if (req.method === 'POST') { // POST: read JSON body with the final callback URL and resolve let body = ''; req.on('data', (chunk: Buffer) => { body += chunk.toString(); }); req.on('end', () => { try { const { url: callbackUrl } = JSON.parse(body); if (!callbackUrl || typeof callbackUrl !== 'string') { res.writeHead(400, { 'Content-Type': 'text/plain' }); res.end('Missing url in request body'); return; } // Send success response res.writeHead(200, { 'Content-Type': 'text/plain' }); res.end('OK'); // Resolve the callback promise if (callbackResolve) { callbackResolve(callbackUrl); callbackResolve = null; callbackReject = null; } // Stop the server after a short delay to ensure response is sent setTimeout(() => stopServer(), 100); } catch { res.writeHead(400, { 'Content-Type': 'text/plain' }); res.end('Invalid JSON'); } }); return; } // GET: serve intermediate page that reads the fragment and POSTs back res.writeHead(200, { 'Content-Type': 'text/html' }); res.end(getFragmentForwardingHtml()); }); server.on('error', (err: Error) => { if (!stopped) { reject(err); } }); const stopServer = () => { if (stopped) return; stopped = true; // Clear the singleton reference if (activeServer?.stop === stopServer) { activeServer = null; } if (timeoutHandle) { clearTimeout(timeoutHandle); timeoutHandle = null; } server.close(); if (callbackReject) { callbackReject(new Error('Callback server stopped')); callbackResolve = null; callbackReject = null; } }; server.listen(port, '127.0.0.1', () => { const address = server.address(); if (!address || typeof address === 'string') { reject(new Error('Failed to get server address')); return; } const actualPort = address.port; const redirectUri = `http://127.0.0.1:${actualPort}${path}`; console.log(`[oauth2] Callback server listening on ${redirectUri}`); const result: CallbackServerResult = { port: actualPort, redirectUri, waitForCallback: () => { return new Promise((res, rej) => { if (stopped) { rej(new Error('Callback server already stopped')); return; } callbackResolve = res; callbackReject = rej; // Set timeout timeoutHandle = setTimeout(() => { if (callbackReject) { callbackReject(new Error('Authorization timed out')); callbackResolve = null; callbackReject = null; } stopServer(); }, timeoutMs); }); }, stop: stopServer, }; activeServer = result; resolve(result); }); }); } /** * Build the redirect URI for the hosted callback page. * The hosted page will redirect to the local server with the OAuth response. */ export function buildHostedCallbackRedirectUri(localPort: number, localPath: string): string { const localRedirectUri = `http://127.0.0.1:${localPort}${localPath}`; // The hosted callback page will read params and redirect to the local server return `${HOSTED_CALLBACK_URL}?redirect_to=${encodeURIComponent(localRedirectUri)}`; } /** * Stop the active callback server if one is running. * Called during plugin dispose to ensure the server is cleaned up before the process exits. */ export function stopActiveServer(): void { if (activeServer) { console.log('[oauth2] Stopping active callback server during dispose'); activeServer.stop(); activeServer = null; } } /** * Open an authorization URL in the system browser, start a local callback server, * and wait for the OAuth provider to redirect back. * * Returns the raw callback URL and the redirect URI that was registered with the * OAuth provider (needed for token exchange). */ export async function getRedirectUrlViaExternalBrowser( ctx: Context, authorizationUrl: URL, options: { callbackType: 'localhost' | 'hosted'; callbackPort?: number; }, ): Promise<{ callbackUrl: string; redirectUri: string }> { const { callbackType, callbackPort } = options; // Determine port based on callback type: // - localhost: use specified port or default stable port // - hosted: use random port (0) since hosted page redirects to local const port = callbackType === 'localhost' ? (callbackPort ?? DEFAULT_LOCALHOST_PORT) : 0; console.log( `[oauth2] Starting callback server (type: ${callbackType}, port: ${port || 'random'})`, ); const server = await startCallbackServer({ port, path: '/callback', }); try { // Determine the redirect URI to send to the OAuth provider let oauthRedirectUri: string; if (callbackType === 'hosted') { oauthRedirectUri = buildHostedCallbackRedirectUri(server.port, '/callback'); console.log('[oauth2] Using hosted callback redirect:', oauthRedirectUri); } else { oauthRedirectUri = server.redirectUri; console.log('[oauth2] Using localhost callback redirect:', oauthRedirectUri); } // Set the redirect URI on the authorization URL authorizationUrl.searchParams.set('redirect_uri', oauthRedirectUri); const authorizationUrlStr = authorizationUrl.toString(); console.log('[oauth2] Opening external browser:', authorizationUrlStr); // Show toast to inform user await ctx.toast.show({ message: 'Opening browser for authorization...', icon: 'info', timeout: 3000, }); // Open the system browser await ctx.window.openExternalUrl(authorizationUrlStr); // Wait for the callback console.log('[oauth2] Waiting for callback on', server.redirectUri); const callbackUrl = await server.waitForCallback(); console.log('[oauth2] Received callback:', callbackUrl); return { callbackUrl, redirectUri: oauthRedirectUri }; } finally { server.stop(); } } /** * Intermediate HTML page that reads the URL fragment and _fragment query param, * reconstructs a proper OAuth callback URL, and POSTs it back to the server. * * Handles three cases: * - Localhost implicit: fragment is in location.hash (e.g. #access_token=...) * - Hosted implicit: fragment was converted to ?_fragment=... by the hosted redirect page * - Auth code: no fragment, code is already in query params */ function getFragmentForwardingHtml(): string { return ` Yaak

Authorizing...

Please wait

`; }