diff --git a/lambda/qbo-lookup/index.ts b/lambda/qbo-lookup/index.ts index 025d3b5..f3f3221 100644 --- a/lambda/qbo-lookup/index.ts +++ b/lambda/qbo-lookup/index.ts @@ -200,7 +200,7 @@ export const handler = async (event: BedrockActionEvent): Promise = { + '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): APIGatewayResponse { - return { statusCode: 302, headers: { Location: url }, body: '' }; +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: { 'Content-Type': 'text/html' }, + headers: { ...SECURITY_HEADERS, 'Content-Type': 'text/html' }, body: ` ${title} — Sea Haven Industries @@ -52,10 +65,30 @@ function html(title: string, message: string): APIGatewayResponse { }; } +// ── 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 ────────────────────────────────── function handleConnect(): APIGatewayResponse { - // Generate a simple state parameter for CSRF protection const state = crypto.randomUUID(); const params = new URLSearchParams({ @@ -66,18 +99,34 @@ function handleConnect(): APIGatewayResponse { state, }); - return redirect(`${AUTHORIZE_URL}?${params.toString()}`); + return redirect( + `${AUTHORIZE_URL}?${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 { code, realmId } = query; + 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) { - return html('Connection Failed', 'Missing authorization code or company ID from Intuit. Please try connecting again.'); + console.error('OAuth callback: missing code or realmId'); + return redirect('/qbo/launch?error=missing_params', clearCookie); } const credentials = Buffer.from(`${QBO_CLIENT_ID}:${QBO_CLIENT_SECRET}`).toString('base64'); @@ -97,9 +146,8 @@ async function handleCallback( }); if (!tokenRes.ok) { - const err = await tokenRes.text(); - console.error('Token exchange failed:', err); - return html('Connection Failed', 'Could not exchange authorization code for tokens. Please try again.'); + 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 { @@ -122,8 +170,8 @@ async function handleCallback( }), ); - console.log('QBO OAuth tokens stored successfully for realmId:', realmId); - return redirect('/qbo/launch'); + console.log('QBO OAuth: tokens stored successfully'); + return redirect('/qbo/launch', clearCookie); } // ── /qbo/disconnect — revoke token and clear secret ────────────────────────── @@ -155,8 +203,8 @@ async function handleDisconnect(): Promise { }, body: JSON.stringify({ token: refreshToken }), }); - } catch (err) { - console.error('Token revocation failed (non-fatal):', err); + } catch { + console.error('OAuth disconnect: token revocation failed (non-fatal)'); } // Clear the stored secret @@ -179,9 +227,20 @@ async function handleDisconnect(): Promise { ); } -// ── /qbo/launch — success landing page ─────────────────────────────────────── +// ── /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.'); + } -function handleLaunch(): APIGatewayResponse { return html( 'Connected', 'Your QuickBooks account is connected to Sea Haven Industries. You can close this window.', @@ -193,17 +252,18 @@ function handleLaunch(): APIGatewayResponse { 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); + return handleCallback(query, cookies); case '/qbo/disconnect': return handleDisconnect(); case '/qbo/launch': - return handleLaunch(); + return handleLaunch(query); default: - return { statusCode: 404, body: 'Not found' }; + return { statusCode: 404, headers: SECURITY_HEADERS, body: 'Not found' }; } };