diff --git a/surfsense_desktop/.env.example b/surfsense_desktop/.env.example index 83e658db9..42de081af 100644 --- a/surfsense_desktop/.env.example +++ b/surfsense_desktop/.env.example @@ -8,8 +8,9 @@ HOSTED_FRONTEND_URL=http://localhost:3000 # The backend API URL used by desktop auth and refresh flows. HOSTED_BACKEND_URL=http://localhost:8000 -# Public Google OAuth Desktop app client ID. This is required for native -# loopback + PKCE login and is safe to ship in the desktop app. +# Public Google OAuth Desktop app client ID. Required for packaged desktop +# Google login using loopback + PKCE. This is safe to ship in the desktop app; +# the PKCE code verifier, not a client secret, protects the token exchange. GOOGLE_DESKTOP_CLIENT_ID=your_google_desktop_client_id.apps.googleusercontent.com # Runtime override for the above (read at app start, no rebuild required). diff --git a/surfsense_desktop/src/ipc/handlers.ts b/surfsense_desktop/src/ipc/handlers.ts index ab4ba0d92..bc2bdcc20 100644 --- a/surfsense_desktop/src/ipc/handlers.ts +++ b/surfsense_desktop/src/ipc/handlers.ts @@ -54,6 +54,7 @@ import { import { installDownloadedUpdate } from '../modules/auto-updater'; import { secretStore } from '../modules/secret-store'; import { startGoogleOAuth } from '../modules/oauth'; +import { createMainWindow, getMainWindow } from '../modules/window'; const REFRESH_TOKEN_KEY = 'surfsense_refresh_token'; let accessToken: string | null = null; @@ -85,6 +86,16 @@ async function storeTokens(tokens: { bearer: string; refresh?: string | null }): broadcastAuthChanged(); } +function showDashboardAfterAuth(): void { + const win = getMainWindow(); + if (!win || win.isDestroyed()) { + createMainWindow('/dashboard'); + return; + } + win.show(); + win.focus(); +} + async function refreshAccessToken(): Promise { if (refreshInFlight) return refreshInFlight; @@ -266,6 +277,7 @@ export function registerIpcHandlers(): void { } const tokens = await startGoogleOAuth(backendUrl); await storeTokens({ bearer: tokens.access_token, refresh: tokens.refresh_token }); + showDashboardAfterAuth(); return { ok: true }; }); diff --git a/surfsense_desktop/src/modules/oauth-page.ts b/surfsense_desktop/src/modules/oauth-page.ts new file mode 100644 index 000000000..749429587 --- /dev/null +++ b/surfsense_desktop/src/modules/oauth-page.ts @@ -0,0 +1,72 @@ +import http from 'node:http'; + +function escapeHtml(value: string): string { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function renderOAuthPage(title: string, message: string): string { + return ` + + + + + ${escapeHtml(title)} + + + +
+

${escapeHtml(title)}

+

${escapeHtml(message)}

