mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-26 21:39:43 +02:00
fix(desktop):add oauth session handoff
This commit is contained in:
parent
f481d1360a
commit
fcf2485863
4 changed files with 191 additions and 6 deletions
|
|
@ -1,4 +1,4 @@
|
||||||
import { app, ipcMain, shell } from 'electron';
|
import { app, BrowserWindow, ipcMain, shell } from 'electron';
|
||||||
import { IPC_CHANNELS } from './channels';
|
import { IPC_CHANNELS } from './channels';
|
||||||
import {
|
import {
|
||||||
getPermissionsStatus,
|
getPermissionsStatus,
|
||||||
|
|
@ -52,8 +52,59 @@ import {
|
||||||
type AgentFilesystemTreeWatchOptions,
|
type AgentFilesystemTreeWatchOptions,
|
||||||
} from '../modules/agent-filesystem-tree-watcher';
|
} from '../modules/agent-filesystem-tree-watcher';
|
||||||
import { installDownloadedUpdate } from '../modules/auto-updater';
|
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<string | null> | 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<void> {
|
||||||
|
accessToken = tokens.bearer || null;
|
||||||
|
if (tokens.refresh) {
|
||||||
|
await secretStore.set(REFRESH_TOKEN_KEY, tokens.refresh);
|
||||||
|
}
|
||||||
|
broadcastAuthChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAccessToken(): Promise<string | null> {
|
||||||
|
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 {
|
export function registerIpcHandlers(): void {
|
||||||
ipcMain.on(IPC_CHANNELS.OPEN_EXTERNAL, (_event, url: string) => {
|
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 }) => {
|
ipcMain.handle(IPC_CHANNELS.SET_AUTH_TOKENS, async (_event, tokens: { bearer: string; refresh: string }) => {
|
||||||
authTokens = tokens;
|
await storeTokens(tokens);
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle(IPC_CHANNELS.GET_AUTH_TOKENS, () => {
|
ipcMain.handle(IPC_CHANNELS.GET_AUTH_TOKENS, async () => {
|
||||||
return authTokens;
|
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());
|
ipcMain.handle(IPC_CHANNELS.GET_SHORTCUTS, () => getShortcuts());
|
||||||
|
|
|
||||||
80
surfsense_desktop/src/modules/oauth.ts
Normal file
80
surfsense_desktop/src/modules/oauth.ts
Normal file
|
|
@ -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<DesktopAuthTokens> {
|
||||||
|
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());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -94,6 +94,10 @@ export function createMainWindow(initialPath = '/dashboard'): BrowserWindow {
|
||||||
session.defaultSession.webRequest.onBeforeRequest(rewriteFilter, (details, callback) => {
|
session.defaultSession.webRequest.onBeforeRequest(rewriteFilter, (details, callback) => {
|
||||||
try {
|
try {
|
||||||
const u = new URL(details.url);
|
const u = new URL(details.url);
|
||||||
|
if (!u.pathname.includes('/connectors/callback')) {
|
||||||
|
callback({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
const originalHost = u.host;
|
const originalHost = u.host;
|
||||||
const local = new URL(getServerOrigin());
|
const local = new URL(getServerOrigin());
|
||||||
u.protocol = local.protocol;
|
u.protocol = local.protocol;
|
||||||
|
|
|
||||||
7
surfsense_web/types/window.d.ts
vendored
7
surfsense_web/types/window.d.ts
vendored
|
|
@ -143,6 +143,13 @@ interface ElectronAPI {
|
||||||
// Auth token sync across windows
|
// Auth token sync across windows
|
||||||
getAuthTokens: () => Promise<{ bearer: string; refresh: string } | null>;
|
getAuthTokens: () => Promise<{ bearer: string; refresh: string } | null>;
|
||||||
setAuthTokens: (bearer: string, refresh: string) => Promise<void>;
|
setAuthTokens: (bearer: string, refresh: string) => Promise<void>;
|
||||||
|
getAccessToken: () => Promise<string | null>;
|
||||||
|
refreshAccessToken: () => Promise<string | null>;
|
||||||
|
logout: () => Promise<void>;
|
||||||
|
startGoogleOAuth: () => Promise<{ ok: true }>;
|
||||||
|
onAuthChanged: (
|
||||||
|
callback: (payload: { authed: boolean; accessToken: string | null }) => void
|
||||||
|
) => () => void;
|
||||||
// Keyboard shortcut configuration
|
// Keyboard shortcut configuration
|
||||||
getShortcuts: () => Promise<{
|
getShortcuts: () => Promise<{
|
||||||
generalAssist: string;
|
generalAssist: string;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue