diff --git a/lambda/qbo-lookup/index.ts b/lambda/qbo-lookup/index.ts index 08eb9f9..025d3b5 100644 --- a/lambda/qbo-lookup/index.ts +++ b/lambda/qbo-lookup/index.ts @@ -1,4 +1,4 @@ -import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager'; +import { SecretsManagerClient, GetSecretValueCommand, PutSecretValueCommand } from '@aws-sdk/client-secrets-manager'; const secretsClient = new SecretsManagerClient({}); @@ -96,7 +96,20 @@ async function refreshAccessToken(secret: QBOSecret): Promise { throw new Error(`QBO token refresh failed: ${res.status} ${res.statusText}`); } - const data = (await res.json()) as { access_token: string }; + const data = (await res.json()) as { access_token: string; refresh_token: string }; + + // Intuit rotates the refresh token on each use — persist it so it doesn't expire + if (data.refresh_token && data.refresh_token !== secret.refreshToken) { + secret.refreshToken = data.refresh_token; + cachedSecret = secret; + await secretsClient.send( + new PutSecretValueCommand({ + SecretId: process.env.QBO_SECRET_ARN!, + SecretString: JSON.stringify(secret), + }), + ); + } + return data.access_token; } diff --git a/lambda/qbo-oauth/index.ts b/lambda/qbo-oauth/index.ts new file mode 100644 index 0000000..498dd5e --- /dev/null +++ b/lambda/qbo-oauth/index.ts @@ -0,0 +1,209 @@ +import { + SecretsManagerClient, + GetSecretValueCommand, + PutSecretValueCommand, +} from '@aws-sdk/client-secrets-manager'; + +const secretsClient = new SecretsManagerClient({}); + +const QBO_SECRET_ARN = process.env.QBO_SECRET_ARN!; +const QBO_CLIENT_ID = process.env.QBO_CLIENT_ID!; +const QBO_CLIENT_SECRET = process.env.QBO_CLIENT_SECRET!; +const REDIRECT_URI = process.env.REDIRECT_URI!; // https://bot.seahaven.com/qbo/callback + +// Intuit OAuth endpoints +const AUTHORIZE_URL = 'https://appcenter.intuit.com/connect/oauth2'; +const TOKEN_URL = 'https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer'; +const REVOKE_URL = 'https://developer.api.intuit.com/v2/oauth2/tokens/revoke'; + +// Scopes needed for vendor queries +const SCOPES = 'com.intuit.quickbooks.accounting'; + +interface APIGatewayEvent { + requestContext: { http: { method: string; path: string } }; + queryStringParameters?: Record; + headers: Record; +} + +interface APIGatewayResponse { + statusCode: number; + headers?: Record; + body: string; +} + +function redirect(url: string): APIGatewayResponse { + return { statusCode: 302, headers: { Location: url }, body: '' }; +} + +function html(title: string, message: string): APIGatewayResponse { + return { + statusCode: 200, + headers: { 'Content-Type': 'text/html' }, + body: ` + +${title} — Sea Haven Industries + +

${title}

${message}

