feat(oauth): implement OAuth page rendering and enhance authentication flow

This commit is contained in:
Anish Sarkar 2026-06-24 18:37:04 +05:30
parent c4e35ac21c
commit eb76c02d43
5 changed files with 191 additions and 21 deletions

View file

@ -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).

View file

@ -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<string | null> {
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 };
});

View file

@ -0,0 +1,72 @@
import http from 'node:http';
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function renderOAuthPage(title: string, message: string): string {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${escapeHtml(title)}</title>
<style>
:root {
color-scheme: dark;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #303030;
background: oklch(0.24 0 0);
color: #fafafa;
}
main {
width: min(420px, calc(100vw - 32px));
text-align: center;
}
h1 {
margin: 0 0 12px;
font-size: 24px;
line-height: 1.2;
letter-spacing: -0.02em;
}
p {
margin: 0;
color: #d4d4d4;
line-height: 1.5;
}
</style>
</head>
<body>
<main>
<h1>${escapeHtml(title)}</h1>
<p>${escapeHtml(message)}</p>
</main>
</body>
</html>`;
}
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));
}

View file

@ -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<DesktopAuthTokens> {
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);
});
});
}

View file

@ -8,6 +8,15 @@ type SessionState =
| { status: "authenticated"; authenticated: true; accessExpiresAt: number | null }
| { status: "unauthenticated"; authenticated: false; accessExpiresAt: null };
async function getSessionHeaders(): Promise<HeadersInit> {
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<SessionState>({
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({