From 9463a07e50686c4f201a2f8de7100464a0ad21e7 Mon Sep 17 00:00:00 2001 From: Adam Moussa <166072409+amoussa1229@users.noreply.github.com> Date: Mon, 13 Apr 2026 19:59:23 -0400 Subject: [PATCH] Use Intuit discovery document for OAuth endpoints Fetch authorization, token, and revocation endpoints from Intuit's .well-known/openid_configuration at runtime instead of hardcoding. Cached per Lambda instance for performance. --- lambda/qbo-lookup/index.ts | 17 ++++++++++++++++- lambda/qbo-oauth/index.ts | 34 ++++++++++++++++++++++++++-------- 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/lambda/qbo-lookup/index.ts b/lambda/qbo-lookup/index.ts index f3f3221..b626a9e 100644 --- a/lambda/qbo-lookup/index.ts +++ b/lambda/qbo-lookup/index.ts @@ -66,6 +66,20 @@ interface QBOVendor { let cachedSecret: QBOSecret | undefined; +// Intuit discovery document — token endpoint resolved at runtime +const DISCOVERY_URL = 'https://developer.api.intuit.com/.well-known/openid_configuration'; +let cachedTokenEndpoint: string | undefined; + +async function getTokenEndpoint(): Promise { + if (!cachedTokenEndpoint) { + const res = await fetch(DISCOVERY_URL); + if (!res.ok) throw new Error(`Discovery fetch failed: ${res.status}`); + const doc = (await res.json()) as { token_endpoint: string }; + cachedTokenEndpoint = doc.token_endpoint; + } + return cachedTokenEndpoint; +} + async function getQBOSecret(): Promise { if (!cachedSecret) { const res = await secretsClient.send( @@ -77,9 +91,10 @@ async function getQBOSecret(): Promise { } async function refreshAccessToken(secret: QBOSecret): Promise { + const tokenEndpoint = await getTokenEndpoint(); const credentials = Buffer.from(`${secret.clientId}:${secret.clientSecret}`).toString('base64'); - const res = await fetch('https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer', { + const res = await fetch(tokenEndpoint, { method: 'POST', headers: { Authorization: `Basic ${credentials}`, diff --git a/lambda/qbo-oauth/index.ts b/lambda/qbo-oauth/index.ts index 67311be..8a94211 100644 --- a/lambda/qbo-oauth/index.ts +++ b/lambda/qbo-oauth/index.ts @@ -11,10 +11,25 @@ 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'; +// Intuit discovery document — endpoints resolved at runtime per Intuit requirements +const DISCOVERY_URL = 'https://developer.api.intuit.com/.well-known/openid_configuration'; + +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'; @@ -88,7 +103,8 @@ function clearStateCookie(): string { // ── /qbo/connect — redirect to Intuit OAuth ────────────────────────────────── -function handleConnect(): APIGatewayResponse { +async function handleConnect(): Promise { + const discovery = await getDiscovery(); const state = crypto.randomUUID(); const params = new URLSearchParams({ @@ -100,7 +116,7 @@ function handleConnect(): APIGatewayResponse { }); return redirect( - `${AUTHORIZE_URL}?${params.toString()}`, + `${discovery.authorization_endpoint}?${params.toString()}`, [makeStateCookie(state)], ); } @@ -129,9 +145,10 @@ async function handleCallback( return redirect('/qbo/launch?error=missing_params', clearCookie); } + const discovery = await getDiscovery(); const credentials = Buffer.from(`${QBO_CLIENT_ID}:${QBO_CLIENT_SECRET}`).toString('base64'); - const tokenRes = await fetch(TOKEN_URL, { + const tokenRes = await fetch(discovery.token_endpoint, { method: 'POST', headers: { Authorization: `Basic ${credentials}`, @@ -191,10 +208,11 @@ async function handleDisconnect(): Promise { // Revoke the token at Intuit if we have one if (refreshToken) { + const discovery = await getDiscovery(); const credentials = Buffer.from(`${QBO_CLIENT_ID}:${QBO_CLIENT_SECRET}`).toString('base64'); try { - await fetch(REVOKE_URL, { + await fetch(discovery.revocation_endpoint, { method: 'POST', headers: { Authorization: `Basic ${credentials}`,