diff --git a/lib/auth.js b/lib/auth.js index d7922ac..f9916b1 100644 --- a/lib/auth.js +++ b/lib/auth.js @@ -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, }; diff --git a/lib/sheets.js b/lib/sheets.js index c9ec31e..6959b39 100644 --- a/lib/sheets.js +++ b/lib/sheets.js @@ -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 || {}); } }