super-productivity/electron/plugin-oauth.ts
oon arfiandwi 5ea0570f39
feat(plugins): generic OAuth hooks for issue-provider plugins (#8546)
* 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.
2026-06-30 14:11:00 +02:00

168 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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();
});
});
};