super-productivity/electron/plugin-oauth.ts
Johannes Millan 25051d2899 fix(plugins): use loopback redirect and system browser for Google OAuth
Google's Desktop OAuth client requires loopback redirect URIs
(http://127.0.0.1:<port>) and blocks embedded webviews. This replaces
the custom URI scheme + BrowserWindow approach with a temporary loopback
HTTP server and shell.openExternal for the system browser.

- Add PLUGIN_OAUTH_PREPARE IPC to start loopback server and return port
- Open auth URL in system browser instead of Electron BrowserWindow
- Make getRedirectUri() async to support IPC port retrieval
- Validate OAuth config before starting loopback server to prevent leaks
- Force 200 status on OPTIONS preflight responses in CORS bypass
2026-03-26 17:43:04 +01:00

120 lines
4.2 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';
let loopbackServer: Server | null = null;
const cleanupServer = (): void => {
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;
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
try {
const parsed = new URL(url);
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',
});
cleanupServer();
return;
}
} catch {
mainWin.webContents.send(IPC.PLUGIN_OAUTH_CB, {
error: 'invalid_auth_url',
});
cleanupServer();
return;
}
// shell.openExternal returns Promise<void> at runtime despite outdated types
const result: unknown = shell.openExternal(url);
if (result && typeof (result as Record<string, unknown>).catch === 'function') {
(result as Promise<void>).catch((err: unknown) => {
log('Plugin OAuth: Failed to open system browser:', err);
mainWin.webContents.send(IPC.PLUGIN_OAUTH_CB, {
error: 'failed_to_open_browser',
});
cleanupServer();
});
}
});
};