Add QBO OAuth endpoints for QuickBooks app listing
Adds /qbo/connect, /qbo/callback, /qbo/disconnect, and /qbo/launch routes to bot.seahaven.com for Intuit app store compliance. Also updates qbo-lookup to persist rotated refresh tokens automatically.
This commit is contained in:
parent
fb026dfd72
commit
acfe8185a9
4 changed files with 264 additions and 2 deletions
|
|
@ -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({});
|
const secretsClient = new SecretsManagerClient({});
|
||||||
|
|
||||||
|
|
@ -96,7 +96,20 @@ async function refreshAccessToken(secret: QBOSecret): Promise<string> {
|
||||||
throw new Error(`QBO token refresh failed: ${res.status} ${res.statusText}`);
|
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;
|
return data.access_token;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
209
lambda/qbo-oauth/index.ts
Normal file
209
lambda/qbo-oauth/index.ts
Normal file
|
|
@ -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<string, string>;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface APIGatewayResponse {
|
||||||
|
statusCode: number;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
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: `<!DOCTYPE html>
|
||||||
|
<html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>${title} — Sea Haven Industries</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #f8f8f8; color: #333; }
|
||||||
|
.card { background: #fff; border-radius: 12px; padding: 3rem; max-width: 480px; text-align: center; box-shadow: 0 2px 12px rgba(0,0,0,.08); }
|
||||||
|
h1 { font-size: 1.5rem; margin: 0 0 1rem; }
|
||||||
|
p { color: #666; line-height: 1.6; margin: 0; }
|
||||||
|
</style></head>
|
||||||
|
<body><div class="card"><h1>${title}</h1><p>${message}</p></div></body></html>`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── /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<string, string>,
|
||||||
|
): Promise<APIGatewayResponse> {
|
||||||
|
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<APIGatewayResponse> {
|
||||||
|
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<APIGatewayResponse> => {
|
||||||
|
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' };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -54,6 +54,7 @@ export class BedrockAgentConstruct extends Construct {
|
||||||
bundling,
|
bundling,
|
||||||
});
|
});
|
||||||
qboSecret.grantRead(this.qboLambda);
|
qboSecret.grantRead(this.qboLambda);
|
||||||
|
qboSecret.grantWrite(this.qboLambda);
|
||||||
|
|
||||||
// ── Google Maps lookup action group Lambda ────────────────────────────────
|
// ── Google Maps lookup action group Lambda ────────────────────────────────
|
||||||
this.mapsLambda = new lambdaNodejs.NodejsFunction(this, 'MapsLookupFn', {
|
this.mapsLambda = new lambdaNodejs.NodejsFunction(this, 'MapsLookupFn', {
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,45 @@ export class SlackHandlerConstruct extends Construct {
|
||||||
integration: new HttpLambdaIntegration('WebhookIntegration', this.webhookLambda),
|
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 ───────────────────────────────────────
|
// ── Custom domain: bot.seahaven.com ───────────────────────────────────────
|
||||||
const certificate = acm.Certificate.fromCertificateArn(
|
const certificate = acm.Certificate.fromCertificateArn(
|
||||||
this, 'WildcardCert', props.wildcardCertArn,
|
this, 'WildcardCert', props.wildcardCertArn,
|
||||||
|
|
|
||||||
Reference in a new issue