Files
NetMesh/electron/bridges/onedriveAuthBridge.cjs

347 lines
12 KiB
JavaScript
Raw Permalink Normal View History

/**
* OneDrive OAuth + Graph Bridge (main process)
*
* Renderer fetches to Microsoft token/Graph endpoints can be blocked by CORS.
* This bridge proxies token exchange/refresh and Graph requests via main process.
*/
const ONEDRIVE_TOKEN_URL = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token";
const ONEDRIVE_GRAPH_API = "https://graph.microsoft.com/v1.0";
const APP_FOLDER_PATH = "/drive/special/approot";
const DEFAULT_SYNC_FILE_NAME = "netcatty-vault.json";
const DEFAULT_SCOPE =
"https://graph.microsoft.com/Files.ReadWrite.AppFolder https://graph.microsoft.com/User.Read offline_access";
// Stable marker prefixed onto refresh errors when Microsoft says the refresh
// token itself is dead (expired/revoked/consent withdrawn). Only IPC error
// `message` survives the bridge boundary, so the renderer keys off this string
// to flip OneDrive into a "needs reconnect" state instead of retrying forever.
const ONEDRIVE_REAUTH_REQUIRED_MARKER = "ONEDRIVE_REAUTH_REQUIRED";
// OAuth2 error codes that mean the refresh token can no longer be used and the
// user must sign in again (vs. a transient/server error worth retrying).
// https://learn.microsoft.com/azure/active-directory/develop/reference-error-codes
const REFRESH_REAUTH_ERROR_CODES = new Set([
"invalid_grant", // expired/revoked refresh token (the common #1189 case)
"interaction_required",
"consent_required",
"login_required",
]);
const isNonEmptyString = (v) => typeof v === "string" && v.trim().length > 0;
const safeJsonParse = (text) => {
try {
return JSON.parse(text);
} catch {
return null;
}
};
const describeNetworkError = (err) => {
const msg = err?.message || String(err);
const cause = err?.cause;
const causeCode = cause?.code ? ` (${cause.code})` : "";
const causeMsg = cause?.message ? `: ${cause.message}` : "";
return `${msg}${causeCode}${causeMsg}`.trim();
};
const base64FromArrayBuffer = (buffer) => {
return Buffer.from(buffer).toString("base64");
};
/**
* @param {Electron.IpcMain} ipcMain
* @param {import('electron')=} electronModule
*/
function registerHandlers(ipcMain, electronModule) {
const fetchImpl =
electronModule?.net?.fetch ? electronModule.net.fetch.bind(electronModule.net) : fetch;
ipcMain.handle("netcatty:onedrive:oauth:exchange", async (_event, payload) => {
const clientId = payload?.clientId;
const code = payload?.code;
const codeVerifier = payload?.codeVerifier;
const redirectUri = payload?.redirectUri;
const scope = isNonEmptyString(payload?.scope) ? payload.scope : DEFAULT_SCOPE;
if (!isNonEmptyString(clientId)) throw new Error("Missing OneDrive clientId");
if (!isNonEmptyString(code)) throw new Error("Missing authorization code");
if (!isNonEmptyString(codeVerifier)) throw new Error("Missing codeVerifier");
if (!isNonEmptyString(redirectUri)) throw new Error("Missing redirectUri");
const body = new URLSearchParams({
client_id: clientId,
code,
code_verifier: codeVerifier,
grant_type: "authorization_code",
redirect_uri: redirectUri,
scope,
});
let res;
try {
res = await fetchImpl(ONEDRIVE_TOKEN_URL, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded",
},
body: body.toString(),
});
} catch (err) {
throw new Error(
`OneDrive token exchange network error. Check your network/VPN and whether Microsoft services are reachable. (${describeNetworkError(err)})`
);
}
const text = await res.text();
if (!res.ok) {
const data = safeJsonParse(text) || { error: text };
throw new Error(
`OneDrive token exchange failed: ${data.error_description || data.error || res.status}`
);
}
const data = safeJsonParse(text);
if (!data) {
throw new Error(`OneDrive token exchange invalid JSON: ${text.slice(0, 200)}`);
}
return {
accessToken: data.access_token,
refreshToken: data.refresh_token,
expiresAt: Date.now() + (data.expires_in || 0) * 1000,
tokenType: data.token_type,
scope: data.scope,
};
});
ipcMain.handle("netcatty:onedrive:oauth:refresh", async (_event, payload) => {
const clientId = payload?.clientId;
const refreshToken = payload?.refreshToken;
const scope = isNonEmptyString(payload?.scope) ? payload.scope : DEFAULT_SCOPE;
if (!isNonEmptyString(clientId)) throw new Error("Missing OneDrive clientId");
if (!isNonEmptyString(refreshToken)) throw new Error("Missing refreshToken");
const body = new URLSearchParams({
client_id: clientId,
refresh_token: refreshToken,
grant_type: "refresh_token",
scope,
});
let res;
try {
res = await fetchImpl(ONEDRIVE_TOKEN_URL, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/x-www-form-urlencoded",
},
body: body.toString(),
});
} catch (err) {
throw new Error(
`OneDrive token refresh network error. Check your network/VPN and whether Microsoft services are reachable. (${describeNetworkError(err)})`
);
}
const text = await res.text();
if (!res.ok) {
const data = safeJsonParse(text) || { error: text };
const reauthRequired = REFRESH_REAUTH_ERROR_CODES.has(data.error);
const description = data.error_description || data.error || res.status;
throw new Error(
reauthRequired
? `${ONEDRIVE_REAUTH_REQUIRED_MARKER}: OneDrive session expired, please reconnect. (${description})`
: `OneDrive token refresh failed: ${description}`
);
}
const data = safeJsonParse(text);
if (!data) {
throw new Error(`OneDrive token refresh invalid JSON: ${text.slice(0, 200)}`);
}
return {
accessToken: data.access_token,
refreshToken: data.refresh_token || refreshToken,
expiresAt: Date.now() + (data.expires_in || 0) * 1000,
tokenType: data.token_type,
scope: data.scope,
};
});
ipcMain.handle("netcatty:onedrive:oauth:userinfo", async (_event, payload) => {
const accessToken = payload?.accessToken;
if (!isNonEmptyString(accessToken)) throw new Error("Missing accessToken");
let res;
try {
res = await fetchImpl(`${ONEDRIVE_GRAPH_API}/me`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
} catch (err) {
throw new Error(
`OneDrive userinfo network error. Check your network/VPN and whether Microsoft services are reachable. (${describeNetworkError(err)})`
);
}
const text = await res.text();
if (!res.ok) {
throw new Error(`OneDrive userinfo failed: ${res.status} - ${text.slice(0, 200)}`);
}
const user = safeJsonParse(text);
if (!user) {
throw new Error(`OneDrive userinfo invalid JSON: ${text.slice(0, 200)}`);
}
let avatarDataUrl;
try {
const photoRes = await fetchImpl(`${ONEDRIVE_GRAPH_API}/me/photo/$value`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (photoRes.ok) {
const buffer = await photoRes.arrayBuffer();
const contentType = photoRes.headers.get("content-type") || "image/jpeg";
avatarDataUrl = `data:${contentType};base64,${base64FromArrayBuffer(buffer)}`;
}
} catch {
// Ignore photo errors
}
return {
id: user.id,
email: user.mail || user.userPrincipalName,
name: user.displayName,
avatarDataUrl,
};
});
ipcMain.handle("netcatty:onedrive:drive:findSyncFile", async (_event, payload) => {
const accessToken = payload?.accessToken;
const fileName = isNonEmptyString(payload?.fileName) ? payload.fileName : DEFAULT_SYNC_FILE_NAME;
if (!isNonEmptyString(accessToken)) throw new Error("Missing accessToken");
const url = `${ONEDRIVE_GRAPH_API}/me${APP_FOLDER_PATH}:/${encodeURIComponent(fileName)}`;
const res = await fetchImpl(url, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (res.status === 404) {
return { fileId: null };
}
if (!res.ok) {
const text = await res.text();
throw new Error(`OneDrive find sync file failed: ${res.status} - ${text.slice(0, 200)}`);
}
const item = await res.json();
return { fileId: item?.id || null };
});
ipcMain.handle("netcatty:onedrive:drive:uploadSyncFile", async (_event, payload) => {
const accessToken = payload?.accessToken;
const fileName = isNonEmptyString(payload?.fileName) ? payload.fileName : DEFAULT_SYNC_FILE_NAME;
const syncedFile = payload?.syncedFile;
if (!isNonEmptyString(accessToken)) throw new Error("Missing accessToken");
if (!syncedFile) throw new Error("Missing syncedFile");
const url = `${ONEDRIVE_GRAPH_API}/me${APP_FOLDER_PATH}:/${encodeURIComponent(fileName)}:/content`;
const res = await fetchImpl(url, {
method: "PUT",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(syncedFile, null, 2),
});
const text = await res.text();
if (!res.ok) {
throw new Error(`OneDrive upload failed: ${res.status} - ${text.slice(0, 200)}`);
}
const item = safeJsonParse(text) || {};
return { fileId: item.id || null };
});
ipcMain.handle("netcatty:onedrive:drive:downloadSyncFile", async (_event, payload) => {
const accessToken = payload?.accessToken;
const fileId = payload?.fileId;
const fileName = isNonEmptyString(payload?.fileName) ? payload.fileName : DEFAULT_SYNC_FILE_NAME;
if (!isNonEmptyString(accessToken)) throw new Error("Missing accessToken");
const itemUrl = fileId
? `${ONEDRIVE_GRAPH_API}/me/drive/items/${fileId}`
: `${ONEDRIVE_GRAPH_API}/me${APP_FOLDER_PATH}:/${encodeURIComponent(fileName)}`;
const itemRes = await fetchImpl(itemUrl, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (itemRes.status === 404) {
return { syncedFile: null };
}
const itemText = await itemRes.text();
if (!itemRes.ok) {
throw new Error(`OneDrive download item failed: ${itemRes.status} - ${itemText.slice(0, 200)}`);
}
const item = safeJsonParse(itemText);
if (!item) {
throw new Error(`OneDrive download item invalid JSON: ${itemText.slice(0, 200)}`);
}
const downloadUrl = item["@microsoft.graph.downloadUrl"];
if (!isNonEmptyString(downloadUrl)) {
throw new Error("OneDrive download item missing @microsoft.graph.downloadUrl");
}
const downloadRes = await fetchImpl(downloadUrl);
const downloadText = await downloadRes.text();
if (!downloadRes.ok) {
throw new Error(
`OneDrive download content failed: ${downloadRes.status} - ${downloadText.slice(0, 200)}`
);
}
const data = safeJsonParse(downloadText);
if (!data) {
throw new Error(`OneDrive download invalid JSON: ${downloadText.slice(0, 200)}`);
}
return { syncedFile: data };
});
ipcMain.handle("netcatty:onedrive:drive:deleteSyncFile", async (_event, payload) => {
const accessToken = payload?.accessToken;
const fileId = payload?.fileId;
if (!isNonEmptyString(accessToken)) throw new Error("Missing accessToken");
if (!isNonEmptyString(fileId)) throw new Error("Missing fileId");
const url = `${ONEDRIVE_GRAPH_API}/me/drive/items/${fileId}`;
const res = await fetchImpl(url, {
method: "DELETE",
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok && res.status !== 404) {
const text = await res.text();
throw new Error(`OneDrive delete failed: ${res.status} - ${text.slice(0, 200)}`);
}
return { ok: true };
});
}
module.exports = {
registerHandlers,
ONEDRIVE_REAUTH_REQUIRED_MARKER,
REFRESH_REAUTH_ERROR_CODES,
};