fix: improve auth and request handling
This commit is contained in:
+132
-27
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user