2026-04-13 19:31:13 -04:00
|
|
|
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
|
|
|
|
|
|
2026-04-13 19:59:23 -04:00
|
|
|
// Intuit discovery document — endpoints resolved at runtime per Intuit requirements
|
|
|
|
|
const DISCOVERY_URL = 'https://developer.api.intuit.com/.well-known/openid_configuration';
|
|
|
|
|
|
2026-04-13 20:22:21 -04:00
|
|
|
// 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<QBOClientCreds> {
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 19:59:23 -04:00
|
|
|
interface DiscoveryDocument {
|
|
|
|
|
authorization_endpoint: string;
|
|
|
|
|
token_endpoint: string;
|
|
|
|
|
revocation_endpoint: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let cachedDiscovery: DiscoveryDocument | undefined;
|
|
|
|
|
|
|
|
|
|
async function getDiscovery(): Promise<DiscoveryDocument> {
|
|
|
|
|
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;
|
|
|
|
|
}
|
2026-04-13 19:31:13 -04:00
|
|
|
|
|
|
|
|
// Scopes needed for vendor queries
|
|
|
|
|
const SCOPES = 'com.intuit.quickbooks.accounting';
|
|
|
|
|
|
2026-04-13 19:49:29 -04:00
|
|
|
// Security headers applied to every response (Intuit requires no-cache, no-store)
|
|
|
|
|
const SECURITY_HEADERS: Record<string, string> = {
|
|
|
|
|
'Cache-Control': 'no-cache, no-store',
|
|
|
|
|
'Pragma': 'no-cache',
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-13 19:31:13 -04:00
|
|
|
interface APIGatewayEvent {
|
|
|
|
|
requestContext: { http: { method: string; path: string } };
|
|
|
|
|
queryStringParameters?: Record<string, string>;
|
|
|
|
|
headers: Record<string, string>;
|
2026-04-13 19:49:29 -04:00
|
|
|
cookies?: string[];
|
2026-04-13 19:31:13 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface APIGatewayResponse {
|
|
|
|
|
statusCode: number;
|
|
|
|
|
headers?: Record<string, string>;
|
2026-04-13 19:49:29 -04:00
|
|
|
cookies?: string[];
|
2026-04-13 19:31:13 -04:00
|
|
|
body: string;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 19:49:29 -04:00
|
|
|
function redirect(url: string, cookies?: string[]): APIGatewayResponse {
|
|
|
|
|
return {
|
|
|
|
|
statusCode: 302,
|
|
|
|
|
headers: { ...SECURITY_HEADERS, Location: url },
|
|
|
|
|
...(cookies && { cookies }),
|
|
|
|
|
body: '',
|
|
|
|
|
};
|
2026-04-13 19:31:13 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function html(title: string, message: string): APIGatewayResponse {
|
|
|
|
|
return {
|
|
|
|
|
statusCode: 200,
|
2026-04-13 19:49:29 -04:00
|
|
|
headers: { ...SECURITY_HEADERS, 'Content-Type': 'text/html' },
|
2026-04-13 19:31:13 -04:00
|
|
|
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>`,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 19:49:29 -04:00
|
|
|
// ── Cookie helpers for CSRF state ────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
function parseCookies(cookieHeaders: string[] | undefined): Record<string, string> {
|
|
|
|
|
const cookies: Record<string, string> = {};
|
|
|
|
|
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';
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 19:31:13 -04:00
|
|
|
// ── /qbo/connect — redirect to Intuit OAuth ──────────────────────────────────
|
|
|
|
|
|
2026-04-13 19:59:23 -04:00
|
|
|
async function handleConnect(): Promise<APIGatewayResponse> {
|
2026-04-13 20:22:21 -04:00
|
|
|
const [discovery, creds] = await Promise.all([getDiscovery(), getClientCreds()]);
|
2026-04-13 19:31:13 -04:00
|
|
|
const state = crypto.randomUUID();
|
|
|
|
|
|
|
|
|
|
const params = new URLSearchParams({
|
2026-04-13 20:22:21 -04:00
|
|
|
client_id: creds.clientId,
|
2026-04-13 19:31:13 -04:00
|
|
|
response_type: 'code',
|
|
|
|
|
scope: SCOPES,
|
|
|
|
|
redirect_uri: REDIRECT_URI,
|
|
|
|
|
state,
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-13 19:49:29 -04:00
|
|
|
return redirect(
|
2026-04-13 19:59:23 -04:00
|
|
|
`${discovery.authorization_endpoint}?${params.toString()}`,
|
2026-04-13 19:49:29 -04:00
|
|
|
[makeStateCookie(state)],
|
|
|
|
|
);
|
2026-04-13 19:31:13 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── /qbo/callback — exchange code for tokens, store in Secrets Manager ───────
|
2026-04-13 19:49:29 -04:00
|
|
|
// Per Intuit sensitive-info requirement: this endpoint receives tokens in URL
|
|
|
|
|
// params, so it must NEVER return HTML — always 302 redirect.
|
2026-04-13 19:31:13 -04:00
|
|
|
|
|
|
|
|
async function handleCallback(
|
|
|
|
|
query: Record<string, string>,
|
2026-04-13 19:49:29 -04:00
|
|
|
cookies: Record<string, string>,
|
2026-04-13 19:31:13 -04:00
|
|
|
): Promise<APIGatewayResponse> {
|
2026-04-13 19:49:29 -04:00
|
|
|
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);
|
|
|
|
|
}
|
2026-04-13 19:31:13 -04:00
|
|
|
|
|
|
|
|
if (!code || !realmId) {
|
2026-04-13 19:49:29 -04:00
|
|
|
console.error('OAuth callback: missing code or realmId');
|
|
|
|
|
return redirect('/qbo/launch?error=missing_params', clearCookie);
|
2026-04-13 19:31:13 -04:00
|
|
|
}
|
|
|
|
|
|
2026-04-13 20:22:21 -04:00
|
|
|
const [discovery, creds] = await Promise.all([getDiscovery(), getClientCreds()]);
|
|
|
|
|
const credentials = Buffer.from(`${creds.clientId}:${creds.clientSecret}`).toString('base64');
|
2026-04-13 19:31:13 -04:00
|
|
|
|
2026-04-13 19:59:23 -04:00
|
|
|
const tokenRes = await fetch(discovery.token_endpoint, {
|
2026-04-13 19:31:13 -04:00
|
|
|
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) {
|
2026-04-13 19:49:29 -04:00
|
|
|
console.error('OAuth callback: token exchange failed with status', tokenRes.status);
|
|
|
|
|
return redirect('/qbo/launch?error=token_exchange', clearCookie);
|
2026-04-13 19:31:13 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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({
|
2026-04-13 20:22:21 -04:00
|
|
|
clientId: creds.clientId,
|
|
|
|
|
clientSecret: creds.clientSecret,
|
2026-04-13 19:31:13 -04:00
|
|
|
refreshToken: tokens.refresh_token,
|
|
|
|
|
realmId,
|
|
|
|
|
}),
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
|
2026-04-13 19:49:29 -04:00
|
|
|
console.log('QBO OAuth: tokens stored successfully');
|
|
|
|
|
return redirect('/qbo/launch', clearCookie);
|
2026-04-13 19:31:13 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── /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) {
|
2026-04-13 20:22:21 -04:00
|
|
|
const [discovery, creds] = await Promise.all([getDiscovery(), getClientCreds()]);
|
|
|
|
|
const credentials = Buffer.from(`${creds.clientId}:${creds.clientSecret}`).toString('base64');
|
2026-04-13 19:31:13 -04:00
|
|
|
|
|
|
|
|
try {
|
2026-04-13 19:59:23 -04:00
|
|
|
await fetch(discovery.revocation_endpoint, {
|
2026-04-13 19:31:13 -04:00
|
|
|
method: 'POST',
|
|
|
|
|
headers: {
|
|
|
|
|
Authorization: `Basic ${credentials}`,
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
Accept: 'application/json',
|
|
|
|
|
},
|
|
|
|
|
body: JSON.stringify({ token: refreshToken }),
|
|
|
|
|
});
|
2026-04-13 19:49:29 -04:00
|
|
|
} catch {
|
|
|
|
|
console.error('OAuth disconnect: token revocation failed (non-fatal)');
|
2026-04-13 19:31:13 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Clear the stored secret
|
|
|
|
|
await secretsClient.send(
|
|
|
|
|
new PutSecretValueCommand({
|
|
|
|
|
SecretId: QBO_SECRET_ARN,
|
|
|
|
|
SecretString: JSON.stringify({
|
2026-04-13 20:22:21 -04:00
|
|
|
clientId: creds.clientId,
|
|
|
|
|
clientSecret: creds.clientSecret,
|
2026-04-13 19:31:13 -04:00
|
|
|
refreshToken: '',
|
|
|
|
|
realmId: '',
|
|
|
|
|
}),
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return html(
|
|
|
|
|
'Disconnected',
|
|
|
|
|
'Your QuickBooks account has been disconnected from Sea Haven Industries. You can reconnect at any time.',
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-13 19:49:29 -04:00
|
|
|
// ── /qbo/launch — success/error landing page ────────────────────────────────
|
|
|
|
|
|
|
|
|
|
function handleLaunch(query: Record<string, string>): APIGatewayResponse {
|
|
|
|
|
const error = query['error'];
|
|
|
|
|
|
|
|
|
|
if (error) {
|
|
|
|
|
const messages: Record<string, string> = {
|
|
|
|
|
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.');
|
|
|
|
|
}
|
2026-04-13 19:31:13 -04:00
|
|
|
|
|
|
|
|
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 ?? {};
|
2026-04-13 19:49:29 -04:00
|
|
|
const cookies = parseCookies(event.cookies);
|
2026-04-13 19:31:13 -04:00
|
|
|
|
|
|
|
|
switch (path) {
|
|
|
|
|
case '/qbo/connect':
|
|
|
|
|
return handleConnect();
|
|
|
|
|
case '/qbo/callback':
|
2026-04-13 19:49:29 -04:00
|
|
|
return handleCallback(query, cookies);
|
2026-04-13 19:31:13 -04:00
|
|
|
case '/qbo/disconnect':
|
|
|
|
|
return handleDisconnect();
|
|
|
|
|
case '/qbo/launch':
|
2026-04-13 19:49:29 -04:00
|
|
|
return handleLaunch(query);
|
2026-04-13 19:31:13 -04:00
|
|
|
default:
|
2026-04-13 19:49:29 -04:00
|
|
|
return { statusCode: 404, headers: SECURITY_HEADERS, body: 'Not found' };
|
2026-04-13 19:31:13 -04:00
|
|
|
}
|
|
|
|
|
};
|