From fcf2485863d5d545cd87ec092f875f7809788b72 Mon Sep 17 00:00:00 2001 From: Anish Sarkar <104695310+AnishSarkar22@users.noreply.github.com> Date: Tue, 23 Jun 2026 12:55:43 +0530 Subject: [PATCH] fix(desktop):add oauth session handoff --- surfsense_desktop/src/ipc/handlers.ts | 106 ++++++++++++++++++++++-- surfsense_desktop/src/modules/oauth.ts | 80 ++++++++++++++++++ surfsense_desktop/src/modules/window.ts | 4 + surfsense_web/types/window.d.ts | 7 ++ 4 files changed, 191 insertions(+), 6 deletions(-) create mode 100644 surfsense_desktop/src/modules/oauth.ts diff --git a/surfsense_desktop/src/ipc/handlers.ts b/surfsense_desktop/src/ipc/handlers.ts index ed7eaac66..4ca98a9e6 100644 --- a/surfsense_desktop/src/ipc/handlers.ts +++ b/surfsense_desktop/src/ipc/handlers.ts @@ -1,4 +1,4 @@ -import { app, ipcMain, shell } from 'electron'; +import { app, BrowserWindow, ipcMain, shell } from 'electron'; import { IPC_CHANNELS } from './channels'; import { getPermissionsStatus, @@ -52,8 +52,59 @@ import { type AgentFilesystemTreeWatchOptions, } from '../modules/agent-filesystem-tree-watcher'; import { installDownloadedUpdate } from '../modules/auto-updater'; +import { secretStore } from '../modules/secret-store'; +import { startGoogleOAuth } from '../modules/oauth'; -let authTokens: { bearer: string; refresh: string } | null = null; +const REFRESH_TOKEN_KEY = 'surfsense_refresh_token'; +let accessToken: string | null = null; +let refreshInFlight: Promise | null = null; + +function getBackendUrl(): string { + return (process.env.HOSTED_BACKEND_URL || process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || '').replace( + /\/+$/, + '' + ); +} + +function broadcastAuthChanged(): void { + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.send(IPC_CHANNELS.AUTH_CHANGED, { authed: !!accessToken, accessToken }); + } +} + +async function storeTokens(tokens: { bearer: string; refresh?: string | null }): Promise { + accessToken = tokens.bearer || null; + if (tokens.refresh) { + await secretStore.set(REFRESH_TOKEN_KEY, tokens.refresh); + } + broadcastAuthChanged(); +} + +async function refreshAccessToken(): Promise { + if (refreshInFlight) return refreshInFlight; + + refreshInFlight = (async () => { + const refresh = await secretStore.get(REFRESH_TOKEN_KEY); + const backendUrl = getBackendUrl(); + if (!refresh || !backendUrl) return null; + + const response = await fetch(`${backendUrl}/auth/jwt/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: refresh }), + }); + if (!response.ok) return null; + + const data = (await response.json()) as { access_token?: string; refresh_token?: string | null }; + if (!data.access_token) return null; + await storeTokens({ bearer: data.access_token, refresh: data.refresh_token }); + return data.access_token; + })().finally(() => { + refreshInFlight = null; + }); + + return refreshInFlight; +} export function registerIpcHandlers(): void { ipcMain.on(IPC_CHANNELS.OPEN_EXTERNAL, (_event, url: string) => { @@ -173,12 +224,55 @@ export function registerIpcHandlers(): void { } ); - ipcMain.handle(IPC_CHANNELS.SET_AUTH_TOKENS, (_event, tokens: { bearer: string; refresh: string }) => { - authTokens = tokens; + ipcMain.handle(IPC_CHANNELS.SET_AUTH_TOKENS, async (_event, tokens: { bearer: string; refresh: string }) => { + await storeTokens(tokens); }); - ipcMain.handle(IPC_CHANNELS.GET_AUTH_TOKENS, () => { - return authTokens; + ipcMain.handle(IPC_CHANNELS.GET_AUTH_TOKENS, async () => { + if (!accessToken) { + await refreshAccessToken(); + } + return accessToken ? { bearer: accessToken, refresh: '' } : null; + }); + + ipcMain.handle(IPC_CHANNELS.GET_ACCESS_TOKEN, async () => { + if (!accessToken) { + await refreshAccessToken(); + } + return accessToken; + }); + + ipcMain.handle(IPC_CHANNELS.REFRESH_ACCESS_TOKEN, () => { + return refreshAccessToken(); + }); + + ipcMain.handle(IPC_CHANNELS.LOGOUT, async () => { + const backendUrl = getBackendUrl(); + const refresh = await secretStore.get(REFRESH_TOKEN_KEY); + if (backendUrl && refresh) { + try { + await fetch(`${backendUrl}/auth/jwt/revoke`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refresh_token: refresh }), + }); + } catch { + // Local logout is fail-closed even if the server revoke call fails. + } + } + accessToken = null; + await secretStore.clear(REFRESH_TOKEN_KEY); + broadcastAuthChanged(); + }); + + ipcMain.handle(IPC_CHANNELS.AUTH_START_GOOGLE, async () => { + const backendUrl = getBackendUrl(); + if (!backendUrl) { + throw new Error('Backend URL is not configured'); + } + const tokens = await startGoogleOAuth(backendUrl); + await storeTokens({ bearer: tokens.access_token, refresh: tokens.refresh_token }); + return { ok: true }; }); ipcMain.handle(IPC_CHANNELS.GET_SHORTCUTS, () => getShortcuts()); diff --git a/surfsense_desktop/src/modules/oauth.ts b/surfsense_desktop/src/modules/oauth.ts new file mode 100644 index 000000000..82fb61baa --- /dev/null +++ b/surfsense_desktop/src/modules/oauth.ts @@ -0,0 +1,80 @@ +import { shell } from 'electron'; +import crypto from 'node:crypto'; +import http from 'node:http'; + +export interface DesktopAuthTokens { + access_token: string; + refresh_token: string; +} + +function base64Url(buffer: Buffer): string { + return buffer.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +function randomUrlSafe(bytes = 32): string { + return base64Url(crypto.randomBytes(bytes)); +} + +function sha256(value: string): string { + return base64Url(crypto.createHash('sha256').update(value).digest()); +} + +export async function startGoogleOAuth(backendUrl: string): Promise { + const state = randomUrlSafe(); + const codeVerifier = randomUrlSafe(64); + const codeChallenge = sha256(codeVerifier); + + return new Promise((resolve, reject) => { + let address: { port: number }; + 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')); + return; + } + + const redirectUri = `http://127.0.0.1:${address.port}/callback`; + 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')); + 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); + } catch (error) { + reject(error); + } finally { + server.close(); + } + }); + + 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')); + return; + } + address = addressInfo; + const redirectUri = `http://127.0.0.1:${address.port}/callback`; + 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('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()); + }); + }); +} diff --git a/surfsense_desktop/src/modules/window.ts b/surfsense_desktop/src/modules/window.ts index 42011d089..bfcd9b512 100644 --- a/surfsense_desktop/src/modules/window.ts +++ b/surfsense_desktop/src/modules/window.ts @@ -94,6 +94,10 @@ export function createMainWindow(initialPath = '/dashboard'): BrowserWindow { session.defaultSession.webRequest.onBeforeRequest(rewriteFilter, (details, callback) => { try { const u = new URL(details.url); + if (!u.pathname.includes('/connectors/callback')) { + callback({}); + return; + } const originalHost = u.host; const local = new URL(getServerOrigin()); u.protocol = local.protocol; diff --git a/surfsense_web/types/window.d.ts b/surfsense_web/types/window.d.ts index 2d12169b1..60fb9bd66 100644 --- a/surfsense_web/types/window.d.ts +++ b/surfsense_web/types/window.d.ts @@ -143,6 +143,13 @@ interface ElectronAPI { // Auth token sync across windows getAuthTokens: () => Promise<{ bearer: string; refresh: string } | null>; setAuthTokens: (bearer: string, refresh: string) => Promise; + getAccessToken: () => Promise; + refreshAccessToken: () => Promise; + logout: () => Promise; + startGoogleOAuth: () => Promise<{ ok: true }>; + onAuthChanged: ( + callback: (payload: { authed: boolean; accessToken: string | null }) => void + ) => () => void; // Keyboard shortcut configuration getShortcuts: () => Promise<{ generalAssist: string;