Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
312 changes: 215 additions & 97 deletions node_helper.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,58 @@
const NodeHelper = require("node_helper");
const { google } = require("googleapis");
const https = require("https");
const { encodeQueryData, formatError } = require("./helpers");
const fs = require("fs");
const path = require("path");
const Log = require("logger");

const TOKEN_FILE_NAME = "token.json";
const CREDENTIALS_FILE_NAME = "credentials.json";
const OAUTH_TOKEN_HOST = "oauth2.googleapis.com";
const CALENDAR_API_HOST = "www.googleapis.com";

// The recurring calendar fetch (getAccessToken/fetchCalendar below) talks to Google
// directly over Node's `https` module instead of through `googleapis`/`gaxios`.
// `gaxios` (google-auth-library's transport) only uses native `fetch` when a browser
// `window` global exists; with no `window` - i.e. `node_helper.js` running inside
// Electron's main process under `npm run start:wayland` - it falls back to the
// `node-fetch` npm package, and that combination reliably fails the OAuth token
// refresh call with "Invalid response body ... Premature close" (100% reproducible,
// confirmed identical requests succeed instantly via plain `node`/system Node outside
// Electron). Same bug independently diagnosed for MMM-GoogleSDM - see that module's
// CLAUDE.md/node_helper.js for the sibling fix. The one-time interactive auth-code
// exchange below (authenticate/authenticateWeb, only used when token.json is missing)
// still goes through `googleapis` and could hit the same issue if ever triggered.
function httpsRequestJSON(options, body) {
return new Promise((resolve, reject) => {
const req = https.request(options, (res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => {
let parsed;
try {
parsed = data ? JSON.parse(data) : {};
} catch (err) {
reject(new Error(`Could not parse response from ${options.host}: ${err.message}`));
return;
}
if (res.statusCode < 200 || res.statusCode >= 300) {
const err = new Error(
parsed?.error_description || parsed?.error?.message || parsed?.error || `HTTP ${res.statusCode}`
);
err.statusCode = res.statusCode;
err.errorCode = typeof parsed?.error === "string" ? parsed.error : parsed?.error?.status;
reject(err);
return;
}
resolve(parsed);
});
});
req.on("error", reject);
if (body) req.write(body);
req.end();
});
}

