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.
This commit is contained in:
Adam Moussa 2026-04-13 19:59:23 -04:00
parent 066ff59d22
commit 9463a07e50
2 changed files with 42 additions and 9 deletions

View file

@ -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<string> {
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<QBOSecret> {
if (!cachedSecret) {
const res = await secretsClient.send(
@ -77,9 +91,10 @@ async function getQBOSecret(): Promise<QBOSecret> {
}
async function refreshAccessToken(secret: QBOSecret): Promise<string> {
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}`,

View file

@ -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<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;
}
// 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<APIGatewayResponse> {
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<APIGatewayResponse> {
// 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}`,