`, + }; +} + +// ── /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({ + client_id: QBO_CLIENT_ID, + response_type: 'code', + scope: SCOPES, + redirect_uri: REDIRECT_URI, + state, + }); + + return redirect(`${AUTHORIZE_URL}?${params.toString()}`); +} + +// ── /qbo/callback — exchange code for tokens, store in Secrets Manager ─────── + +async function handleCallback( + query: Record, +): Promise { + const { code, realmId } = query; + + if (!code || !realmId) { + return html('Connection Failed', 'Missing authorization code or company ID from Intuit. Please try connecting again.'); + } + + const credentials = Buffer.from(`${QBO_CLIENT_ID}:${QBO_CLIENT_SECRET}`).toString('base64'); + + const tokenRes = await fetch(TOKEN_URL, { + 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) { + 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.'); + } + + 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: QBO_CLIENT_ID, + clientSecret: QBO_CLIENT_SECRET, + refreshToken: tokens.refresh_token, + realmId, + }), + }), + ); + + console.log('QBO OAuth tokens stored successfully for realmId:', realmId); + return redirect('/qbo/launch'); +} + +// ── /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 credentials = Buffer.from(`${QBO_CLIENT_ID}:${QBO_CLIENT_SECRET}`).toString('base64'); + + try { + await fetch(REVOKE_URL, { + method: 'POST', + headers: { + Authorization: `Basic ${credentials}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ token: refreshToken }), + }); + } catch (err) { + console.error('Token revocation failed (non-fatal):', err); + } + + // Clear the stored secret + await secretsClient.send( + new PutSecretValueCommand({ + SecretId: QBO_SECRET_ARN, + SecretString: JSON.stringify({ + clientId: QBO_CLIENT_ID, + clientSecret: QBO_CLIENT_SECRET, + refreshToken: '', + realmId: '', + }), + }), + ); + } + + return html( + 'Disconnected', + 'Your QuickBooks account has been disconnected from Sea Haven Industries. You can reconnect at any time.', + ); +} + +// ── /qbo/launch — success landing page ─────────────────────────────────────── + +function handleLaunch(): APIGatewayResponse { + 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 ?? {}; + + switch (path) { + case '/qbo/connect': + return handleConnect(); + case '/qbo/callback': + return handleCallback(query); + case '/qbo/disconnect': + return handleDisconnect(); + case '/qbo/launch': + return handleLaunch(); + default: + return { statusCode: 404, body: 'Not found' }; + } +}; diff --git a/lib/constructs/bedrock-agent.ts b/lib/constructs/bedrock-agent.ts index 27cd23d..d907272 100644 --- a/lib/constructs/bedrock-agent.ts +++ b/lib/constructs/bedrock-agent.ts @@ -54,6 +54,7 @@ export class BedrockAgentConstruct extends Construct { bundling, }); qboSecret.grantRead(this.qboLambda); + qboSecret.grantWrite(this.qboLambda); // ── Google Maps lookup action group Lambda ──────────────────────────────── this.mapsLambda = new lambdaNodejs.NodejsFunction(this, 'MapsLookupFn', { diff --git a/lib/constructs/slack-handler.ts b/lib/constructs/slack-handler.ts index 09703f3..634d212 100644 --- a/lib/constructs/slack-handler.ts +++ b/lib/constructs/slack-handler.ts @@ -105,6 +105,45 @@ export class SlackHandlerConstruct extends Construct { integration: new HttpLambdaIntegration('WebhookIntegration', this.webhookLambda), }); + // ── QBO OAuth Lambda ───────────────────────────────────────────────────── + // Handles /qbo/connect, /qbo/callback, /qbo/disconnect, /qbo/launch + const qboSecret = secretsmanager.Secret.fromSecretNameV2( + this, 'QBOSecret', 'seahaven/qbo/oauth', + ); + + const qboOAuthLambda = new lambdaNodejs.NodejsFunction(this, 'QBOOAuthFn', { + functionName: 'seahaven-qbo-oauth', + entry: path.join(__dirname, '../../lambda/qbo-oauth/index.ts'), + handler: 'handler', + runtime: lambda.Runtime.NODEJS_22_X, + timeout: cdk.Duration.seconds(15), + memorySize: 256, + environment: { + QBO_SECRET_ARN: qboSecret.secretArn, + QBO_CLIENT_ID: '{{resolve:secretsmanager:seahaven/qbo/oauth:SecretString:clientId}}', + QBO_CLIENT_SECRET: '{{resolve:secretsmanager:seahaven/qbo/oauth:SecretString:clientSecret}}', + REDIRECT_URI: 'https://bot.seahaven.com/qbo/callback', + }, + bundling: { + externalModules: ['@aws-sdk/*'], + minify: true, + sourceMap: false, + }, + }); + + qboSecret.grantRead(qboOAuthLambda); + qboSecret.grantWrite(qboOAuthLambda); + + const qboOAuthIntegration = new HttpLambdaIntegration('QBOOAuthIntegration', qboOAuthLambda); + + for (const qboPath of ['/qbo/connect', '/qbo/callback', '/qbo/disconnect', '/qbo/launch']) { + this.api.addRoutes({ + path: qboPath, + methods: [apigatewayv2.HttpMethod.GET], + integration: qboOAuthIntegration, + }); + } + // ── Custom domain: bot.seahaven.com ─────────────────────────────────────── const certificate = acm.Certificate.fromCertificateArn( this, 'WildcardCert', props.wildcardCertArn,