mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
* feat(plugins): enable OAuth-based issue-provider plugins Generic, provider-agnostic plugin-framework hooks so an issue-provider plugin that needs an exact OAuth redirect can work without any built-in code: - OAuthFlowConfig.redirectUri: a plugin may declare an exact pre-registered callback; the host uses it for both the authorize request and token exchange. - The Electron loopback honors a plugin-requested fixed port and rejects with a clear message when that port is already in use. - Apply user-supplied clientId/clientSecret/redirectUri overrides onto a plugin's oauthConfig (bring-your-own OAuth app). - Fix: merging a partial pluginConfig update no longer drops omitted keys and deep-merges nested objects (e.g. twoWaySync). Split out of the Basecamp community-plugin work per PR #8507 feedback; contains no provider-specific code. * fix(plugins): address #8546 review - validate OAuthFlowConfig.redirectUri per platform (loopback / same-origin / app scheme) and fail fast instead of hanging; restrict desktop loopback to 127.0.0.1 - warn when a client secret is dropped on web/native (bring-your-own credentials) - validate the IPC loopback port to [1024,65535], register the error handler before listen(), and close the failed server - merge pluginConfig via generic recursion instead of a hardcoded twoWaySync case - nits: named token-store imports; fix stale prepareRedirectUri comment - tests: redirectUri validation, the web client-secret warning, and generic merge * fix(plugins): address #8546 round-2 review - loopback error handler calls cleanupServer() so a post-listen runtime error doesn't leave the server ref / 5-min timer dangling - share OAUTH_LOOPBACK_PORT_{MIN,MAX} between the renderer and Electron main so the bounds never drift; reject out-of-range (incl. 0/80/443) redirect ports early - drop _getElectronLoopbackPort and parse the already-validated redirectUri once - document that a bring-your-own clientSecret syncs via pluginConfig (override boundary) - skip __proto__/constructor/prototype keys in the pluginConfig merge (defense-in-depth) - test: prototype-pollution guard * fix(plugins): address #8546 round-3 review - reject native redirectUri overrides outright (closes CodeQL js/incomplete-url-scheme-check) via a pure, per-platform validateOAuthRedirectUri util (electron loopback / native reject / web same-origin) - gate bring-your-own OAuth credentials to the desktop loopback flow and warn (instead of silently dropping clientId) when set on web/native - namespace BYO under pluginConfig.oauthOverrides (was flat keys); document the convention on OAuthFlowConfig (public plugin API) - shallow top-level pluginConfig merge: drop the deep recursion + proto guard; nested objects (e.g. twoWaySync) are replaced wholesale, matching callers - pin the web redirect to /assets/oauth-callback.html via a shared constant so a same-origin wrong-path URI fails fast; note the desktop 127.0.0.1-only rule - companion tests for each * fix(plugins): strip desktop redirectUri on web/native OAuth flows A plugin-declared redirectUri is the desktop loopback override; keeping it on the web/native branches made a web/native-capable plugin throw at connect time (the loopback URI fails web/native redirectUri validation). Strip it on those branches so prepareRedirectUri falls through to the platform default. Document redirectUri as desktop-only in the plugin API and fix a misleading test name. * refactor(plugins): extract resolveEffectiveOAuthConfig and harden native fallthrough Move the platform client/secret/redirectUri selection out of the bridge into a pure, parameterized util so every branch is unit-testable (the IS_* platform consts are module-level and cannot be mocked in karma). Also strip clientSecret and redirectUri on the native fall-through — a native platform where the plugin ships no matching client id — keeping both strictly desktop-only. Optional hardening on top of the redirectUri fix; safe to drop independently.
168 lines
5.9 KiB
TypeScript
168 lines
5.9 KiB
TypeScript
import { BrowserWindow, ipcMain, shell } from 'electron';
|
||
import { createServer, Server } from 'http';
|
||
import { IPC } from './shared-with-frontend/ipc-events.const';
|
||
import {
|
||
OAUTH_LOOPBACK_PORT_MIN,
|
||
OAUTH_LOOPBACK_PORT_MAX,
|
||
} from './shared-with-frontend/oauth-loopback.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 (_event, requestedPort?: number): Promise<{ port: number }> => {
|
||
cleanupServer();
|
||
|
||
return new Promise<{ port: number }>((resolve, reject) => {
|
||
let handled = false;
|
||
|
||
let port = 0;
|
||
if (requestedPort !== undefined) {
|
||
if (
|
||
!Number.isInteger(requestedPort) ||
|
||
requestedPort < OAUTH_LOOPBACK_PORT_MIN ||
|
||
requestedPort > OAUTH_LOOPBACK_PORT_MAX
|
||
) {
|
||
reject(
|
||
new Error(
|
||
`Invalid OAuth loopback port ${requestedPort}; must be an integer in [${OAUTH_LOOPBACK_PORT_MIN}, ${OAUTH_LOOPBACK_PORT_MAX}].`,
|
||
),
|
||
);
|
||
return;
|
||
}
|
||
port = requestedPort;
|
||
}
|
||
|
||
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.on('error', (err) => {
|
||
cleanupServer();
|
||
// Note: EADDRINUSE message interpolates port which is 0 only in the system-assigned
|
||
// (no requested port) case — where EADDRINUSE effectively cannot occur.
|
||
if ((err as NodeJS.ErrnoException).code === 'EADDRINUSE') {
|
||
reject(
|
||
new Error(
|
||
`OAuth loopback port ${port} is already in use. Close the app using it and try again.`,
|
||
),
|
||
);
|
||
} else {
|
||
reject(err);
|
||
}
|
||
});
|
||
|
||
server.listen(port, 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'));
|
||
}
|
||
});
|
||
});
|
||
},
|
||
);
|
||
|
||
// 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();
|
||
});
|
||
});
|
||
};
|