/** * PKCE Authorization Flow implementation for Berget * Based on RFC 7636 - Proof Key for Code Exchange * * This flow is preferred for desktop environments where a local * callback server can be started. It works better with magic link * authentication than device flow. */ import { spawn } from 'node:child_process'; import * as crypto from 'node:crypto'; import * as http from 'node:http'; import * as url from 'node:url'; import type { AuthOAuthResult, AuthorizeResult } from './types'; import { getKeycloakRealm, getKeycloakUrl, KEYCLOAK_CLIENT_ID, PKCE_CALLBACK_PORT, } from '../constants'; import { logDebug } from './debug'; /** * Creates the OAuth authorize method using PKCE flow * This is called when user selects "Use Berget Code plan" in OpenCode */ export function createPkceAuthorizeMethod(): ( inputs?: Record, ) => Promise { return executePkceAuthorization; } /** * Exchanges authorization code for tokens */ export async function exchangeCodeForTokens( code: string, codeVerifier: string, redirectUri: string, ): Promise { const tokenUrl = `${getKeycloakUrl()}/realms/${getKeycloakRealm()}/protocol/openid-connect/token`; logDebug(`Exchanging code for tokens at ${tokenUrl}`); const response = await fetch(tokenUrl, { body: new URLSearchParams({ client_id: KEYCLOAK_CLIENT_ID, code, code_verifier: codeVerifier, grant_type: 'authorization_code', redirect_uri: redirectUri, }).toString(), headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, method: 'POST', }); if (!response.ok) { const errorText = await response.text(); logDebug(`Token exchange failed: ${errorText}`); return { error: `Failed to exchange code for tokens: ${errorText}`, type: 'failed', }; } const tokenData = (await response.json()) as Record; if ( typeof tokenData.access_token !== 'string' || typeof tokenData.expires_in !== 'number' || typeof tokenData.refresh_token !== 'string' ) { logDebug('Token exchange returned malformed body'); return { error: 'Invalid token response from authorization server', type: 'failed', }; } const expires = Date.now() + tokenData.expires_in * 1000; logDebug('Successfully obtained tokens via PKCE'); return { access: tokenData.access_token, expires, refresh: tokenData.refresh_token, type: 'success', }; } /** * Generate a random string for PKCE code_verifier */ export function generateCodeVerifier(): string { const bytes = new Uint8Array(32); crypto.webcrypto.getRandomValues(bytes); return Buffer.from(bytes).toString('base64url'); } /** * Handles the OAuth callback request */ export async function handleCallbackRequest( response: http.ServerResponse, server: http.Server, parsedUrl: url.UrlWithParsedQuery, state: string, codeVerifier: string, redirectUri: string, resolve: (value: AuthOAuthResult | PromiseLike) => void, ): Promise { const receivedState = parsedUrl.query.state as string; const code = parsedUrl.query.code as string; const error = parsedUrl.query.error as string; const errorDescription = parsedUrl.query.error_description as string | undefined; if (error) { const displayMessage = errorDescription ? `${error}: ${errorDescription}` : error; writeHtmlResponse(response, buildHtmlResponse(false, displayMessage)); server.close(); resolve({ error: `Authentication failed: ${displayMessage}`, type: 'failed', }); return; } if (receivedState !== state) { writeHtmlResponse(response, buildHtmlResponse(false, 'Invalid state parameter')); server.close(); resolve({ error: 'Invalid state parameter. Please try again.', type: 'failed', }); return; } if (!code) { writeHtmlResponse(response, buildHtmlResponse(false, 'No authorization code received')); server.close(); resolve({ error: 'No authorization code received.', type: 'failed', }); return; } // Exchange code for tokens writeHtmlResponse( response, buildHtmlResponse(true, 'You can close this window and return to OpenCode.'), ); server.close(); const result = await exchangeCodeForTokens(code, codeVerifier, redirectUri); resolve(result); } /** * Builds the HTML response for the callback page */ function buildHtmlResponse(success: boolean, message: string): string { const gradient = success ? 'linear-gradient(135deg, #4ade80 0%, #22c55e 100%)' : 'linear-gradient(135deg, #f87171 0%, #ef4444 100%)'; const shadow = success ? 'rgba(74, 222, 128, 0.3)' : 'rgba(248, 113, 113, 0.3)'; const icon = success ? '' : ''; const title = success ? 'Authentication Successful' : 'Authentication Failed'; return ` Berget - ${title}
${icon}

${title}

${message}

BERGET
`; } /** * Creates a local HTTP server to receive the OAuth callback */ function createCallbackServerPromise( state: string, codeVerifier: string, redirectUri: string, ): Promise { return new Promise((resolve) => { const server = http.createServer(async (request, response) => { const parsedUrl = url.parse(request.url || '', true); if (parsedUrl.pathname !== '/callback') return; await handleCallbackRequest( response, server, parsedUrl, state, codeVerifier, redirectUri, resolve, ); }); server.on('error', (error: NodeJS.ErrnoException) => { handleServerError(error, resolve); }); server.listen(PKCE_CALLBACK_PORT, '127.0.0.1', () => { logDebug(`Callback server listening on 127.0.0.1:${PKCE_CALLBACK_PORT}`); }); // Timeout after 5 minutes setTimeout( () => { server.close(); resolve({ error: 'Authentication timed out. Please try again.', type: 'failed', }); }, 5 * 60 * 1000, ); }); } async function executePkceAuthorization( _inputs?: Record, ): Promise { const isHeadless = isHeadlessEnvironment(); if (isHeadless) { logDebug('Headless environment detected - PKCE flow may not work'); // In headless mode, we could fall back to device flow // For now, we'll still try PKCE but warn the user } // Generate PKCE parameters const codeVerifier = generateCodeVerifier(); const codeChallenge = generateCodeChallenge(codeVerifier); const state = generateRandomHex(16); const redirectUri = `http://localhost:${PKCE_CALLBACK_PORT}/callback`; // Build authorization URL — guarded so a malformed BERGET_API_URL surfaces // as a clear error instead of a raw TypeError let authUrl: URL; try { authUrl = new URL( `${getKeycloakUrl()}/realms/${getKeycloakRealm()}/protocol/openid-connect/auth`, ); } catch { throw new Error( `Invalid Keycloak URL "${getKeycloakUrl()}" — check the BERGET_API_URL environment variable`, ); } authUrl.searchParams.set('client_id', KEYCLOAK_CLIENT_ID); authUrl.searchParams.set('response_type', 'code'); authUrl.searchParams.set('redirect_uri', redirectUri); authUrl.searchParams.set('scope', 'openid email profile offline_access'); authUrl.searchParams.set('state', state); authUrl.searchParams.set('code_challenge', codeChallenge); authUrl.searchParams.set('code_challenge_method', 'S256'); logDebug(`Authorization URL: ${authUrl.toString()}`); // Open browser if (!isHeadless) { openBrowserUrl(authUrl.toString()); } return { callback: async (): Promise => { return createCallbackServerPromise(state, codeVerifier, redirectUri); }, instructions: isHeadless ? `Open the URL above in your browser to sign in.\n\nNote: PKCE flow requires a browser on this machine.` : `Complete the sign-in flow in your browser. The page should have opened automatically.`, method: 'auto' as const, url: authUrl.toString(), }; } /** * Generate code_challenge from code_verifier using S256 method */ function generateCodeChallenge(verifier: string): string { return crypto.createHash('sha256').update(verifier).digest('base64url'); } /** * Generate a random hex string using Web Crypto (non-blocking, idiomatic in Node 18+) */ function generateRandomHex(byteLength: number): string { const bytes = new Uint8Array(byteLength); crypto.webcrypto.getRandomValues(bytes); return Buffer.from(bytes).toString('hex'); } /** * Handles server startup errors */ function handleServerError( error: NodeJS.ErrnoException, resolve: (value: AuthOAuthResult | PromiseLike) => void, ): void { if (error.code === 'EADDRINUSE') { logDebug(`Port ${PKCE_CALLBACK_PORT} is already in use`); resolve({ error: `Port ${PKCE_CALLBACK_PORT} is already in use. Another OpenCode login may be in progress. Please wait and try again, or close other OpenCode sessions.`, type: 'failed', }); return; } logDebug(`Server error: ${error.message}`); resolve({ error: `Failed to start callback server: ${error.message}`, type: 'failed', }); } /** * Checks if running in a headless environment */ function isHeadlessEnvironment(): boolean { return !!( process.env.SSH_CONNECTION || process.env.SSH_CLIENT || process.env.SSH_TTY || process.env.OPENCODE_HEADLESS || process.env.CI ); } /** * Opens a URL in the user's default browser */ function openBrowserUrl(urlString: string): void { try { const platform = process.platform; let command: string; let arguments_: string[]; if (platform === 'darwin') { command = 'open'; arguments_ = [urlString]; } else if (platform === 'win32') { command = 'rundll32'; arguments_ = ['url.dll,FileProtocolHandler', urlString]; } else { command = 'xdg-open'; arguments_ = [urlString]; } const child = spawn(command, arguments_, { detached: true, stdio: 'ignore', }); child.unref?.(); logDebug(`Opened browser with command: ${command}`); } catch (error) { logDebug(`Failed to open browser: ${error}`); } } /** * Sends an HTML response with cache-control headers appropriate for OAuth callbacks */ function writeHtmlResponse(response: http.ServerResponse, html: string): void { response.writeHead(200, { 'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate', 'Content-Type': 'text/html', Expires: '0', Pragma: 'no-cache', }); response.end(html); }