mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
* fix(plugin-oauth): surface real error and propagate state on local errors Clicking "Connect Google Account" could show the generic "Authentication failed!" snack with no detail. Two issues caused this: 1. The catch in connectOAuth swallowed the actual error. Log it and include the message in the snack so failures are diagnosable. 2. When the Electron main process emitted a local OAuth error (invalid_auth_url, failed_to_open_browser), the IPC payload had no state, and handleRedirectError silently dropped any callback whose state did not match. Echo the state from the auth URL on the main side, and treat missing-state errors as trusted local failures on the renderer side (they are not CSRF-relevant). This rejects the pending flow immediately instead of waiting for the 5-minute timeout. * fix(plugin-oauth): apply review feedback on connect-OAuth UX - Split the try block in connectOAuth so a failure of _loadDynamicOptions no longer surfaces as an "Authentication failed" snack on top of the success snack. The OAuth connection itself succeeded; loadOptions has its own per-field error reporting. - Sanitize the surfaced error: collapse whitespace and cap to 200 chars so a long HttpErrorResponse message doesn't blow up the snack. - Log the message field instead of the raw Error, per CLAUDE.md anti-pattern #11 (log history is exportable). - Use undefined instead of null for the state echo in the main process to match the renderer/preload signatures. --------- Co-authored-by: Claude <noreply@anthropic.com>
133 lines
4.6 KiB
TypeScript
133 lines
4.6 KiB
TypeScript
import { BrowserWindow, ipcMain, shell } from 'electron';
|
||
import { createServer, Server } from 'http';
|
||
import { IPC } from './shared-with-frontend/ipc-events.const';
|
||
import { log } from 'electron-log/main';
|
||
|
||
const LOOPBACK_HOST = '127.0.0.1';
|
||
const OAUTH_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
||
|
||
let loopbackServer: Server | null = null;
|
||
let oauthTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||
|
||
const cleanupServer = (): void => {
|
||
if (oauthTimeoutId) {
|
||
clearTimeout(oauthTimeoutId);
|
||
oauthTimeoutId = null;
|
||
}
|
||
if (loopbackServer) {
|
||
loopbackServer.close();
|
||
loopbackServer = null;
|
||
}
|
||
};
|
||
|
||
// Success page shown in the user's browser after completing OAuth
|
||
const SUCCESS_HTML = `<!DOCTYPE html>
|
||
<html><head><meta charset="utf-8"><title>Super Productivity</title>
|
||
<style>body{font-family:system-ui,sans-serif;display:flex;align-items:center;
|
||
justify-content:center;height:100vh;margin:0;background:#f5f5f5}
|
||
.card{text-align:center;padding:2rem;background:#fff;border-radius:8px;
|
||
box-shadow:0 2px 8px rgba(0,0,0,.1)}</style></head>
|
||
<body><div class="card"><h2>Authentication complete</h2>
|
||
<p>You can close this tab and return to Super Productivity.</p></div></body></html>`;
|
||
|
||
export const initPluginOAuth = (mainWin: BrowserWindow): void => {
|
||
// Prepare: start a loopback HTTP server and return the port.
|
||
// Google Desktop OAuth requires http://127.0.0.1:<port> redirect URIs
|
||
// and blocks embedded webviews, so we open the system browser instead.
|
||
ipcMain.handle(IPC.PLUGIN_OAUTH_PREPARE, async (): Promise<{ port: number }> => {
|
||
cleanupServer();
|
||
|
||
return new Promise<{ port: number }>((resolve, reject) => {
|
||
let handled = false;
|
||
|
||
const server = createServer((req, res) => {
|
||
if (handled) {
|
||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||
res.end(SUCCESS_HTML);
|
||
return;
|
||
}
|
||
handled = true;
|
||
|
||
const url = new URL(req.url!, `http://${LOOPBACK_HOST}`);
|
||
const code = url.searchParams.get('code');
|
||
const error = url.searchParams.get('error');
|
||
const state = url.searchParams.get('state');
|
||
|
||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||
res.end(SUCCESS_HTML);
|
||
|
||
mainWin.webContents.send(IPC.PLUGIN_OAUTH_CB, { code, error, state });
|
||
|
||
// Re-focus the main window after auth completes
|
||
if (!mainWin.isDestroyed()) {
|
||
mainWin.show();
|
||
mainWin.focus();
|
||
}
|
||
|
||
cleanupServer();
|
||
});
|
||
|
||
server.listen(0, LOOPBACK_HOST, () => {
|
||
const addr = server.address();
|
||
if (addr && typeof addr !== 'string') {
|
||
loopbackServer = server;
|
||
oauthTimeoutId = setTimeout(() => {
|
||
log('Plugin OAuth: Timeout – closing abandoned loopback server');
|
||
cleanupServer();
|
||
}, OAUTH_TIMEOUT_MS);
|
||
log(`Plugin OAuth: Loopback server listening on port ${addr.port}`);
|
||
resolve({ port: addr.port });
|
||
} else {
|
||
server.close();
|
||
reject(new Error('Failed to start OAuth loopback server'));
|
||
}
|
||
});
|
||
|
||
server.on('error', (err) => {
|
||
reject(err);
|
||
});
|
||
});
|
||
});
|
||
|
||
// Open the auth URL in the system browser (not an embedded webview).
|
||
// Google blocks OAuth in embedded browsers (Electron BrowserWindow).
|
||
ipcMain.on(IPC.PLUGIN_OAUTH_START, (_ev: unknown, { url }: { url: string }) => {
|
||
log('Plugin OAuth: Opening system browser for auth');
|
||
|
||
// Validate URL protocol before opening to prevent file:// or javascript: abuse.
|
||
// Echo back the state param so the renderer can match the error to the
|
||
// pending flow (state validation in handleRedirectError).
|
||
let state: string | undefined;
|
||
try {
|
||
const parsed = new URL(url);
|
||
state = parsed.searchParams.get('state') ?? undefined;
|
||
if (parsed.protocol !== 'https:') {
|
||
log('Plugin OAuth: Rejected non-https auth URL:', parsed.protocol);
|
||
mainWin.webContents.send(IPC.PLUGIN_OAUTH_CB, {
|
||
error: 'invalid_auth_url',
|
||
state,
|
||
});
|
||
cleanupServer();
|
||
return;
|
||
}
|
||
} catch {
|
||
mainWin.webContents.send(IPC.PLUGIN_OAUTH_CB, {
|
||
error: 'invalid_auth_url',
|
||
state,
|
||
});
|
||
cleanupServer();
|
||
return;
|
||
}
|
||
|
||
shell.openExternal(url).catch((err: unknown) => {
|
||
log('Plugin OAuth: Failed to open system browser:', err);
|
||
mainWin.webContents.send(IPC.PLUGIN_OAUTH_CB, {
|
||
error: 'failed_to_open_browser',
|
||
state,
|
||
});
|
||
cleanupServer();
|
||
});
|
||
});
|
||
};
|