fix: improve auth and request handling
build-windows-exe / build (push) Successful in 47s

This commit is contained in:
2026-07-22 17:34:13 +01:00
parent 694cc55eaa
commit c4c470c073
2 changed files with 163 additions and 56 deletions
+132 -27
View File
@@ -1,5 +1,7 @@
const fs = require('fs');
const path = require('path');
const https = require('https');
const crypto = require('crypto');
const {applyRateLimitBuffer} = require('./rate');
const {fetchSpreadsheetValuesBatch} = require('./sheets');
@@ -24,6 +26,128 @@ function resolveConfigPath(config, candidatePath) {
return path.resolve(getConfigBaseDir(config), candidatePath);
}
function base64UrlEncode(value) {
return Buffer.from(value)
.toString('base64')
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_');
}
function createServiceAccountAuthClient(credentials) {
const scopes = ['https://www.googleapis.com/auth/spreadsheets.readonly'];
const tokenUri = credentials.token_uri || 'https://oauth2.googleapis.com/token';
const tokenCache = {accessToken: '', expiryDate: 0};
async function requestAccessToken() {
if (!credentials.client_email || !credentials.private_key) {
throw new Error('Service account credentials must include client_email and private_key');
}
const now = Math.floor(Date.now() / 1000);
const header = {alg: 'RS256', typ: 'JWT'};
const payload = {
iss: credentials.client_email,
scope: scopes.join(' '),
aud: tokenUri,
iat: now,
exp: now + 3600,
};
const unsignedToken = `${base64UrlEncode(JSON.stringify(header))}.${base64UrlEncode(JSON.stringify(payload))}`;
const signer = crypto.createSign('RSA-SHA256');
signer.update(unsignedToken);
signer.end();
const signature = signer.sign(credentials.private_key);
const assertion = `${unsignedToken}.${base64UrlEncode(signature)}`;
const body = `grant_type=${encodeURIComponent('urn:ietf:params:oauth:grant-type:jwt-bearer')}&assertion=${encodeURIComponent(assertion)}`;
const tokenUrl = new URL(tokenUri);
const requestOptions = {
protocol: tokenUrl.protocol,
hostname: tokenUrl.hostname,
port: tokenUrl.port || 443,
method: 'POST',
path: `${tokenUrl.pathname}${tokenUrl.search}`,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(body),
},
};
const responseBody = await new Promise((resolve, reject) => {
const request = https.request(requestOptions, response => {
let responseText = '';
response.setEncoding('utf8');
response.on('data', chunk => {
responseText += chunk;
});
response.on('end', () => {
if (response.statusCode >= 200 && response.statusCode < 300) {
resolve(responseText);
return;
}
let message = `Token request failed with status ${response.statusCode}`;
try {
const parsed = JSON.parse(responseText);
if (parsed && parsed.error && parsed.error_description) {
message = `${parsed.error}: ${parsed.error_description}`;
} else if (parsed && parsed.error && parsed.error.message) {
message = parsed.error.message;
}
} catch (_) {
if (responseText) {
message = responseText;
}
}
reject(new Error(message));
});
});
request.on('error', reject);
request.write(body);
request.end();
});
const tokenData = JSON.parse(responseBody);
if (!tokenData.access_token) {
throw new Error('Token response did not include access_token');
}
tokenCache.accessToken = tokenData.access_token;
tokenCache.expiryDate = Date.now() + Math.max(0, (tokenData.expires_in || 3600) - 60) * 1000;
return tokenData.access_token;
}
return {
scopes,
async getAccessToken() {
if (tokenCache.accessToken && Date.now() < tokenCache.expiryDate) {
return {token: tokenCache.accessToken, expiry_date: tokenCache.expiryDate};
}
const accessToken = await requestAccessToken();
return {token: accessToken, expiry_date: tokenCache.expiryDate};
},
async getRequestHeaders() {
const token = await this.getAccessToken();
return {Authorization: `Bearer ${token.token}`};
},
};
}
function readServiceAccountCredentials(resolvedPath) {
try {
const raw = fs.readFileSync(resolvedPath, 'utf8');
return JSON.parse(raw);
} catch (e) {
throw new Error(`Failed to read/parse service account credentials at ${resolvedPath}: ${e.message}`);
}
}
function loadApiKeys(config) {
const apiKeys = [];
const defaultRateLimit = config.rateLimitPerMinute || 50;
@@ -85,21 +209,8 @@ function createServiceAccountBatchFetcher(config) {
if (!fs.existsSync(resolvedPath)) {
throw new Error(`Service account credentials not found: ${resolvedPath}`);
}
let credentials;
try {
credentials = JSON.parse(fs.readFileSync(resolvedPath, 'utf8'));
} catch (e) {
throw new Error(`Failed to read/parse service account credentials at ${resolvedPath}: ${e.message}`);
}
let authClient;
try {
authClient = google.auth.fromJSON(credentials);
} catch (e) {
throw new Error(`Failed to initialize auth client from credentials at ${resolvedPath}: ${e.message}`);
}
authClient.scopes = ['https://www.googleapis.com/auth/spreadsheets.readonly'];
const credentials = readServiceAccountCredentials(resolvedPath);
const authClient = createServiceAccountAuthClient(credentials);
const bufferedRateLimit = applyRateLimitBuffer(rateLimitPerMinute);
const interval = Math.ceil(60000 / bufferedRateLimit);
@@ -222,11 +333,8 @@ function getFirstServiceAccountAuthClient(config) {
if (!fs.existsSync(resolvedPath)) return null;
try {
const raw = fs.readFileSync(resolvedPath, 'utf8');
const credentials = JSON.parse(raw);
const authClient = google.auth.fromJSON(credentials);
authClient.scopes = ['https://www.googleapis.com/auth/spreadsheets.readonly'];
return authClient;
const credentials = readServiceAccountCredentials(resolvedPath);
return createServiceAccountAuthClient(credentials);
} catch (e) {
console.error(`Failed to load first service account from ${resolvedPath}: ${e.message}`);
return null;
@@ -242,11 +350,8 @@ function loadAuth(config) {
if (!fs.existsSync(resolvedPath)) {
throw new Error(`Credentials file not found: ${resolvedPath}`);
}
const raw = fs.readFileSync(resolvedPath, 'utf8');
const credentials = JSON.parse(raw);
const authClient = google.auth.fromJSON(credentials);
authClient.scopes = ['https://www.googleapis.com/auth/spreadsheets.readonly'];
return authClient;
const credentials = readServiceAccountCredentials(resolvedPath);
return createServiceAccountAuthClient(credentials);
}
return getFirstServiceAccountAuthClient(config);
@@ -258,6 +363,6 @@ module.exports = {
createServiceAccountBatchFetcher,
createApiKeyBatchFetcher,
getFirstApiKey,
getFirstServiceAccountAuthClient
, loadAuth
getFirstServiceAccountAuthClient,
loadAuth,
};
+31 -29
View File
@@ -45,35 +45,37 @@ function extractErrorMessage(data) {
function requestJson(urlString, headers = {}) {
return new Promise((resolve, reject) => {
const url = new URL(urlString);
const request = https.request(
url,
{
method: 'GET',
headers: Object.assign({Accept: 'application/json'}, headers),
},
response => {
let body = '';
response.setEncoding('utf8');
const requestOptions = {
protocol: url.protocol,
hostname: url.hostname,
port: url.port || 443,
method: 'GET',
path: `${url.pathname}${url.search}`,
headers: Object.assign({Accept: 'application/json'}, headers),
};
response.on('data', chunk => {
body += chunk;
});
const request = https.request(requestOptions, response => {
let body = '';
response.setEncoding('utf8');
response.on('end', () => {
const data = parseResponseBody(body);
response.on('data', chunk => {
body += chunk;
});
if (response.statusCode >= 200 && response.statusCode < 300) {
resolve({status: response.statusCode, headers: response.headers, data});
return;
}
response.on('end', () => {
const data = parseResponseBody(body);
const message = extractErrorMessage(data) || `Request failed with status ${response.statusCode}`;
const error = new Error(message);
error.response = {status: response.statusCode, headers: response.headers, data};
reject(error);
});
}
);
if (response.statusCode >= 200 && response.statusCode < 300) {
resolve({status: response.statusCode, headers: response.headers, data});
return;
}
const message = extractErrorMessage(data) || `Request failed with status ${response.statusCode}`;
const error = new Error(message);
error.response = {status: response.statusCode, headers: response.headers, data};
reject(error);
});
});
request.on('error', reject);
request.end();
@@ -96,15 +98,15 @@ async function requestSheetsJson(pathname, context, queryParams = {}) {
const headers = {};
if (context && context.authClient) {
if (typeof context.authClient.getRequestHeaders === 'function') {
const authHeaders = await context.authClient.getRequestHeaders(url.toString());
Object.assign(headers, authHeaders || {});
} else if (typeof context.authClient.getAccessToken === 'function') {
if (typeof context.authClient.getAccessToken === 'function') {
const token = await context.authClient.getAccessToken();
const accessToken = token && typeof token === 'object' ? token.token || token.access_token : token;
if (accessToken) {
headers.Authorization = `Bearer ${accessToken}`;
}
} else if (typeof context.authClient.getRequestHeaders === 'function') {
const authHeaders = await context.authClient.getRequestHeaders(url.toString());
Object.assign(headers, authHeaders || {});
}
}