import { SecretsManagerClient, GetSecretValueCommand, PutSecretValueCommand, } from '@aws-sdk/client-secrets-manager'; const secretsClient = new SecretsManagerClient({}); const QBO_SECRET_ARN = process.env.QBO_SECRET_ARN!; const REDIRECT_URI = process.env.REDIRECT_URI!; // https://bot.seahaven.com/qbo/callback // Intuit discovery document — endpoints resolved at runtime per Intuit requirements const DISCOVERY_URL = 'https://developer.api.intuit.com/.well-known/openid_configuration'; // Client credentials — read from Secrets Manager at runtime so updates // don't require a redeploy interface QBOClientCreds { clientId: string; clientSecret: string } let cachedCreds: QBOClientCreds | undefined; async function getClientCreds(): Promise { if (!cachedCreds) { const res = await secretsClient.send( new GetSecretValueCommand({ SecretId: QBO_SECRET_ARN }), ); const secret = JSON.parse(res.SecretString!); cachedCreds = { clientId: secret.clientId, clientSecret: secret.clientSecret }; } return cachedCreds; } interface DiscoveryDocument { authorization_endpoint: string; token_endpoint: string; revocation_endpoint: string; } let cachedDiscovery: DiscoveryDocument | undefined; async function getDiscovery(): Promise { if (!cachedDiscovery) { const res = await fetch(DISCOVERY_URL); if (!res.ok) throw new Error(`Discovery fetch failed: ${res.status}`); cachedDiscovery = (await res.json()) as DiscoveryDocument; } return cachedDiscovery; } // Scopes needed for vendor queries const SCOPES = 'com.intuit.quickbooks.accounting'; // Security headers applied to every response (Intuit requires no-cache, no-store) const SECURITY_HEADERS: Record = { 'Cache-Control': 'no-cache, no-store', 'Pragma': 'no-cache', }; interface APIGatewayEvent { requestContext: { http: { method: string; path: string } }; queryStringParameters?: Record; headers: Record; cookies?: string[]; } interface APIGatewayResponse { statusCode: number; headers?: Record; cookies?: string[]; body: string; } function redirect(url: string, cookies?: string[]): APIGatewayResponse { return { statusCode: 302, headers: { ...SECURITY_HEADERS, Location: url }, ...(cookies && { cookies }), body: '', }; } function html(title: string, message: string): APIGatewayResponse { return { statusCode: 200, headers: { ...SECURITY_HEADERS, 'Content-Type': 'text/html' }, body: ` ${title} — Sea Haven Industries

${title}

${message}