+
+ +`; +} + +export function writeOAuthPage( + res: http.ServerResponse, + statusCode: number, + title: string, + message: string, + _tone?: 'success' | 'error' | 'neutral', +): void { + res + .writeHead(statusCode, { 'content-type': 'text/html; charset=utf-8' }) + .end(renderOAuthPage(title, message)); +} diff --git a/surfsense_desktop/src/modules/oauth.ts b/surfsense_desktop/src/modules/oauth.ts index 82fb61baa..65b1b207b 100644 --- a/surfsense_desktop/src/modules/oauth.ts +++ b/surfsense_desktop/src/modules/oauth.ts @@ -1,12 +1,16 @@ import { shell } from 'electron'; import crypto from 'node:crypto'; import http from 'node:http'; +import { writeOAuthPage } from './oauth-page'; export interface DesktopAuthTokens { access_token: string; refresh_token: string; } +const OAUTH_TIMEOUT_MS = 5 * 60 * 1000; +const OAUTH_CALLBACK_PATH = '/callback'; + function base64Url(buffer: Buffer): string { return buffer.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } @@ -19,62 +23,133 @@ function sha256(value: string): string { return base64Url(crypto.createHash('sha256').update(value).digest()); } +function getGoogleDesktopClientId(): string { + const clientId = (process.env.GOOGLE_DESKTOP_CLIENT_ID || '').trim(); + if (!clientId) { + throw new Error('Google desktop OAuth client ID is not configured'); + } + return clientId; +} + export async function startGoogleOAuth(backendUrl: string): Promise { + const clientId = getGoogleDesktopClientId(); const state = randomUrlSafe(); const codeVerifier = randomUrlSafe(64); const codeChallenge = sha256(codeVerifier); return new Promise((resolve, reject) => { - let address: { port: number }; + let settled = false; + let port: number | null = null; + let timeout: NodeJS.Timeout | null = null; + + const cleanup = () => { + if (timeout) { + clearTimeout(timeout); + timeout = null; + } + if (server.listening) { + server.close(); + } + }; + + const fail = (error: Error) => { + if (settled) return; + settled = true; + cleanup(); + reject(error); + }; + + const succeed = (tokens: DesktopAuthTokens) => { + if (settled) return; + settled = true; + cleanup(); + resolve(tokens); + }; + const server = http.createServer(async (req, res) => { try { const url = new URL(req.url || '/', 'http://127.0.0.1'); - const code = url.searchParams.get('code'); - const returnedState = url.searchParams.get('state'); - if (!code || returnedState !== state) { - res.writeHead(400).end('Authentication failed. You can close this window.'); - reject(new Error('Invalid OAuth callback')); + if (url.pathname !== OAUTH_CALLBACK_PATH) { + writeOAuthPage(res, 404, 'Not found', 'This OAuth callback endpoint is only used by SurfSense.'); return; } - const redirectUri = `http://127.0.0.1:${address.port}/callback`; + const oauthError = url.searchParams.get('error'); + if (oauthError) { + const description = url.searchParams.get('error_description'); + writeOAuthPage(res, 400, 'Authentication failed', 'You can close this window and return to SurfSense.', 'error'); + fail(new Error(description || `Google OAuth failed: ${oauthError}`)); + return; + } + + const code = url.searchParams.get('code'); + const returnedState = url.searchParams.get('state'); + if (!code || returnedState !== state) { + writeOAuthPage(res, 400, 'Authentication failed', 'You can close this window and return to SurfSense.', 'error'); + fail(new Error('Invalid OAuth callback')); + return; + } + + if (!port) { + writeOAuthPage(res, 500, 'Authentication failed', 'You can close this window and return to SurfSense.', 'error'); + fail(new Error('OAuth loopback server was not ready')); + return; + } + + const redirectUri = `http://127.0.0.1:${port}${OAUTH_CALLBACK_PATH}`; const response = await fetch(`${backendUrl}/auth/desktop/session`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code, code_verifier: codeVerifier, redirect_uri: redirectUri }), }); if (!response.ok) { - res.writeHead(401).end('Authentication failed. You can close this window.'); - reject(new Error('Desktop session exchange failed')); + let detail = 'Desktop session exchange failed'; + try { + const error = (await response.json()) as { detail?: string }; + detail = error.detail || detail; + } catch { + // Keep the generic exchange error if the backend did not return JSON. + } + writeOAuthPage(res, 401, 'Authentication failed', 'You can close this window and return to SurfSense.', 'error'); + fail(new Error(detail)); return; } const tokens = (await response.json()) as DesktopAuthTokens; - res.writeHead(200, { 'content-type': 'text/html' }).end('Authentication complete. You can close this window.'); - resolve(tokens); + writeOAuthPage(res, 200, 'Authentication complete', 'You can close this window and return to SurfSense.', 'success'); + succeed(tokens); } catch (error) { - reject(error); - } finally { - server.close(); + fail(error instanceof Error ? error : new Error('Google OAuth failed')); } }); server.listen(0, '127.0.0.1', () => { const addressInfo = server.address(); if (!addressInfo || typeof addressInfo === 'string') { - reject(new Error('Unable to bind loopback OAuth server')); + fail(new Error('Unable to bind loopback OAuth server')); return; } - address = addressInfo; - const redirectUri = `http://127.0.0.1:${address.port}/callback`; + port = addressInfo.port; + timeout = setTimeout(() => { + fail(new Error('Google OAuth timed out')); + }, OAUTH_TIMEOUT_MS); + + const redirectUri = `http://127.0.0.1:${port}${OAUTH_CALLBACK_PATH}`; const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth'); - authUrl.searchParams.set('client_id', process.env.GOOGLE_DESKTOP_CLIENT_ID || ''); + authUrl.searchParams.set('client_id', clientId); authUrl.searchParams.set('redirect_uri', redirectUri); authUrl.searchParams.set('response_type', 'code'); authUrl.searchParams.set('scope', 'openid email profile'); authUrl.searchParams.set('state', state); authUrl.searchParams.set('code_challenge', codeChallenge); authUrl.searchParams.set('code_challenge_method', 'S256'); - shell.openExternal(authUrl.toString()); + + shell.openExternal(authUrl.toString()).catch((error) => { + fail(error instanceof Error ? error : new Error('Unable to open browser for Google OAuth')); + }); + }); + + server.on('error', (error) => { + fail(error); }); }); } diff --git a/surfsense_web/hooks/use-session.ts b/surfsense_web/hooks/use-session.ts index c37c0d603..6bb10456f 100644 --- a/surfsense_web/hooks/use-session.ts +++ b/surfsense_web/hooks/use-session.ts @@ -8,6 +8,15 @@ type SessionState = | { status: "authenticated"; authenticated: true; accessExpiresAt: number | null } | { status: "unauthenticated"; authenticated: false; accessExpiresAt: null }; +async function getSessionHeaders(): Promise { + if (typeof window === "undefined" || !window.electronAPI?.getAccessToken) { + return {}; + } + + const token = await window.electronAPI.getAccessToken(); + return token ? { Authorization: `Bearer ${token}` } : {}; +} + export function useSession() { const [state, setState] = useState({ status: "loading", @@ -19,6 +28,7 @@ export function useSession() { try { const response = await fetch(buildBackendUrl("/auth/session"), { credentials: "include", + headers: await getSessionHeaders(), }); if (!response.ok) { setState({