module.exports = NodeHelper.create({
// Override start method.
Expand Down Expand Up @@ -117,52 +163,88 @@ module.exports = NodeHelper.create({
return;
}

_this.oAuth2Client = new google.auth.OAuth2(
// Authorization-code-for-token exchange, done over raw `https` for the same
// reason as getAccessToken()/fetchCalendar() above: `google.auth.OAuth2#getToken()`
// goes through gaxios, which reliably fails with "Premature close" inside
// Electron's main process. This is the last runtime call that used to go
// through `googleapis` - see the httpsRequestJSON comment up top, and
// CLAUDE.md in this module's folder, for the full story.
const redirect_uri = redirect_uris ? redirect_uris[0] : "http://localhost:8080";
const body = new URLSearchParams({
client_id,
client_secret,
redirect_uris ? redirect_uris[0] : "http://localhost:8080" // Default redirect URI
);
code,
grant_type: "authorization_code",
redirect_uri
}).toString();

httpsRequestJSON(
{
host: OAUTH_TOKEN_HOST,
path: "/token",
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Content-Length": Buffer.byteLength(body)
}
},
body
)
.then((token) => {
// getAccessToken() (used for every subsequent fetch) expects token.json to
// be self-contained - client_id/client_secret stored alongside the refresh_token,
// not just the bare token response - so it never needs credentials.json again.
// Google only returns a refresh_token on the first consent grant (or with
// prompt=consent); fall back to whatever is already on disk so a re-auth
// without a fresh refresh_token doesn't wipe out a working one.
fs.readFile(path.join(_this.path, TOKEN_FILE_NAME), (readErr, existingContent) => {
let existing = {};
if (!readErr) {
try {
existing = JSON.parse(existingContent);
} catch (e) {
existing = {};
}
}

const toStore = {
type: "authorized_user",
client_id,
client_secret,
refresh_token: token.refresh_token || existing.refresh_token
};

_this.oAuth2Client.getToken(code, (err, token) => {
if (err) {
if (!toStore.refresh_token) {
Log.warn(`${_this.name}: Google did not return a refresh_token and none was already stored; re-auth with prompt=consent may be required.`);
}

fs.writeFile(
path.join(_this.path, TOKEN_FILE_NAME),
JSON.stringify(toStore),
(writeFileErr) => {
if (writeFileErr) {
// Log the error, but don't send AUTH_FAILED here as the token was successfully retrieved.
return console.error(
`${_this.name}: Error writing token file:`,
writeFileErr
);
}
console.log(
`${_this.name}: Token stored to`,
path.join(_this.path, TOKEN_FILE_NAME)
);
}
);

callback(null, _this);
});
})
.catch((err) => {
console.error(`${_this.name}: Error retrieving access token`, err);
_this.sendSocketNotification("AUTH_FAILED", {
error_type: "ERROR_TOKEN_EXCHANGE"
});
return;
}
_this.oAuth2Client.setCredentials(token);
_this.oAuth2Client.on("tokens", (newTokens) => {
fs.readFile(path.join(_this.path, TOKEN_FILE_NAME), (readErr, content) => {
if (readErr) return;
try {
const merged = { ...JSON.parse(content), ...newTokens };
fs.writeFile(path.join(_this.path, TOKEN_FILE_NAME), JSON.stringify(merged), () => {});
} catch (e) {
Log.error(`${_this.name}: Error persisting refreshed token`, e);
}
});
});
// Store the token to disk for later program executions
fs.writeFile(
path.join(_this.path, TOKEN_FILE_NAME),
JSON.stringify(token),
(writeFileErr) => {
if (writeFileErr) {
// Log the error, but don't send AUTH_FAILED here as the token was successfully retrieved.
return console.error(
`${_this.name}: Error writing token file:`,
writeFileErr
);
}
console.log(
`${_this.name}: Token stored to`,
path.join(_this.path, TOKEN_FILE_NAME)
);
}
);
callback(_this.oAuth2Client, _this);
});
},

// Authenticate oAuth credentials
Expand Down Expand Up @@ -271,17 +353,50 @@ module.exports = NodeHelper.create({
}
},

/**
* Check for data.error from API response
* @param {object} request - The request object, expected to contain response.data.error
* @returns {string | undefined} The error code in uppercase or undefined.
*/
checkForHTTPError: function (request) {
return request?.response?.data?.error?.toUpperCase();
// Reads the stored refresh token and exchanges it for a fresh access token,
// talking to Google directly over `https` (see comment near httpsRequestJSON above).
getAccessToken: function () {
return new Promise((resolve, reject) => {
fs.readFile(path.join(this.path, TOKEN_FILE_NAME), (err, content) => {
if (err) {
reject(err);
return;
}
let token;
try {
token = JSON.parse(content);
} catch (parseErr) {
reject(parseErr);
return;
}

const body = new URLSearchParams({
client_id: token.client_id,
client_secret: token.client_secret,
refresh_token: token.refresh_token,
grant_type: "refresh_token"
}).toString();

httpsRequestJSON(
{
host: OAUTH_TOKEN_HOST,
path: "/token",
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Content-Length": Buffer.byteLength(body)
}
},
body
)
.then((res) => resolve(res.access_token))
.catch(reject);
});
});
},

startCalendarService: function (auth, _this) {
_this.calendarService = google.calendar({ version: "v3", auth });
_this.calendarService = true;
_this.sendSocketNotification("SERVICE_READY", {});
},

Expand All @@ -305,58 +420,62 @@ module.exports = NodeHelper.create({
) {
if (!this.calendarService) return;

this.calendarService.events.list(
{
calendarId: calendarID,
timeMin: new Date(
new Date().setDate(new Date().getDate() - pastDaysCount)
).toISOString(),
timeMax: new Date(
new Date().setDate(new Date().getDate() + maximumNumberOfDays)
).toISOString(),
maxResults: maximumEntries,
singleEvents: true,
orderBy: "startTime"
},
(err, res) => {
// Arrow function for callback
if (err) {
Log.error(
`${this.name} Error. Could not fetch calendar: `,
calendarID,
formatError(err)
);
let errorType = NodeHelper.checkFetchError(err);
if (errorType === "MODULE_ERROR_UNSPECIFIED") {
errorType = this.checkForHTTPError(err) || errorType;
}

if (
errorType === "INVALID_GRANT" ||
err?.response?.status === 401 ||
err?.message?.toLowerCase().includes("invalid_grant")
) {
Log.warn(`${this.name}: Token invalid or revoked, clearing token and requesting re-auth`);
this.calendarService = null;
fs.unlink(path.join(this.path, TOKEN_FILE_NAME), () => {});
this.authenticate();
return;
}

// send error to module
this.sendSocketNotification("CALENDAR_ERROR", {
id: identifier,
error_type: errorType
});
} else {
const events = res.data.items;
const queryParams = {
timeMin: new Date(
new Date().setDate(new Date().getDate() - pastDaysCount)
).toISOString(),
timeMax: new Date(
new Date().setDate(new Date().getDate() + maximumNumberOfDays)
).toISOString(),
maxResults: maximumEntries,
singleEvents: true,
orderBy: "startTime"
};

this.getAccessToken()
.then((accessToken) => httpsRequestJSON({
host: CALENDAR_API_HOST,
path: `/calendar/v3/calendars/${encodeURIComponent(calendarID)}/events?${encodeQueryData(queryParams)}`,
method: "GET",
headers: { Authorization: `Bearer ${accessToken}` }
}))
.then((res) => {
const events = res.items || [];
Log.info(
`${this.name}: ${events.length} events loaded for ${calendarID}`
);
this.broadcastEvents(events, identifier, calendarID);
})
.catch((err) => {
Log.error(
`${this.name} Error. Could not fetch calendar: `,
calendarID,
formatError(err)
);
let errorType = NodeHelper.checkFetchError(err);
if (errorType === "MODULE_ERROR_UNSPECIFIED" && err.errorCode) {
errorType = String(err.errorCode).toUpperCase();
}

Log.info(
`${this.name}: ${events.length} events loaded for ${calendarID}`
);
this.broadcastEvents(events, identifier, calendarID);
if (
err.errorCode === "invalid_grant" ||
err.statusCode === 401 ||
(err.message && err.message.toLowerCase().includes("invalid_grant"))
) {
Log.warn(`${this.name}: Token invalid or revoked, clearing token and requesting re-auth`);
this.calendarService = null;
fs.unlink(path.join(this.path, TOKEN_FILE_NAME), () => {});
this.authenticate();
return;
}

// send error to module
this.sendSocketNotification("CALENDAR_ERROR", {
id: identifier,
error_type: errorType
});
})
.finally(() => {
this.scheduleNextCalendarFetch(
calendarID,
fetchInterval,
Expand All @@ -365,8 +484,7 @@ module.exports = NodeHelper.create({
maximumNumberOfDays,
identifier
);
}
);
});
},

scheduleNextCalendarFetch: function (
Expand Down