`, }; } // ── Cookie helpers for CSRF state ──────────────────────────────────────────── function parseCookies(cookieHeaders: string[] | undefined): Record { const cookies: Record = {}; if (!cookieHeaders) return cookies; for (const header of cookieHeaders) { const [name, ...rest] = header.split('='); if (name) cookies[name.trim()] = rest.join('=').trim(); } return cookies; } function makeStateCookie(state: string): string { // 10-minute expiry, Secure + HttpOnly per Intuit cookie requirements return `qbo_oauth_state=${state}; Max-Age=600; Path=/qbo; Secure; HttpOnly; SameSite=Lax`; } function clearStateCookie(): string { return 'qbo_oauth_state=; Max-Age=0; Path=/qbo; Secure; HttpOnly; SameSite=Lax'; } // ── /qbo/connect — redirect to Intuit OAuth ────────────────────────────────── async function handleConnect(): Promise { const [discovery, creds] = await Promise.all([getDiscovery(), getClientCreds()]); const state = crypto.randomUUID(); const params = new URLSearchParams({ client_id: creds.clientId, response_type: 'code', scope: SCOPES, redirect_uri: REDIRECT_URI, state, }); return redirect( `${discovery.authorization_endpoint}?${params.toString()}`, [makeStateCookie(state)], ); } // ── /qbo/callback — exchange code for tokens, store in Secrets Manager ─────── // Per Intuit sensitive-info requirement: this endpoint receives tokens in URL // params, so it must NEVER return HTML — always 302 redirect. async function handleCallback( query: Record, cookies: Record, ): Promise { const clearCookie = [clearStateCookie()]; // Validate CSRF state const { state, code, realmId } = query; const savedState = cookies['qbo_oauth_state']; if (!state || !savedState || state !== savedState) { console.error('OAuth callback: state mismatch'); return redirect('/qbo/launch?error=csrf', clearCookie); } if (!code || !realmId) { console.error('OAuth callback: missing code or realmId'); return redirect('/qbo/launch?error=missing_params', clearCookie); } const [discovery, creds] = await Promise.all([getDiscovery(), getClientCreds()]); const credentials = Buffer.from(`${creds.clientId}:${creds.clientSecret}`).toString('base64'); const tokenRes = await fetch(discovery.token_endpoint, { method: 'POST', headers: { Authorization: `Basic ${credentials}`, 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', }, body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: REDIRECT_URI, }).toString(), }); if (!tokenRes.ok) { console.error('OAuth callback: token exchange failed with status', tokenRes.status); return redirect('/qbo/launch?error=token_exchange', clearCookie); } const tokens = (await tokenRes.json()) as { access_token: string; refresh_token: string; expires_in: number; x_refresh_token_expires_in: number; }; // Store in Secrets Manager — same structure the qbo-lookup Lambda expects await secretsClient.send( new PutSecretValueCommand({ SecretId: QBO_SECRET_ARN, SecretString: JSON.stringify({ clientId: creds.clientId, clientSecret: creds.clientSecret, refreshToken: tokens.refresh_token, realmId, }), }), ); console.log('QBO OAuth: tokens stored successfully'); return redirect('/qbo/launch', clearCookie); } // ── /qbo/disconnect — revoke token and clear secret ────────────────────────── async function handleDisconnect(): Promise { let refreshToken: string | undefined; try { const res = await secretsClient.send( new GetSecretValueCommand({ SecretId: QBO_SECRET_ARN }), ); const secret = JSON.parse(res.SecretString!); refreshToken = secret.refreshToken; } catch { // Secret may not exist or be empty — that's fine } // Revoke the token at Intuit if we have one if (refreshToken) { const [discovery, creds] = await Promise.all([getDiscovery(), getClientCreds()]); const credentials = Buffer.from(`${creds.clientId}:${creds.clientSecret}`).toString('base64'); try { await fetch(discovery.revocation_endpoint, { method: 'POST', headers: { Authorization: `Basic ${credentials}`, 'Content-Type': 'application/json', Accept: 'application/json', }, body: JSON.stringify({ token: refreshToken }), }); } catch { console.error('OAuth disconnect: token revocation failed (non-fatal)'); } // Clear the stored secret await secretsClient.send( new PutSecretValueCommand({ SecretId: QBO_SECRET_ARN, SecretString: JSON.stringify({ clientId: creds.clientId, clientSecret: creds.clientSecret, refreshToken: '', realmId: '', }), }), ); } return html( 'Disconnected', 'Your QuickBooks account has been disconnected from Sea Haven Industries. You can reconnect at any time.', ); } // ── /qbo/launch — success/error landing page ──────────────────────────────── function handleLaunch(query: Record): APIGatewayResponse { const error = query['error']; if (error) { const messages: Record = { csrf: 'The connection request could not be verified. Please try again.', missing_params: 'Missing authorization details from Intuit. Please try connecting again.', token_exchange: 'Could not complete the connection to QuickBooks. Please try again.', }; return html('Connection Failed', messages[error] ?? 'An unexpected error occurred. Please try again.'); } return html( 'Connected', 'Your QuickBooks account is connected to Sea Haven Industries. You can close this window.', ); } // ── Router ─────────────────────────────────────────────────────────────────── export const handler = async (event: APIGatewayEvent): Promise => { const path = event.requestContext.http.path; const query = event.queryStringParameters ?? {}; const cookies = parseCookies(event.cookies); switch (path) { case '/qbo/connect': return handleConnect(); case '/qbo/callback': return handleCallback(query, cookies); case '/qbo/disconnect': return handleDisconnect(); case '/qbo/launch': return handleLaunch(query); default: return { statusCode: 404, headers: SECURITY_HEADERS, body: 'Not found' }; } };